inkbridge 0.1.0-beta.2 → 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 +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
MOBILE_ONLY_DEFAULT_WIDTH,
|
|
4
|
+
VIEWPORT_HEIGHTS,
|
|
5
|
+
collectLeadingSpineClasses,
|
|
6
|
+
detectViewportHeightPattern,
|
|
7
|
+
mobileOnlyRootWidth,
|
|
8
|
+
treeHasDescendantClass,
|
|
9
|
+
treeHasPortalWithFullHeight,
|
|
10
|
+
} from '../src/design-system/story-dimensioning';
|
|
11
|
+
import type { JsxNode } from '../src/tailwind';
|
|
12
|
+
import type { ComponentStory } from '../src/components';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Regression: the story-dimensioning helpers decide the desktop width and
|
|
16
|
+
* height of every Story Layout frame. Drift here resizes every story
|
|
17
|
+
* differently — mobile-only nav stretches to 900px again (the "empty strip"
|
|
18
|
+
* bug), drawer sheets collapse to 0 height, full-page shells lose their
|
|
19
|
+
* synthetic viewport. Lock the heuristics.
|
|
20
|
+
*
|
|
21
|
+
* Extracted from `src/design-system/story-builder.ts` into
|
|
22
|
+
* `src/design-system/story-dimensioning.ts`. The Figma-API-touching entry
|
|
23
|
+
* points (resolveStoryLayoutWidth / Height) are exercised in real Figma
|
|
24
|
+
* during plugin runs — this fixture covers the pure detectors that decide
|
|
25
|
+
* which width/height bucket a story lands in.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
function el(props: Record<string, unknown>, children: JsxNode[] = []): JsxNode {
|
|
29
|
+
return { type: 'element', tagName: 'div', props, children } as unknown as JsxNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function story(jsxTree: JsxNode | null): ComponentStory {
|
|
33
|
+
return { name: 'TestStory', jsxTree } as unknown as ComponentStory;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// collectLeadingSpineClasses — walks single-child spine, stops on fan-out
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// Single-element spine: classes appear in the output.
|
|
41
|
+
{
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
collectLeadingSpineClasses(el({ className: 'flex flex-col h-full' }), out);
|
|
44
|
+
assert.deepEqual(out, ['flex', 'flex-col', 'h-full'], 'root classes split + collected');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Multi-level spine — each parent has exactly one element child.
|
|
48
|
+
{
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
const tree = el({ className: 'outer' }, [el({ className: 'middle' }, [el({ className: 'inner' })])]);
|
|
51
|
+
collectLeadingSpineClasses(tree, out);
|
|
52
|
+
assert.deepEqual(out, ['outer', 'middle', 'inner'], 'spine walks through single-child wrappers');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fan-out stops the walk — siblings are body, not spine.
|
|
56
|
+
{
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
const tree = el({ className: 'root' }, [
|
|
59
|
+
el({ className: 'child-a' }),
|
|
60
|
+
el({ className: 'child-b' }),
|
|
61
|
+
]);
|
|
62
|
+
collectLeadingSpineClasses(tree, out);
|
|
63
|
+
assert.deepEqual(out, ['root'], 'fan-out stops the walk — sibling classes are body, not spine');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Depth limit (>4) caps the walk so pathological deep wrappers don't hang.
|
|
67
|
+
{
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
// Build 6 levels of single-child wrappers.
|
|
70
|
+
let leaf: JsxNode = el({ className: 'L5' });
|
|
71
|
+
for (let i = 4; i >= 0; i--) leaf = el({ className: 'L' + i }, [leaf]);
|
|
72
|
+
collectLeadingSpineClasses(leaf, out);
|
|
73
|
+
// 4 levels max → L0..L3 collected, L4/L5 dropped.
|
|
74
|
+
assert.deepEqual(out, ['L0', 'L1', 'L2', 'L3'], 'depth limit caps spine walk at 4 levels');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Non-element / null / no className → no-op, no throw.
|
|
78
|
+
{
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
collectLeadingSpineClasses(el({}), out);
|
|
81
|
+
assert.deepEqual(out, [], 'element without className contributes nothing');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// treeHasDescendantClass — depth-limited descendant scan
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
assert.equal(
|
|
89
|
+
treeHasDescendantClass(el({ className: 'flex-1' }), 'flex-1'),
|
|
90
|
+
true,
|
|
91
|
+
'class on the root counts',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert.equal(
|
|
95
|
+
treeHasDescendantClass(el({ className: 'flex' }, [el({ className: 'flex-1' })]), 'flex-1'),
|
|
96
|
+
true,
|
|
97
|
+
'class on a direct child is found',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
assert.equal(
|
|
101
|
+
treeHasDescendantClass(el({ className: 'flex' }), 'flex-1'),
|
|
102
|
+
false,
|
|
103
|
+
'class absent — returns false (not undefined)',
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Depth limit (>6) — pathological deep tree without the class returns false
|
|
107
|
+
// without throwing or hanging.
|
|
108
|
+
{
|
|
109
|
+
let deep: JsxNode = el({ className: 'flex-1' });
|
|
110
|
+
for (let i = 0; i < 10; i++) deep = el({ className: 'wrapper' }, [deep]);
|
|
111
|
+
// The flex-1 is buried 10 levels deep; we cap at 6 so it should not be found.
|
|
112
|
+
assert.equal(
|
|
113
|
+
treeHasDescendantClass(deep, 'flex-1'),
|
|
114
|
+
false,
|
|
115
|
+
'depth limit (>6) prevents pathological deep scans from finding deeply-nested matches',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// treeHasPortalWithFullHeight — portal anchor detection
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
// Portal with h-full + descendant flex-1 → true (Sheet content shape)
|
|
124
|
+
{
|
|
125
|
+
const tree = el({}, [
|
|
126
|
+
el(
|
|
127
|
+
{ __fromPortal: true, className: 'h-full' },
|
|
128
|
+
[el({ className: 'flex-1' })],
|
|
129
|
+
),
|
|
130
|
+
]);
|
|
131
|
+
assert.equal(
|
|
132
|
+
treeHasPortalWithFullHeight(tree),
|
|
133
|
+
true,
|
|
134
|
+
'portal subtree with h-full + descendant flex-1 must trigger drawer-height pattern',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// inset-y-0 also qualifies (sheet content variant)
|
|
139
|
+
{
|
|
140
|
+
const tree = el({}, [
|
|
141
|
+
el(
|
|
142
|
+
{ __fromPortal: true, className: 'inset-y-0 absolute' },
|
|
143
|
+
[el({ className: 'flex-1' })],
|
|
144
|
+
),
|
|
145
|
+
]);
|
|
146
|
+
assert.equal(
|
|
147
|
+
treeHasPortalWithFullHeight(tree),
|
|
148
|
+
true,
|
|
149
|
+
'portal subtree with inset-y-0 + descendant flex-1 also triggers drawer-height',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Portal without flex-1 descendant → false (no grow cascade to fill)
|
|
154
|
+
{
|
|
155
|
+
const tree = el({}, [
|
|
156
|
+
el({ __fromPortal: true, className: 'h-full' }, [el({ className: 'p-4' })]),
|
|
157
|
+
]);
|
|
158
|
+
assert.equal(
|
|
159
|
+
treeHasPortalWithFullHeight(tree),
|
|
160
|
+
false,
|
|
161
|
+
'portal without descendant flex-1 must not trigger drawer-height — nothing would fill',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Portal without h-full → false (no anchor intent)
|
|
166
|
+
{
|
|
167
|
+
const tree = el({}, [
|
|
168
|
+
el({ __fromPortal: true, className: 'p-4' }, [el({ className: 'flex-1' })]),
|
|
169
|
+
]);
|
|
170
|
+
assert.equal(
|
|
171
|
+
treeHasPortalWithFullHeight(tree),
|
|
172
|
+
false,
|
|
173
|
+
'portal without h-full/h-screen/inset-y-0 is not a viewport anchor',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Non-portal h-full + flex-1 → false (this case is handled by the spine-h-full
|
|
178
|
+
// path in detectViewportHeightPattern, not the portal path)
|
|
179
|
+
{
|
|
180
|
+
const tree = el({ className: 'h-full' }, [el({ className: 'flex-1' })]);
|
|
181
|
+
assert.equal(
|
|
182
|
+
treeHasPortalWithFullHeight(tree),
|
|
183
|
+
false,
|
|
184
|
+
'__fromPortal must be true — spine h-full is a separate signal path',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// detectViewportHeightPattern — bucket selection
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
// h-screen on the spine → FULL_PAGE (900).
|
|
193
|
+
assert.equal(
|
|
194
|
+
detectViewportHeightPattern(story(el({ className: 'h-screen' })), []),
|
|
195
|
+
VIEWPORT_HEIGHTS.FULL_PAGE,
|
|
196
|
+
'h-screen on the spine triggers FULL_PAGE (900)',
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// min-h-screen on the spine → FULL_PAGE.
|
|
200
|
+
assert.equal(
|
|
201
|
+
detectViewportHeightPattern(story(el({ className: 'min-h-screen' })), []),
|
|
202
|
+
VIEWPORT_HEIGHTS.FULL_PAGE,
|
|
203
|
+
'min-h-screen on the spine triggers FULL_PAGE',
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// h-full + flex-col on spine + descendant flex-1 → DRAWER (700).
|
|
207
|
+
{
|
|
208
|
+
const tree = el({ className: 'h-full flex flex-col' }, [
|
|
209
|
+
el({ className: 'h-12' }),
|
|
210
|
+
el({ className: 'flex-1' }),
|
|
211
|
+
]);
|
|
212
|
+
assert.equal(
|
|
213
|
+
detectViewportHeightPattern(story(tree), []),
|
|
214
|
+
VIEWPORT_HEIGHTS.DRAWER,
|
|
215
|
+
'h-full + flex-col + descendant flex-1 triggers DRAWER (700)',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// h-full alone without flex-col → null (not a drawer)
|
|
220
|
+
assert.equal(
|
|
221
|
+
detectViewportHeightPattern(
|
|
222
|
+
story(el({ className: 'h-full' }, [el({ className: 'flex-1' })])),
|
|
223
|
+
[],
|
|
224
|
+
),
|
|
225
|
+
null,
|
|
226
|
+
'h-full without flex-col on the spine does not trigger drawer height — needs the vertical-flex commitment',
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Layout classes (decorator wrapper) also contribute to spine detection.
|
|
230
|
+
assert.equal(
|
|
231
|
+
detectViewportHeightPattern(story(el({})), ['h-screen']),
|
|
232
|
+
VIEWPORT_HEIGHTS.FULL_PAGE,
|
|
233
|
+
'layoutClasses from the story decorator wrapper feed into spine detection',
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// No viewport anchor → null.
|
|
237
|
+
assert.equal(
|
|
238
|
+
detectViewportHeightPattern(story(el({ className: 'p-4' })), []),
|
|
239
|
+
null,
|
|
240
|
+
'plain story without viewport-anchor classes returns null — natural content height',
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Null jsxTree → null without throw.
|
|
244
|
+
assert.equal(
|
|
245
|
+
detectViewportHeightPattern(story(null), []),
|
|
246
|
+
null,
|
|
247
|
+
'null tree is tolerated (no jsxTree from scanner) — returns null',
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// mobileOnlyRootWidth — {bp}:hidden detection
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
// md:hidden on the root → mobile width
|
|
255
|
+
assert.equal(
|
|
256
|
+
mobileOnlyRootWidth(story(el({ className: 'md:hidden flex' })), []),
|
|
257
|
+
MOBILE_ONLY_DEFAULT_WIDTH,
|
|
258
|
+
'md:hidden on the root signals mobile-only design',
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// sm/lg/xl/2xl:hidden all qualify
|
|
262
|
+
for (const bp of ['sm', 'lg', 'xl', '2xl'] as const) {
|
|
263
|
+
assert.equal(
|
|
264
|
+
mobileOnlyRootWidth(story(el({ className: bp + ':hidden' })), []),
|
|
265
|
+
MOBILE_ONLY_DEFAULT_WIDTH,
|
|
266
|
+
`${bp}:hidden on the root signals mobile-only design`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Layout classes alone qualify (decorator-only mobile-only design)
|
|
271
|
+
assert.equal(
|
|
272
|
+
mobileOnlyRootWidth(story(el({})), ['md:hidden']),
|
|
273
|
+
MOBILE_ONLY_DEFAULT_WIDTH,
|
|
274
|
+
'mobile-only signal can come from layoutClasses too',
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// No hidden class → null
|
|
278
|
+
assert.equal(
|
|
279
|
+
mobileOnlyRootWidth(story(el({ className: 'flex' })), []),
|
|
280
|
+
null,
|
|
281
|
+
'no breakpoint-hidden class returns null — desktop default applies',
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// `hidden` alone (no breakpoint) → null (that's a full hide, not mobile-only)
|
|
285
|
+
assert.equal(
|
|
286
|
+
mobileOnlyRootWidth(story(el({ className: 'hidden' })), []),
|
|
287
|
+
null,
|
|
288
|
+
'plain `hidden` (no breakpoint prefix) is not a mobile-only signal',
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Null tree but layoutClasses present → still works
|
|
292
|
+
assert.equal(
|
|
293
|
+
mobileOnlyRootWidth(story(null), ['xl:hidden']),
|
|
294
|
+
MOBILE_ONLY_DEFAULT_WIDTH,
|
|
295
|
+
'null tree falls back to layoutClasses scan',
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
console.log('story-dimensioning-regression: PASS');
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
INSTANCE_FALLBACK_COMPONENTS,
|
|
4
|
+
exceedsStoryJsxComplexityLimits,
|
|
5
|
+
shouldPreferInstanceRendering,
|
|
6
|
+
shouldSkipStoryJsxTree,
|
|
7
|
+
} from '../src/design-system/story-render-strategy';
|
|
8
|
+
import type { JsxNode } from '../src/tailwind';
|
|
9
|
+
import type { ComponentDef, ComponentStory } from '../src/components';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Regression: the story-render-strategy module decides whether each story
|
|
13
|
+
* renders as a tree (full JSX expansion) or as a symbol instance. Three
|
|
14
|
+
* decision points:
|
|
15
|
+
*
|
|
16
|
+
* - `exceedsStoryJsxComplexityLimits` — the budget guard (320 nodes / 36
|
|
17
|
+
* depth / 1800 class tokens). Blowing one of these silently in a refactor
|
|
18
|
+
* would make the plugin chew on pathological trees and freeze Figma.
|
|
19
|
+
* - `shouldSkipStoryJsxTree` — combines the budget guard with the explicit
|
|
20
|
+
* fallback list (currently just `switch`).
|
|
21
|
+
* - `shouldPreferInstanceRendering` — CVA / state matrix preference.
|
|
22
|
+
*
|
|
23
|
+
* Extracted from `src/design-system/story-builder.ts` into
|
|
24
|
+
* `src/design-system/story-render-strategy.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function el(props: Record<string, unknown> = {}, children: JsxNode[] = []): JsxNode {
|
|
28
|
+
return { type: 'element', tagName: 'div', props, children } as unknown as JsxNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function story(jsxTree: JsxNode | null, instances?: unknown[]): ComponentStory {
|
|
32
|
+
return { name: 'TestStory', jsxTree, instances: instances ?? [] } as unknown as ComponentStory;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function def(overrides: Partial<ComponentDef> & { name: string }): ComponentDef {
|
|
36
|
+
return { type: 'simple', stories: [], ...overrides } as ComponentDef;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// INSTANCE_FALLBACK_COMPONENTS — the explicit fallback list
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
assert.equal(
|
|
44
|
+
INSTANCE_FALLBACK_COMPONENTS.has('switch'),
|
|
45
|
+
true,
|
|
46
|
+
'switch must be on the fallback list — its indicator slot is hard to reconstruct from a JSX tree',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Membership is canonical (no other accidental entries that would change rendering for unrelated components).
|
|
50
|
+
assert.equal(
|
|
51
|
+
INSTANCE_FALLBACK_COMPONENTS.size,
|
|
52
|
+
1,
|
|
53
|
+
'fallback list size — adding a new entry is an intentional decision; bump this assertion alongside the addition',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// exceedsStoryJsxComplexityLimits — node / depth / class-token limits
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// Null / undefined / non-tree inputs return false (defensive).
|
|
61
|
+
assert.equal(exceedsStoryJsxComplexityLimits(null), false, 'null tree is under budget');
|
|
62
|
+
assert.equal(exceedsStoryJsxComplexityLimits(undefined), false, 'undefined tree is under budget');
|
|
63
|
+
|
|
64
|
+
// Simple small tree → under budget.
|
|
65
|
+
{
|
|
66
|
+
const tree = el({}, [el({ className: 'p-4' }), el({ className: 'text-sm' })]);
|
|
67
|
+
assert.equal(
|
|
68
|
+
exceedsStoryJsxComplexityLimits(tree),
|
|
69
|
+
false,
|
|
70
|
+
'a 3-node tree with 2 class tokens is well under budget',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Tree with >320 nodes → exceeds budget.
|
|
75
|
+
{
|
|
76
|
+
const root = el({}, []);
|
|
77
|
+
const rootChildren = (root as unknown as { children: JsxNode[] }).children;
|
|
78
|
+
for (let i = 0; i < 400; i++) rootChildren.push(el({ className: 'x' }));
|
|
79
|
+
assert.equal(
|
|
80
|
+
exceedsStoryJsxComplexityLimits(root),
|
|
81
|
+
true,
|
|
82
|
+
'tree with 400+ nodes exceeds STORY_JSX_NODE_LIMIT (320)',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Tree depth >36 → exceeds budget.
|
|
87
|
+
{
|
|
88
|
+
let deep: JsxNode = el({});
|
|
89
|
+
for (let i = 0; i < 40; i++) deep = el({}, [deep]);
|
|
90
|
+
assert.equal(
|
|
91
|
+
exceedsStoryJsxComplexityLimits(deep),
|
|
92
|
+
true,
|
|
93
|
+
'tree depth >36 exceeds STORY_JSX_DEPTH_LIMIT',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Tree with >1800 class tokens → exceeds budget.
|
|
98
|
+
{
|
|
99
|
+
// 5 children each with a className containing ~400 tokens → 2000 total tokens.
|
|
100
|
+
const wideClass = Array.from({ length: 400 }, (_, i) => `c${i}`).join(' ');
|
|
101
|
+
const root = el({ className: 'root' }, [
|
|
102
|
+
el({ className: wideClass }),
|
|
103
|
+
el({ className: wideClass }),
|
|
104
|
+
el({ className: wideClass }),
|
|
105
|
+
el({ className: wideClass }),
|
|
106
|
+
el({ className: wideClass }),
|
|
107
|
+
]);
|
|
108
|
+
assert.equal(
|
|
109
|
+
exceedsStoryJsxComplexityLimits(root),
|
|
110
|
+
true,
|
|
111
|
+
'tree with >1800 class tokens exceeds STORY_JSX_CLASS_TOKEN_LIMIT',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Modest tree right at the edge → under budget.
|
|
116
|
+
{
|
|
117
|
+
// 100 children with 10 class tokens each = 1000 class tokens — well under 1800.
|
|
118
|
+
const root = el({}, []);
|
|
119
|
+
const rootChildren = (root as unknown as { children: JsxNode[] }).children;
|
|
120
|
+
for (let i = 0; i < 100; i++) {
|
|
121
|
+
rootChildren.push(el({ className: 'a b c d e f g h i j' }));
|
|
122
|
+
}
|
|
123
|
+
assert.equal(
|
|
124
|
+
exceedsStoryJsxComplexityLimits(root),
|
|
125
|
+
false,
|
|
126
|
+
'a 101-node tree with 1000 class tokens is under all three limits',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// shouldSkipStoryJsxTree — fallback list + complexity guard combined
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
// No tree → no skip needed (the tree path won't run anyway).
|
|
135
|
+
assert.equal(
|
|
136
|
+
shouldSkipStoryJsxTree(def({ name: 'Button' }), story(null)),
|
|
137
|
+
false,
|
|
138
|
+
'no jsxTree → no skip decision needed',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Tree present, def on fallback list → skip.
|
|
142
|
+
assert.equal(
|
|
143
|
+
shouldSkipStoryJsxTree(def({ name: 'switch' }), story(el({}))),
|
|
144
|
+
true,
|
|
145
|
+
'fallback-list def with tree must skip tree rendering',
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Tree present, simple component, small tree → do not skip.
|
|
149
|
+
assert.equal(
|
|
150
|
+
shouldSkipStoryJsxTree(def({ name: 'Button' }), story(el({ className: 'p-4' }))),
|
|
151
|
+
false,
|
|
152
|
+
'simple component with small tree must NOT skip — tree rendering wins for fidelity',
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Tree present, simple component, oversized tree → skip due to complexity.
|
|
156
|
+
{
|
|
157
|
+
const root = el({}, []);
|
|
158
|
+
const rootChildren = (root as unknown as { children: JsxNode[] }).children;
|
|
159
|
+
for (let i = 0; i < 400; i++) rootChildren.push(el({}));
|
|
160
|
+
assert.equal(
|
|
161
|
+
shouldSkipStoryJsxTree(def({ name: 'Button' }), story(root)),
|
|
162
|
+
true,
|
|
163
|
+
'oversized tree must skip even for normal components',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// shouldPreferInstanceRendering — CVA / state matrix preference
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
// No def or no story → false (defensive).
|
|
172
|
+
assert.equal(
|
|
173
|
+
shouldPreferInstanceRendering(null as unknown as ComponentDef, story(null)),
|
|
174
|
+
false,
|
|
175
|
+
'null def returns false defensively',
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Empty defName → false.
|
|
179
|
+
assert.equal(
|
|
180
|
+
shouldPreferInstanceRendering(def({ name: '' }), story(null)),
|
|
181
|
+
false,
|
|
182
|
+
'def without name returns false',
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// CVA component with no matching instance → false (nothing to render as instance).
|
|
186
|
+
assert.equal(
|
|
187
|
+
shouldPreferInstanceRendering(
|
|
188
|
+
def({ name: 'Button', type: 'cva' }),
|
|
189
|
+
story(null, [{ componentName: 'OtherComponent' }]),
|
|
190
|
+
),
|
|
191
|
+
false,
|
|
192
|
+
'CVA def whose story has no matching instance returns false (no instance to render)',
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Switch is on the fallback list — even with matching instance, preference is false (uses different path).
|
|
196
|
+
assert.equal(
|
|
197
|
+
shouldPreferInstanceRendering(
|
|
198
|
+
def({ name: 'switch', type: 'state' }),
|
|
199
|
+
story(null, [{ componentName: 'switch', props: {} }]),
|
|
200
|
+
),
|
|
201
|
+
false,
|
|
202
|
+
'switch is on the fallback list and should never prefer instance rendering — uses its own path',
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
console.log('story-render-strategy-regression: PASS');
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { shouldStretchToParentWidth } from '../src/layout/width-solver';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: `shouldStretchToParentWidth(tag, classes)` decides whether a
|
|
7
|
+
* block-level element should layoutAlign=STRETCH inside a VERTICAL parent
|
|
8
|
+
* — the rule that turns a `<section>` into a full-width hero banner.
|
|
9
|
+
*
|
|
10
|
+
* The bug class: every "explicit width" predicate in the plugin must agree
|
|
11
|
+
* on what counts as explicit. `hasExplicitSize` understood `size-N` /
|
|
12
|
+
* `size-full`. `shouldStretchToParentWidth` didn't — so a `<div
|
|
13
|
+
* className="size-10 rounded-full">` (the shadcn Avatar.Root) got
|
|
14
|
+
* STRETCH'd to 900px-wide-pill inside a VERTICAL story root, while the
|
|
15
|
+
* generic-element depth=0 override correctly left it alone. Two
|
|
16
|
+
* independent predicates, two different answers, one broken Avatar.
|
|
17
|
+
*
|
|
18
|
+
* This file locks the predicate so any future drift triggers a fixture
|
|
19
|
+
* failure instead of a Figma rendering regression.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Block tag with no width signal → STRETCH (default block behaviour).
|
|
23
|
+
assert.equal(
|
|
24
|
+
shouldStretchToParentWidth('div', ['flex', 'gap-4']),
|
|
25
|
+
true,
|
|
26
|
+
'plain div with layout classes → stretches to parent width',
|
|
27
|
+
);
|
|
28
|
+
assert.equal(
|
|
29
|
+
shouldStretchToParentWidth('section', ['bg-card']),
|
|
30
|
+
true,
|
|
31
|
+
'plain section → stretches',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Inline tags — never stretch.
|
|
35
|
+
assert.equal(
|
|
36
|
+
shouldStretchToParentWidth('span', ['flex']),
|
|
37
|
+
false,
|
|
38
|
+
'span (non-BLOCK_TAG) does not stretch',
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Out-of-flow positioning — never stretch.
|
|
42
|
+
assert.equal(
|
|
43
|
+
shouldStretchToParentWidth('div', ['absolute', 'inset-0']),
|
|
44
|
+
false,
|
|
45
|
+
'absolute-positioned div does not stretch',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Explicit width — never stretch.
|
|
49
|
+
assert.equal(
|
|
50
|
+
shouldStretchToParentWidth('div', ['w-10']),
|
|
51
|
+
false,
|
|
52
|
+
'w-N suppresses stretch',
|
|
53
|
+
);
|
|
54
|
+
assert.equal(
|
|
55
|
+
shouldStretchToParentWidth('div', ['w-[200px]']),
|
|
56
|
+
false,
|
|
57
|
+
'arbitrary w-[N] suppresses stretch',
|
|
58
|
+
);
|
|
59
|
+
assert.equal(
|
|
60
|
+
shouldStretchToParentWidth('div', ['max-w-md']),
|
|
61
|
+
false,
|
|
62
|
+
'max-w-* suppresses stretch (caps width)',
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
shouldStretchToParentWidth('div', ['min-w-0']),
|
|
66
|
+
false,
|
|
67
|
+
'min-w-* suppresses stretch',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// self-* — never stretch (explicit alignment trumps).
|
|
71
|
+
assert.equal(
|
|
72
|
+
shouldStretchToParentWidth('div', ['self-start']),
|
|
73
|
+
false,
|
|
74
|
+
'self-* alignment suppresses stretch',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// size-N (the regression case) — must NOT stretch. This used to slip
|
|
78
|
+
// through because the bailout only checked `w-*`. Result: shadcn
|
|
79
|
+
// `<Avatar className="size-10 ...">` rendered as a 900-wide pill at
|
|
80
|
+
// story root.
|
|
81
|
+
assert.equal(
|
|
82
|
+
shouldStretchToParentWidth('div', ['relative', 'flex', 'size-10', 'shrink-0', 'overflow-hidden', 'rounded-full']),
|
|
83
|
+
false,
|
|
84
|
+
'size-N (Avatar.Root pattern) must suppress stretch — was the FallbackOnly bug',
|
|
85
|
+
);
|
|
86
|
+
assert.equal(
|
|
87
|
+
shouldStretchToParentWidth('div', ['size-8']),
|
|
88
|
+
false,
|
|
89
|
+
'size-8 alone suppresses stretch',
|
|
90
|
+
);
|
|
91
|
+
assert.equal(
|
|
92
|
+
shouldStretchToParentWidth('div', ['size-[40px]']),
|
|
93
|
+
false,
|
|
94
|
+
'arbitrary size-[N] suppresses stretch',
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Inline displays — never stretch.
|
|
98
|
+
assert.equal(
|
|
99
|
+
shouldStretchToParentWidth('div', ['inline-flex']),
|
|
100
|
+
false,
|
|
101
|
+
'inline-flex suppresses stretch',
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Note: `size-full` is normalised by `splitClassName` into `w-full h-full`
|
|
105
|
+
// before it reaches this predicate, so it never appears here as a token.
|
|
106
|
+
// Its suppression is exercised via the `w-full` case below.
|
|
107
|
+
assert.equal(
|
|
108
|
+
shouldStretchToParentWidth('div', ['w-full', 'h-full']),
|
|
109
|
+
false,
|
|
110
|
+
'w-full suppresses stretch (the post-normalisation form of size-full)',
|
|
111
|
+
);
|
|
112
|
+
|
|
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)');
|