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,319 @@
|
|
|
1
|
+
import { type JsxNode, type JsxElement, splitClassName } from './node-ir';
|
|
2
|
+
import { type ComponentDef } from './class-utils';
|
|
3
|
+
import { extractMaxWidth } from '../layout';
|
|
4
|
+
import { getClassesForBreakpoint, hasSignificantResponsiveChanges } from './responsive-analyzer';
|
|
5
|
+
import { isFrameworkAdapterDroppedTag } from '../framework-adapters';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recursively collect every Tailwind class string from a JSX tree.
|
|
9
|
+
*
|
|
10
|
+
* Elements that the framework adapter will unconditionally drop at render
|
|
11
|
+
* time (e.g. `ScrollAreaPrimitive.Scrollbar` and friends) are skipped —
|
|
12
|
+
* including their subtree. Otherwise classes that never reach Figma
|
|
13
|
+
* (Scrollbar's `sm:bg-black/60`) would falsely trip pre-render scans like
|
|
14
|
+
* `treeHasResponsiveClasses`, producing duplicate-content Responsive
|
|
15
|
+
* previews on stories that are static at every breakpoint post-adapter.
|
|
16
|
+
*/
|
|
17
|
+
export function collectTreeClasses(node: JsxNode | undefined, output: string[]): void {
|
|
18
|
+
if (!node) return;
|
|
19
|
+
if (node.type === 'element') {
|
|
20
|
+
const el = node as JsxElement;
|
|
21
|
+
if (isFrameworkAdapterDroppedTag(el.tagName)) return;
|
|
22
|
+
const className = el.props && el.props.className ? String(el.props.className) : '';
|
|
23
|
+
if (className) {
|
|
24
|
+
const list = splitClassName(className);
|
|
25
|
+
for (let i = 0; i < list.length; i++) output.push(list[i]);
|
|
26
|
+
}
|
|
27
|
+
const children = el.children || [];
|
|
28
|
+
for (let i = 0; i < children.length; i++) {
|
|
29
|
+
collectTreeClasses(children[i], output);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns true when the tree contains classes that produce visible layout
|
|
36
|
+
* differences at responsive breakpoints.
|
|
37
|
+
*/
|
|
38
|
+
export function treeHasResponsiveClasses(node: JsxNode | undefined): boolean {
|
|
39
|
+
const list: string[] = [];
|
|
40
|
+
collectTreeClasses(node, list);
|
|
41
|
+
return list.length > 0 && hasSignificantResponsiveChanges(list);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remap every class in a className string to its breakpoint-specific override.
|
|
46
|
+
* Ensures that bare `grid` classes are paired with `grid-cols-1` if no column
|
|
47
|
+
* count was specified for this breakpoint.
|
|
48
|
+
*/
|
|
49
|
+
export function mapClassNameForBreakpoint(value: string | undefined, breakpoint: string): string | undefined {
|
|
50
|
+
if (!value) return value;
|
|
51
|
+
const list = splitClassName(value);
|
|
52
|
+
if (list.length === 0) return value;
|
|
53
|
+
const mapped = getClassesForBreakpoint(list, breakpoint);
|
|
54
|
+
let hasGrid = false;
|
|
55
|
+
let hasGridCols = false;
|
|
56
|
+
for (let i = 0; i < mapped.length; i++) {
|
|
57
|
+
const cls = mapped[i];
|
|
58
|
+
if (cls === 'grid' || cls === 'inline-grid') hasGrid = true;
|
|
59
|
+
if (cls.startsWith('grid-cols-')) hasGridCols = true;
|
|
60
|
+
}
|
|
61
|
+
// Only inject grid-cols-1 when the original class list had responsive grid-cols
|
|
62
|
+
// variants (e.g. sm:grid-cols-3) that were stripped for this breakpoint.
|
|
63
|
+
// Without this guard, vertical-stack `grid` containers (like DialogContent) get
|
|
64
|
+
// grid-cols-1 injected, which flips their layoutMode to HORIZONTAL and causes
|
|
65
|
+
// resize code to lock the frame height at a wrong value, clipping content.
|
|
66
|
+
if (hasGrid && !hasGridCols) {
|
|
67
|
+
const hadResponsiveGridCols = list.some(cls => /^(?:sm|md|lg|xl|2xl):grid-cols-/.test(cls));
|
|
68
|
+
if (hadResponsiveGridCols) mapped.push('grid-cols-1');
|
|
69
|
+
}
|
|
70
|
+
return mapped.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deep-clone a JSX node, remapping all className values for a given breakpoint.
|
|
75
|
+
*/
|
|
76
|
+
export function cloneJsxNodeForBreakpoint(node: JsxNode, breakpoint: string): JsxNode {
|
|
77
|
+
if (!node || node.type !== 'element') return node;
|
|
78
|
+
const el = node as JsxElement;
|
|
79
|
+
const nextProps = el.props ? Object.assign({}, el.props) : {};
|
|
80
|
+
if (nextProps.className) {
|
|
81
|
+
nextProps.className = mapClassNameForBreakpoint(String(nextProps.className), breakpoint) || '';
|
|
82
|
+
}
|
|
83
|
+
const nextChildren: JsxNode[] = [];
|
|
84
|
+
const children = el.children || [];
|
|
85
|
+
for (let i = 0; i < children.length; i++) {
|
|
86
|
+
nextChildren.push(cloneJsxNodeForBreakpoint(children[i], breakpoint));
|
|
87
|
+
}
|
|
88
|
+
return Object.assign({}, el, { props: nextProps, children: nextChildren });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Walk the JSX tree and return the first explicit `grid-cols-N` value found,
|
|
93
|
+
* or 1 if a grid layout is declared without a column count, or null when no
|
|
94
|
+
* grid layout is present.
|
|
95
|
+
*/
|
|
96
|
+
export function extractGridColsFromTree(node: JsxNode | undefined): number | null {
|
|
97
|
+
if (!node || node.type !== 'element') return null;
|
|
98
|
+
const el = node as JsxElement;
|
|
99
|
+
const className = el.props && el.props.className ? String(el.props.className) : '';
|
|
100
|
+
const list = className ? splitClassName(className) : [];
|
|
101
|
+
let hasGrid = false;
|
|
102
|
+
for (let i = 0; i < list.length; i++) {
|
|
103
|
+
const cls = list[i];
|
|
104
|
+
if (cls === 'grid' || cls === 'inline-grid') hasGrid = true;
|
|
105
|
+
const match = cls.match(/^grid-cols-(\d+)$/);
|
|
106
|
+
if (match) return parseInt(match[1], 10);
|
|
107
|
+
}
|
|
108
|
+
const children = el.children || [];
|
|
109
|
+
for (let i = 0; i < children.length; i++) {
|
|
110
|
+
const found = extractGridColsFromTree(children[i]);
|
|
111
|
+
if (found != null) return found;
|
|
112
|
+
}
|
|
113
|
+
return hasGrid ? 1 : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read only the root node's grid column intent. This avoids accidentally
|
|
118
|
+
* inheriting nested `grid-cols-*` from descendants (for example form rows in
|
|
119
|
+
* dialog content) when sizing top-level responsive preview containers.
|
|
120
|
+
*/
|
|
121
|
+
export function extractRootGridColsFromTree(node: JsxNode | undefined): number | null {
|
|
122
|
+
if (!node || node.type !== 'element') return null;
|
|
123
|
+
const el = node as JsxElement;
|
|
124
|
+
const className = el.props && el.props.className ? String(el.props.className) : '';
|
|
125
|
+
const list = className ? splitClassName(className) : [];
|
|
126
|
+
let hasGrid = false;
|
|
127
|
+
for (let i = 0; i < list.length; i++) {
|
|
128
|
+
const cls = list[i];
|
|
129
|
+
if (cls === 'grid' || cls === 'inline-grid') hasGrid = true;
|
|
130
|
+
const match = cls.match(/^grid-cols-(\d+)$/);
|
|
131
|
+
if (match) return parseInt(match[1], 10);
|
|
132
|
+
}
|
|
133
|
+
return hasGrid ? 1 : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Walk down the leading single-child spine of the JSX tree and return the
|
|
138
|
+
* tightest `max-w-*` constraint where `w-full` is also set on the same node.
|
|
139
|
+
* Returns null when no constrained full-width container is found.
|
|
140
|
+
*/
|
|
141
|
+
export function extractLeadingContainerMaxWidthFromTree(node: JsxNode | undefined): number | null {
|
|
142
|
+
let current: JsxNode | undefined = node;
|
|
143
|
+
let constrainedWidth: number | null = null;
|
|
144
|
+
|
|
145
|
+
while (current && current.type === 'element') {
|
|
146
|
+
const element = current as JsxElement;
|
|
147
|
+
const className = element.props && element.props.className ? String(element.props.className) : '';
|
|
148
|
+
const classes = className ? splitClassName(className) : [];
|
|
149
|
+
const hasFullWidth = classes.indexOf('w-full') !== -1;
|
|
150
|
+
const maxWidth = extractMaxWidth(classes);
|
|
151
|
+
|
|
152
|
+
if (hasFullWidth && maxWidth != null) {
|
|
153
|
+
constrainedWidth = constrainedWidth == null ? maxWidth : Math.min(constrainedWidth, maxWidth);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const children = element.children || [];
|
|
157
|
+
if (children.length !== 1 || !children[0] || children[0].type !== 'element') {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
current = children[0];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return constrainedWidth;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Propagate child-wildcard utility classes (`*:X`, `sm:*:X`, etc.) from a node
|
|
168
|
+
* to all of its direct element children, then remove them from the parent.
|
|
169
|
+
*
|
|
170
|
+
* This mirrors the CSS default where `align-items: stretch` (implicit in flex)
|
|
171
|
+
* makes children fill the cross-axis. A developer writing `*:w-full sm:*:w-auto`
|
|
172
|
+
* on a flex-col container intends each child to be full-width at mobile and
|
|
173
|
+
* auto-width at sm+. The Figma renderer already handles `w-full` and `sm:w-auto`
|
|
174
|
+
* on individual nodes, so we only need to propagate them down.
|
|
175
|
+
*
|
|
176
|
+
* Operates recursively over the whole tree so nested containers are handled too.
|
|
177
|
+
*/
|
|
178
|
+
export function propagateChildSelectorClasses(node: JsxNode): JsxNode {
|
|
179
|
+
if (!node || node.type !== 'element') return node;
|
|
180
|
+
const el = node as JsxElement;
|
|
181
|
+
const className: string = el.props && el.props.className ? String(el.props.className) : '';
|
|
182
|
+
const classes = className ? splitClassName(className) : [];
|
|
183
|
+
|
|
184
|
+
// Collect *:X and bp:*:X classes, convert to the class that should be added to children.
|
|
185
|
+
// e.g. `*:w-full` → `w-full`, `sm:*:w-auto` → `sm:w-auto`
|
|
186
|
+
const rawPropagate: string[] = [];
|
|
187
|
+
const keepOnParent: string[] = [];
|
|
188
|
+
for (let i = 0; i < classes.length; i++) {
|
|
189
|
+
const cls = classes[i];
|
|
190
|
+
// bare `*:X`
|
|
191
|
+
const bareMatch = cls.match(/^\*:(.+)$/);
|
|
192
|
+
if (bareMatch) {
|
|
193
|
+
rawPropagate.push(bareMatch[1]);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// `bp:*:X`
|
|
197
|
+
const bpMatch = cls.match(/^([a-z0-9]+):\*:(.+)$/);
|
|
198
|
+
if (bpMatch) {
|
|
199
|
+
rawPropagate.push(bpMatch[1] + ':' + bpMatch[2]);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
keepOnParent.push(cls);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Deduplicate by Tailwind property prefix (last-wins, mirrors CSS cascade).
|
|
206
|
+
// This ensures that when mapClassNameForBreakpoint produces both *:w-full (base)
|
|
207
|
+
// and *:w-auto (from sm:*:w-auto at sm breakpoint), only w-auto is propagated —
|
|
208
|
+
// avoiding the w-full STRETCH from silently beating the later w-auto.
|
|
209
|
+
const lastByPrefix = new Map<string, string>();
|
|
210
|
+
for (let i = 0; i < rawPropagate.length; i++) {
|
|
211
|
+
const cls = rawPropagate[i];
|
|
212
|
+
// Strip any breakpoint prefix before extracting the property prefix.
|
|
213
|
+
const utilityPart = cls.replace(/^[a-z0-9]+:/, '');
|
|
214
|
+
const dashIdx = utilityPart.indexOf('-');
|
|
215
|
+
const prefix = dashIdx > 0 ? utilityPart.slice(0, dashIdx) : utilityPart;
|
|
216
|
+
lastByPrefix.set(prefix, cls);
|
|
217
|
+
}
|
|
218
|
+
const toPropagate = Array.from(lastByPrefix.values());
|
|
219
|
+
|
|
220
|
+
// Recursively process children first, then inject propagated classes
|
|
221
|
+
const nextChildren: JsxNode[] = [];
|
|
222
|
+
const children = el.children || [];
|
|
223
|
+
for (let i = 0; i < children.length; i++) {
|
|
224
|
+
let child = propagateChildSelectorClasses(children[i]);
|
|
225
|
+
if (toPropagate.length > 0 && child && child.type === 'element') {
|
|
226
|
+
const childEl = child as JsxElement;
|
|
227
|
+
const childClass: string = childEl.props && childEl.props.className
|
|
228
|
+
? String(childEl.props.className)
|
|
229
|
+
: '';
|
|
230
|
+
const merged = toPropagate.join(' ') + (childClass ? ' ' + childClass : '');
|
|
231
|
+
child = Object.assign({}, childEl, {
|
|
232
|
+
props: Object.assign({}, childEl.props || {}, { className: merged }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
nextChildren.push(child);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const nextClassName = keepOnParent.join(' ');
|
|
239
|
+
return Object.assign({}, el, {
|
|
240
|
+
props: Object.assign({}, el.props || {}, { className: nextClassName }),
|
|
241
|
+
children: nextChildren,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Deduplicate a list of classes and return them joined as a single string.
|
|
247
|
+
* Preserves first-occurrence order.
|
|
248
|
+
*/
|
|
249
|
+
export function uniqueClassSignature(classes: string[] | undefined): string {
|
|
250
|
+
const seen: Record<string, boolean> = {};
|
|
251
|
+
const output: string[] = [];
|
|
252
|
+
const list = classes || [];
|
|
253
|
+
for (let i = 0; i < list.length; i++) {
|
|
254
|
+
const cls = list[i];
|
|
255
|
+
if (!cls || seen[cls]) continue;
|
|
256
|
+
seen[cls] = true;
|
|
257
|
+
output.push(cls);
|
|
258
|
+
}
|
|
259
|
+
return output.join(' ');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Produce a stable, deduplicated string signature of all classes in a JSX tree.
|
|
264
|
+
* Used for hashing / change detection.
|
|
265
|
+
*/
|
|
266
|
+
export function treeClassSignature(node: JsxNode | undefined): string {
|
|
267
|
+
const classes: string[] = [];
|
|
268
|
+
collectTreeClasses(node, classes);
|
|
269
|
+
return uniqueClassSignature(classes);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Assemble the final class list for one variant of a CVA component definition,
|
|
274
|
+
* combining base classes + per-variant classes + any extra className prop.
|
|
275
|
+
*/
|
|
276
|
+
export function buildCvaClassesForVariant(
|
|
277
|
+
def: ComponentDef,
|
|
278
|
+
props: Record<string, string>,
|
|
279
|
+
primaryKey: string,
|
|
280
|
+
primaryValue: string
|
|
281
|
+
): string[] {
|
|
282
|
+
const classes: string[] = [];
|
|
283
|
+
if (def && def.baseClasses) {
|
|
284
|
+
for (let i = 0; i < def.baseClasses.length; i++) classes.push(def.baseClasses[i]);
|
|
285
|
+
}
|
|
286
|
+
const variants = def && def.variants ? def.variants : {};
|
|
287
|
+
const variantKeys = Object.keys(variants);
|
|
288
|
+
for (let i = 0; i < variantKeys.length; i++) {
|
|
289
|
+
const key = variantKeys[i];
|
|
290
|
+
let value: string | null = null;
|
|
291
|
+
if (key === primaryKey) {
|
|
292
|
+
value = primaryValue;
|
|
293
|
+
} else if (props && props[key]) {
|
|
294
|
+
value = props[key];
|
|
295
|
+
} else if (def && def.defaultVariants && def.defaultVariants[key]) {
|
|
296
|
+
value = def.defaultVariants[key];
|
|
297
|
+
} else if (variants[key] && variants[key][0]) {
|
|
298
|
+
value = variants[key][0];
|
|
299
|
+
}
|
|
300
|
+
if (value && def.variantClasses && def.variantClasses[key] && def.variantClasses[key][value]) {
|
|
301
|
+
const variantClasses = def.variantClasses[key][value];
|
|
302
|
+
for (let j = 0; j < variantClasses.length; j++) classes.push(variantClasses[j]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const extra = splitClassName(props && props.className);
|
|
306
|
+
for (let i = 0; i < extra.length; i++) classes.push(extra[i]);
|
|
307
|
+
return classes;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Return the pixel width to use for a responsive preview frame.
|
|
312
|
+
* Falls back to a mobile-first 390 px when the breakpoint has no registered
|
|
313
|
+
* min-width or the breakpoint is 'base'.
|
|
314
|
+
*/
|
|
315
|
+
export function getResponsivePreviewWidth(breakpointName: string, minWidth: number): number {
|
|
316
|
+
if (breakpointName === 'base') return 390;
|
|
317
|
+
if (Number.isFinite(minWidth) && minWidth > 0) return minWidth;
|
|
318
|
+
return 390;
|
|
319
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { parseColor, type RGB } from '
|
|
1
|
+
import { parseColor, type RGB } from '../tokens';
|
|
2
|
+
import type { ComponentDef } from '../components/scanner-types';
|
|
3
|
+
import { applyFrameworkAdapters } from '../framework-adapters';
|
|
4
|
+
import { mergeMissing, resolveValuePercents } from './adapter-utils';
|
|
2
5
|
|
|
3
6
|
// ---------------------------------------------------------------------------
|
|
4
7
|
// JSX tree types
|
|
@@ -73,9 +76,9 @@ export type NodeIR =
|
|
|
73
76
|
| NodeIRRing;
|
|
74
77
|
|
|
75
78
|
export type NodeIRHelpers = {
|
|
76
|
-
getComponentDefByName: (name: string) =>
|
|
77
|
-
normalizeComponentDef: (raw:
|
|
78
|
-
getCompoundClasses: (def:
|
|
79
|
+
getComponentDefByName: (name: string) => ComponentDef | null;
|
|
80
|
+
normalizeComponentDef: (raw: ComponentDef) => ComponentDef;
|
|
81
|
+
getCompoundClasses: (def: ComponentDef, tagName: string) => string[];
|
|
79
82
|
mergeClasses: (base: string[], extra: string[]) => string[];
|
|
80
83
|
};
|
|
81
84
|
|
|
@@ -85,10 +88,22 @@ export type NodeIRHelpers = {
|
|
|
85
88
|
|
|
86
89
|
export function splitClassName(value?: string): string[] {
|
|
87
90
|
if (!value) return [];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
for (const raw of String(value).split(/\s+/)) {
|
|
93
|
+
const cleaned = raw.replace(/^!/, '').replace(/!$/, '');
|
|
94
|
+
if (!cleaned) continue;
|
|
95
|
+
// `size-full` is Tailwind shorthand for `w-full h-full`. Expand it here
|
|
96
|
+
// so every downstream parser/check that already understands w-full and
|
|
97
|
+
// h-full handles size-full automatically — no parallel code path. The
|
|
98
|
+
// numeric `size-N` form has its own dedicated handler in sizing.ts and
|
|
99
|
+
// is intentionally left alone.
|
|
100
|
+
if (cleaned === 'size-full') {
|
|
101
|
+
out.push('w-full', 'h-full');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
out.push(cleaned);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
export function resolveNodeIR(node: JsxNode): NodeIR | null {
|
|
@@ -140,7 +155,16 @@ export function applyNodeTransforms(
|
|
|
140
155
|
colorGroup: Record<string, string>,
|
|
141
156
|
helpers: NodeIRHelpers
|
|
142
157
|
): NodeIR {
|
|
143
|
-
|
|
158
|
+
// Framework adapters run FIRST so the injected classes flow through the
|
|
159
|
+
// flatten merge (a wrapper compound's class list and the adapter's
|
|
160
|
+
// slot-injected classes end up on the same element) and through every
|
|
161
|
+
// downstream transform without parallel handling.
|
|
162
|
+
let next = applyFrameworkAdapters(node);
|
|
163
|
+
next = flattenComponentNodes(next, null, helpers);
|
|
164
|
+
// Native HTML input rewrites run BEFORE the visual transforms (space,
|
|
165
|
+
// divide, ring) so the synthetic tree they emit flows through the rest
|
|
166
|
+
// of the pipeline as ordinary div + Tailwind classes.
|
|
167
|
+
next = transformInputNodes(next);
|
|
144
168
|
next = transformSpaceNodes(next);
|
|
145
169
|
next = transformDivideNodes(next, colorGroup);
|
|
146
170
|
next = transformRingNodes(next, colorGroup);
|
|
@@ -157,7 +181,7 @@ export function isElementLikeNode(node: NodeIR): node is NodeIRElement | NodeIRC
|
|
|
157
181
|
|
|
158
182
|
function flattenComponentNodes(
|
|
159
183
|
node: NodeIR,
|
|
160
|
-
parentCompoundDef:
|
|
184
|
+
parentCompoundDef: ComponentDef | null,
|
|
161
185
|
helpers: NodeIRHelpers
|
|
162
186
|
): NodeIR {
|
|
163
187
|
if (node.kind === 'text' || node.kind === 'divider') {
|
|
@@ -229,6 +253,173 @@ function flattenComponentNodes(
|
|
|
229
253
|
});
|
|
230
254
|
}
|
|
231
255
|
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Native HTML input rewrites
|
|
258
|
+
//
|
|
259
|
+
// Some `<input>` types have no built-in className-driven appearance that
|
|
260
|
+
// Figma can render — most notably `type="range"`, which is a custom
|
|
261
|
+
// browser widget (track + thumb). The default input branch in
|
|
262
|
+
// `ui-builder.ts` reads `value` / `defaultValue` / `placeholder` and
|
|
263
|
+
// emits a text node — fine for text/number/email, useless (or worse,
|
|
264
|
+
// stalling) for range. This transform rewrites those special cases at
|
|
265
|
+
// the IR-transform stage into a synthetic Tailwind tree the existing
|
|
266
|
+
// pipeline already knows how to render.
|
|
267
|
+
//
|
|
268
|
+
// Currently handles: `type="range"`. Extend the dispatcher in
|
|
269
|
+
// `transformInputNodes` when other types need similar treatment (e.g.
|
|
270
|
+
// checkbox / radio with `checked` driving a visual indicator).
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
export function transformInputNodes(node: NodeIR): NodeIR {
|
|
274
|
+
if (node.kind === 'text' || node.kind === 'divider') {
|
|
275
|
+
return node;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (node.kind === 'fragment') {
|
|
279
|
+
return recurseFragment(node, transformInputNodes);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (node.kind === 'ring') {
|
|
283
|
+
const nextChild = transformInputNodes(node.child);
|
|
284
|
+
return nextChild === node.child ? node : Object.assign({}, node, { child: nextChild });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Recurse first so any nested `<input>` is rewritten before its parent
|
|
288
|
+
// decides what to do with it. Preserve object identity when no
|
|
289
|
+
// descendant changed (keeps `NODE_LAYOUT_CACHE` warm).
|
|
290
|
+
let childrenChanged = false;
|
|
291
|
+
const nextChildren: NodeIR[] = [];
|
|
292
|
+
for (const child of node.children) {
|
|
293
|
+
const next = transformInputNodes(child);
|
|
294
|
+
if (next !== child) childrenChanged = true;
|
|
295
|
+
nextChildren.push(next);
|
|
296
|
+
}
|
|
297
|
+
const recursed: NodeIR = childrenChanged
|
|
298
|
+
? (Object.assign({}, node, { children: nextChildren }) as NodeIR)
|
|
299
|
+
: node;
|
|
300
|
+
|
|
301
|
+
if (recursed.kind !== 'element') return recursed;
|
|
302
|
+
if (recursed.tagLower !== 'input') return recursed;
|
|
303
|
+
const type = (recursed.props && recursed.props.type) || '';
|
|
304
|
+
if (type !== 'range') return recursed;
|
|
305
|
+
|
|
306
|
+
return buildRangeSliderTree(recursed as NodeIRElement);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function recurseFragment(
|
|
310
|
+
frag: NodeIRFragment,
|
|
311
|
+
fn: (n: NodeIR) => NodeIR,
|
|
312
|
+
): NodeIR {
|
|
313
|
+
let changed = false;
|
|
314
|
+
const next: NodeIR[] = [];
|
|
315
|
+
for (const c of frag.children) {
|
|
316
|
+
const r = fn(c);
|
|
317
|
+
if (r !== c) changed = true;
|
|
318
|
+
next.push(r);
|
|
319
|
+
}
|
|
320
|
+
return changed ? Object.assign({}, frag, { children: next }) : frag;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Rewrite `<input type="range" min=N max=M value=V disabled? className=...>`
|
|
325
|
+
* into a styled `<div>` tree mirroring the shape that shadcn Slider's
|
|
326
|
+
* adapter already produces (and which the renderer is known to handle):
|
|
327
|
+
*
|
|
328
|
+
* wrapper (relative flex h-4 items-center, consumer classes preserved)
|
|
329
|
+
* └── track (flow child: h-1.5 w-full rounded-full overflow-hidden bg-secondary)
|
|
330
|
+
* └── filled (flow child of track: h-full rounded-full bg-primary w-[N%])
|
|
331
|
+
* └── thumb (absolute sibling of track: size-4 rounded-full -translate-x-1/2 left-[N%])
|
|
332
|
+
*
|
|
333
|
+
* Why mirror shadcn's structure (track as flow child, thumb as absolute
|
|
334
|
+
* sibling)? An all-absolute-children wrapper has no flow content to size
|
|
335
|
+
* against — deferred-layout passes can stall trying to resolve a 0-content
|
|
336
|
+
* frame with `flex items-center`. The shadcn Slider already navigates this
|
|
337
|
+
* by making Track a flow child of Control; we copy that pattern.
|
|
338
|
+
*
|
|
339
|
+
* Vertical centering of the thumb comes from `flex items-center` on the
|
|
340
|
+
* wrapper combined with the thumb's static position (size-4 = wrapper
|
|
341
|
+
* height) — no `top-1/2 -translate-y-1/2` needed.
|
|
342
|
+
*
|
|
343
|
+
* Geometry: `pct = ((value - min) / (max - min)) * 100`. See
|
|
344
|
+
* `resolveValuePercents` in `./adapter-utils.ts`.
|
|
345
|
+
*/
|
|
346
|
+
function buildRangeSliderTree(input: NodeIRElement): NodeIR {
|
|
347
|
+
const props = input.props || {};
|
|
348
|
+
const rawValue = props.value ?? props.defaultValue;
|
|
349
|
+
const pcts = resolveValuePercents(rawValue, props.min, props.max);
|
|
350
|
+
const pct = pcts[0] ?? 0;
|
|
351
|
+
const isDisabled = props.disabled !== undefined && props.disabled !== 'false';
|
|
352
|
+
|
|
353
|
+
// Preserve sizing-related classes from the consumer (`w-full`, `w-[Npx]`,
|
|
354
|
+
// `max-w-*`) onto the wrapper. Visual / state classes that applied to the
|
|
355
|
+
// native widget shape (`accent-primary`, focus rings) are kept but
|
|
356
|
+
// typically no-ops on the synthetic div.
|
|
357
|
+
const wrapperExtras = ['relative', 'flex', 'h-4', 'items-center'];
|
|
358
|
+
if (isDisabled) wrapperExtras.push('opacity-50');
|
|
359
|
+
const wrapperClasses = mergeMissing(input.classes, wrapperExtras);
|
|
360
|
+
|
|
361
|
+
const filled: NodeIR = makeRangeChild('div', [
|
|
362
|
+
'h-full',
|
|
363
|
+
'rounded-full',
|
|
364
|
+
'bg-primary',
|
|
365
|
+
`w-[${formatPercent(pct)}%]`,
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
const track: NodeIRElement = {
|
|
369
|
+
kind: 'element',
|
|
370
|
+
tagName: 'div',
|
|
371
|
+
tagLower: 'div',
|
|
372
|
+
props: {},
|
|
373
|
+
classes: [
|
|
374
|
+
'h-1.5',
|
|
375
|
+
'w-full',
|
|
376
|
+
'rounded-full',
|
|
377
|
+
'overflow-hidden',
|
|
378
|
+
'bg-secondary',
|
|
379
|
+
],
|
|
380
|
+
children: [filled],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const thumb: NodeIR = makeRangeChild('div', [
|
|
384
|
+
'absolute',
|
|
385
|
+
'size-4',
|
|
386
|
+
'rounded-full',
|
|
387
|
+
'border-2',
|
|
388
|
+
'border-primary',
|
|
389
|
+
'bg-background',
|
|
390
|
+
'-translate-x-1/2',
|
|
391
|
+
`left-[${formatPercent(pct)}%]`,
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
kind: 'element',
|
|
396
|
+
tagName: 'div',
|
|
397
|
+
tagLower: 'div',
|
|
398
|
+
props: {},
|
|
399
|
+
classes: wrapperClasses,
|
|
400
|
+
children: [track, thumb],
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function makeRangeChild(tag: string, classes: string[]): NodeIRElement {
|
|
405
|
+
return {
|
|
406
|
+
kind: 'element',
|
|
407
|
+
tagName: tag,
|
|
408
|
+
tagLower: tag,
|
|
409
|
+
props: {},
|
|
410
|
+
classes,
|
|
411
|
+
children: [],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function formatPercent(pct: number): string {
|
|
416
|
+
// Two-decimal precision keeps thumb placement visually correct for
|
|
417
|
+
// common (min, max, value) triples without bloating the class name.
|
|
418
|
+
// `Number.toFixed` always renders fixed decimals; trim trailing zeros.
|
|
419
|
+
const fixed = pct.toFixed(2);
|
|
420
|
+
return fixed.replace(/\.?0+$/, '') || '0';
|
|
421
|
+
}
|
|
422
|
+
|
|
232
423
|
function transformSpaceNodes(node: NodeIR): NodeIR {
|
|
233
424
|
if (node.kind === 'text' || node.kind === 'divider') {
|
|
234
425
|
return node;
|
|
@@ -479,38 +670,35 @@ function parseRingTokens(
|
|
|
479
670
|
let opacity: number | null = null;
|
|
480
671
|
let offsetWidth: number | null = null;
|
|
481
672
|
let offsetColor: RGB | null = null;
|
|
482
|
-
let
|
|
673
|
+
let sawRingWidth = false;
|
|
483
674
|
|
|
484
675
|
for (const raw of classes) {
|
|
485
676
|
if (raw === 'ring') {
|
|
486
|
-
|
|
677
|
+
sawRingWidth = true;
|
|
487
678
|
width = width == null ? 3 : width;
|
|
488
679
|
continue;
|
|
489
680
|
}
|
|
490
681
|
const widthMatch = raw.match(/^ring-(\d+)$/);
|
|
491
682
|
if (widthMatch) {
|
|
492
|
-
|
|
683
|
+
sawRingWidth = true;
|
|
493
684
|
width = parseInt(widthMatch[1], 10);
|
|
494
685
|
continue;
|
|
495
686
|
}
|
|
496
687
|
const bracketMatch = raw.match(/^ring-\[(\d+(?:\.\d+)?)px\]$/);
|
|
497
688
|
if (bracketMatch) {
|
|
498
|
-
|
|
689
|
+
sawRingWidth = true;
|
|
499
690
|
width = parseFloat(bracketMatch[1]);
|
|
500
691
|
continue;
|
|
501
692
|
}
|
|
502
693
|
const opacityMatch = raw.match(/^ring-opacity-(\d+)$/);
|
|
503
694
|
if (opacityMatch) {
|
|
504
|
-
sawRing = true;
|
|
505
695
|
opacity = Math.max(0, Math.min(1, parseInt(opacityMatch[1], 10) / 100));
|
|
506
696
|
continue;
|
|
507
697
|
}
|
|
508
698
|
if (raw === 'ring-inset') {
|
|
509
|
-
sawRing = true;
|
|
510
699
|
continue;
|
|
511
700
|
}
|
|
512
701
|
if (raw.startsWith('ring-offset-')) {
|
|
513
|
-
sawRing = true;
|
|
514
702
|
const offsetRaw = raw.slice('ring-offset-'.length);
|
|
515
703
|
const offsetBracket = offsetRaw.match(/^\[(.+)\]$/);
|
|
516
704
|
if (offsetBracket) {
|
|
@@ -530,7 +718,6 @@ function parseRingTokens(
|
|
|
530
718
|
continue;
|
|
531
719
|
}
|
|
532
720
|
if (raw.startsWith('ring-')) {
|
|
533
|
-
sawRing = true;
|
|
534
721
|
const tokenRaw = raw.slice('ring-'.length);
|
|
535
722
|
const parts = tokenRaw.split('/');
|
|
536
723
|
const token = parts[0];
|
|
@@ -548,7 +735,9 @@ function parseRingTokens(
|
|
|
548
735
|
}
|
|
549
736
|
}
|
|
550
737
|
|
|
551
|
-
|
|
738
|
+
// Tailwind ring modifiers like `ring-offset-*`, `ring-opacity-*`, and `ring-<color>`
|
|
739
|
+
// do not render a ring by themselves — they only modify an existing ring width.
|
|
740
|
+
if (!sawRingWidth) return null;
|
|
552
741
|
|
|
553
742
|
if (width == null) width = 3;
|
|
554
743
|
if (!color) {
|
|
@@ -49,12 +49,18 @@ function splitResponsiveClass(cls: string): { bucket: string; utility: string |
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (responsiveVariants.length === 0) {
|
|
52
|
-
|
|
52
|
+
// Non-responsive variants like *:, hover:, focus: — keep the original class
|
|
53
|
+
// string in the base bucket so it appears at every breakpoint.
|
|
54
|
+
if (remainingVariants.length > 0) return { bucket: 'base', utility: cls };
|
|
53
55
|
return { bucket: 'base', utility: utility };
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
if (remainingVariants.length > 0) return null;
|
|
57
58
|
const bucket = responsiveVariants[responsiveVariants.length - 1];
|
|
59
|
+
if (remainingVariants.length > 0) {
|
|
60
|
+
// Mixed e.g. sm:*:w-auto — strip the responsive prefix but preserve the
|
|
61
|
+
// remaining variants so the output is *:w-auto at the sm bucket.
|
|
62
|
+
return { bucket: bucket, utility: remainingVariants.join(':') + ':' + utility };
|
|
63
|
+
}
|
|
58
64
|
return { bucket: bucket, utility: utility };
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -78,6 +84,30 @@ function buildResponsiveBuckets(classes: string[]): Record<string, string[]> {
|
|
|
78
84
|
return buckets;
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
const DISPLAY_UTILITIES = new Set([
|
|
88
|
+
'hidden', 'block', 'inline-block', 'inline', 'flex', 'inline-flex',
|
|
89
|
+
'grid', 'inline-grid', 'table', 'inline-table', 'contents', 'list-item',
|
|
90
|
+
'flow-root', 'table-row', 'table-cell', 'table-caption', 'table-column',
|
|
91
|
+
'table-column-group', 'table-footer-group', 'table-header-group',
|
|
92
|
+
'table-row-group',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns true when the given class set resolves to `display: none` at the
|
|
97
|
+
* target breakpoint, after the base → sm → md → … cascade. Used to skip
|
|
98
|
+
* responsive-preview tiles where the component is hidden (e.g. md:hidden).
|
|
99
|
+
*/
|
|
100
|
+
export function isHiddenAtBreakpoint(classes: string[], breakpoint: string): boolean {
|
|
101
|
+
const effective = getClassesForBreakpoint(classes, breakpoint);
|
|
102
|
+
let lastDisplay: string | null = null;
|
|
103
|
+
for (let i = 0; i < effective.length; i++) {
|
|
104
|
+
const cls = effective[i];
|
|
105
|
+
const bare = cls.charAt(0) === '!' ? cls.slice(1) : cls;
|
|
106
|
+
if (DISPLAY_UTILITIES.has(bare)) lastDisplay = bare;
|
|
107
|
+
}
|
|
108
|
+
return lastDisplay === 'hidden';
|
|
109
|
+
}
|
|
110
|
+
|
|
81
111
|
export function extractBreakpointsFromClasses(classes: string[]): BreakpointInfo[] {
|
|
82
112
|
const buckets = buildResponsiveBuckets(classes);
|
|
83
113
|
const hasResponsive = BREAKPOINT_ORDER.some(bp => (buckets[bp] && buckets[bp].length > 0));
|