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
|
@@ -166,7 +166,22 @@ export function resolveSpacing(value: string): number | undefined {
|
|
|
166
166
|
// State Modifiers
|
|
167
167
|
// ============================================================================
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Modifiers that imply the component has its OWN interactive/aria state
|
|
171
|
+
* (it receives focus, can be disabled directly, etc.). These are the
|
|
172
|
+
* trigger set for state-component classification — if a root className
|
|
173
|
+
* uses any of these, the component participates in a state-variant matrix
|
|
174
|
+
* and the plugin renders it via the state-master path.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately excludes `group-*:` and `peer-*:` modifiers — those are
|
|
177
|
+
* *passive reactions* to a parent's or sibling's state (e.g. Label
|
|
178
|
+
* dimming when its `<input>` peer is `:disabled`). They don't justify
|
|
179
|
+
* promoting the component to a state primitive. A Label that only
|
|
180
|
+
* carries `peer-disabled:opacity-50` should stay `simple`, render its
|
|
181
|
+
* text children normally, and not get hoisted into a state-master that
|
|
182
|
+
* wipes its content.
|
|
183
|
+
*/
|
|
184
|
+
export const OWN_STATE_MODIFIERS = [
|
|
170
185
|
'hover:',
|
|
171
186
|
'focus:',
|
|
172
187
|
'focus-visible:',
|
|
@@ -182,6 +197,16 @@ export const STATE_MODIFIERS = [
|
|
|
182
197
|
'data-[state=active]:',
|
|
183
198
|
'data-[checked]:',
|
|
184
199
|
'data-[disabled]:',
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* All recognized state modifiers — used during state-grouping after a
|
|
204
|
+
* component has been classified, so passive `group-*` / `peer-*`
|
|
205
|
+
* variants still get their classes captured even though they don't
|
|
206
|
+
* trigger classification on their own.
|
|
207
|
+
*/
|
|
208
|
+
export const STATE_MODIFIERS = [
|
|
209
|
+
...OWN_STATE_MODIFIERS,
|
|
185
210
|
'group-data-[checked]:',
|
|
186
211
|
'group-data-[disabled]:',
|
|
187
212
|
'peer-data-[checked]:',
|
|
@@ -512,7 +537,18 @@ export function groupClassesByState(classes: string[]): Record<string, string[]>
|
|
|
512
537
|
if (!groups[stateName]) {
|
|
513
538
|
groups[stateName] = [];
|
|
514
539
|
}
|
|
515
|
-
|
|
540
|
+
// Reconstitute the utility with its opacity suffix when one was
|
|
541
|
+
// parsed off — without this, `focus-visible:ring-ring/50` and
|
|
542
|
+
// `aria-invalid:ring-destructive/20` get stripped to plain
|
|
543
|
+
// `ring-ring` / `ring-destructive` (full opacity), losing the
|
|
544
|
+
// translucent look shadcn uses for soft focus / invalid rings.
|
|
545
|
+
// Symptom: State Matrix focus/error variants render solid bright
|
|
546
|
+
// rings instead of the brand-tinted, semi-transparent look the
|
|
547
|
+
// browser shows.
|
|
548
|
+
const utilityWithOpacity = parsed.opacity != null
|
|
549
|
+
? parsed.utility + '/' + parsed.opacity
|
|
550
|
+
: parsed.utility;
|
|
551
|
+
groups[stateName].push(utilityWithOpacity);
|
|
516
552
|
} else if (!parsed.modifier) {
|
|
517
553
|
groups.default.push(cls);
|
|
518
554
|
} else {
|
|
@@ -7,9 +7,7 @@ import { tailwindClassesToStyle, applyTailwindStylesToFrame } from '../tailwind'
|
|
|
7
7
|
import { bindColorVariable, pxFromSizeToken } from '../tokens';
|
|
8
8
|
import { createTextNode } from '../text';
|
|
9
9
|
import { extractStatesFromClasses, mergeStatesWithDefinition, type StateInfo } from '../tailwind';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
type RingInfo = { width: number; color: { r: number; g: number; b: number; a?: number } };
|
|
10
|
+
import { getRingInfoFromClasses, markRingNode, applyRingIfPossible } from '../layout';
|
|
13
11
|
|
|
14
12
|
function getStateEntry(states: StateInfo[], name: string): StateInfo | null {
|
|
15
13
|
for (let i = 0; i < states.length; i++) {
|
|
@@ -37,152 +35,6 @@ function buildStateClasses(states: StateInfo[], name: string): string[] {
|
|
|
37
35
|
return baseClasses.concat(entry.classes);
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
function parseRingWidth(utility: string): number | null {
|
|
41
|
-
if (utility === 'ring') return 3;
|
|
42
|
-
if (!utility.startsWith('ring-')) return null;
|
|
43
|
-
const token = utility.substring(5);
|
|
44
|
-
if (token === 'inset' || token.startsWith('offset-')) return null;
|
|
45
|
-
if (token.startsWith('[')) {
|
|
46
|
-
const arbitrary = extractArbitraryValue(utility);
|
|
47
|
-
if (!arbitrary) return null;
|
|
48
|
-
return parseLength(arbitrary);
|
|
49
|
-
}
|
|
50
|
-
const num = parseFloat(token);
|
|
51
|
-
if (!Number.isNaN(num) && String(num) === token) return num;
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function parseRingColor(utility: string, colorGroup: Record<string, string>): { r: number; g: number; b: number; a?: number } | null {
|
|
56
|
-
if (!utility.startsWith('ring-')) return null;
|
|
57
|
-
const token = utility.substring(5);
|
|
58
|
-
if (token === 'inset' || token.startsWith('offset-')) return null;
|
|
59
|
-
if (token.startsWith('[')) return null;
|
|
60
|
-
const num = parseFloat(token);
|
|
61
|
-
if (!Number.isNaN(num) && String(num) === token) return null;
|
|
62
|
-
let colorToken = token;
|
|
63
|
-
let opacityMultiplier: number | null = null;
|
|
64
|
-
const slashIndex = token.lastIndexOf('/');
|
|
65
|
-
if (slashIndex > 0 && slashIndex < token.length - 1) {
|
|
66
|
-
colorToken = token.substring(0, slashIndex);
|
|
67
|
-
const opacityRaw = token.substring(slashIndex + 1).trim();
|
|
68
|
-
const opacityNum = parseFloat(opacityRaw);
|
|
69
|
-
if (!Number.isNaN(opacityNum)) {
|
|
70
|
-
opacityMultiplier = Math.max(0, Math.min(1, opacityNum / 100));
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const resolved = colorGroup[colorToken];
|
|
74
|
-
if (!resolved) return null;
|
|
75
|
-
const parsed = parseColor(resolved);
|
|
76
|
-
if (opacityMultiplier == null) return parsed;
|
|
77
|
-
const baseAlpha = parsed.a == null ? 1 : parsed.a;
|
|
78
|
-
return {
|
|
79
|
-
r: parsed.r,
|
|
80
|
-
g: parsed.g,
|
|
81
|
-
b: parsed.b,
|
|
82
|
-
a: Math.max(0, Math.min(1, baseAlpha * opacityMultiplier)),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function getRingInfoFromClasses(classes: string[], colorGroup: Record<string, string>): RingInfo | null {
|
|
87
|
-
let width: number | null = null;
|
|
88
|
-
let color: { r: number; g: number; b: number; a?: number } | null = null;
|
|
89
|
-
|
|
90
|
-
for (let i = 0; i < classes.length; i++) {
|
|
91
|
-
const cls = classes[i];
|
|
92
|
-
const nextWidth = parseRingWidth(cls);
|
|
93
|
-
if (nextWidth != null) width = nextWidth;
|
|
94
|
-
const nextColor = parseRingColor(cls, colorGroup);
|
|
95
|
-
if (nextColor) color = nextColor;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (width == null && color == null) return null;
|
|
99
|
-
if (width == null) width = 3;
|
|
100
|
-
if (!color) {
|
|
101
|
-
const fallback = colorGroup.ring || colorGroup.primary;
|
|
102
|
-
if (!fallback) return null;
|
|
103
|
-
color = parseColor(fallback);
|
|
104
|
-
}
|
|
105
|
-
if (!width || width <= 0) return null;
|
|
106
|
-
return { width, color };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function applyRingOverlay(node: FrameNode, ring: RingInfo): boolean {
|
|
110
|
-
const width = typeof node.width === 'number' ? node.width : 0;
|
|
111
|
-
const height = typeof node.height === 'number' ? node.height : 0;
|
|
112
|
-
if (!(width > 0) || !(height > 0)) return false;
|
|
113
|
-
|
|
114
|
-
const children = node.children;
|
|
115
|
-
if (Array.isArray(children) && children.length > 0) {
|
|
116
|
-
for (let i = children.length - 1; i >= 0; i--) {
|
|
117
|
-
const child = children[i];
|
|
118
|
-
if (!child || child.name !== '__inkbridge-ring__') continue;
|
|
119
|
-
try {
|
|
120
|
-
child.remove();
|
|
121
|
-
} catch (_err) {}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const overlay = figma.createFrame();
|
|
126
|
-
overlay.name = '__inkbridge-ring__';
|
|
127
|
-
overlay.resize(width + ring.width * 2, height + ring.width * 2);
|
|
128
|
-
overlay.fills = [];
|
|
129
|
-
overlay.strokes = [{
|
|
130
|
-
type: 'SOLID',
|
|
131
|
-
color: { r: ring.color.r, g: ring.color.g, b: ring.color.b },
|
|
132
|
-
opacity: ring.color.a == null ? 1 : ring.color.a,
|
|
133
|
-
}];
|
|
134
|
-
overlay.strokeWeight = ring.width;
|
|
135
|
-
try {
|
|
136
|
-
overlay.strokeAlign = 'INSIDE';
|
|
137
|
-
} catch (_err) {}
|
|
138
|
-
|
|
139
|
-
const nodeRadius = node.cornerRadius;
|
|
140
|
-
if (typeof nodeRadius === 'number') {
|
|
141
|
-
overlay.cornerRadius = Math.max(0, nodeRadius + ring.width);
|
|
142
|
-
} else {
|
|
143
|
-
const tl = typeof node.topLeftRadius === 'number' ? node.topLeftRadius : null;
|
|
144
|
-
const tr = typeof node.topRightRadius === 'number' ? node.topRightRadius : null;
|
|
145
|
-
const br = typeof node.bottomRightRadius === 'number' ? node.bottomRightRadius : null;
|
|
146
|
-
const bl = typeof node.bottomLeftRadius === 'number' ? node.bottomLeftRadius : null;
|
|
147
|
-
if (tl != null && tr != null && br != null && bl != null) {
|
|
148
|
-
overlay.topLeftRadius = Math.max(0, tl + ring.width);
|
|
149
|
-
overlay.topRightRadius = Math.max(0, tr + ring.width);
|
|
150
|
-
overlay.bottomRightRadius = Math.max(0, br + ring.width);
|
|
151
|
-
overlay.bottomLeftRadius = Math.max(0, bl + ring.width);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
node.appendChild(overlay);
|
|
156
|
-
try {
|
|
157
|
-
overlay.layoutPositioning = 'ABSOLUTE';
|
|
158
|
-
} catch (_err) {}
|
|
159
|
-
overlay.x = -ring.width;
|
|
160
|
-
overlay.y = -ring.width;
|
|
161
|
-
try {
|
|
162
|
-
node.clipsContent = false;
|
|
163
|
-
} catch (_err) {}
|
|
164
|
-
try {
|
|
165
|
-
node.insertChild(0, overlay);
|
|
166
|
-
} catch (_err) {}
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function applyRingEffect(node: FrameNode, classes: string[], colorGroup: Record<string, string>): void {
|
|
171
|
-
const ring = getRingInfoFromClasses(classes, colorGroup);
|
|
172
|
-
if (!ring) return;
|
|
173
|
-
if (applyRingOverlay(node, ring)) return;
|
|
174
|
-
const strokeWeight = typeof node.strokeWeight === 'number' ? node.strokeWeight : 0;
|
|
175
|
-
node.strokes = [{
|
|
176
|
-
type: 'SOLID',
|
|
177
|
-
color: { r: ring.color.r, g: ring.color.g, b: ring.color.b },
|
|
178
|
-
opacity: ring.color.a == null ? 1 : ring.color.a,
|
|
179
|
-
}];
|
|
180
|
-
node.strokeWeight = Math.max(strokeWeight, ring.width);
|
|
181
|
-
try {
|
|
182
|
-
node.strokeAlign = 'INSIDE';
|
|
183
|
-
} catch (_err) {}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
38
|
export function createCVAComponentSet(parent: FrameNode | PageNode, def: ComponentDef, theme: string): SceneNode | null {
|
|
187
39
|
const colorGroup = getThemeColors(TOKENS, theme);
|
|
188
40
|
const radiusGroup = getThemeRadius(TOKENS, theme);
|
|
@@ -303,8 +155,14 @@ export function createCVAComponentSet(parent: FrameNode | PageNode, def: Compone
|
|
|
303
155
|
}
|
|
304
156
|
|
|
305
157
|
comp.appendChild(text);
|
|
306
|
-
|
|
158
|
+
const ringInfo = getRingInfoFromClasses(stateClasses, colorGroup);
|
|
159
|
+
if (ringInfo) markRingNode(comp, ringInfo);
|
|
307
160
|
stateRow.appendChild(comp);
|
|
161
|
+
// Comp's children are in place and it's now in the parent's auto-layout
|
|
162
|
+
// — apply ring (no-op if not marked). `applyRingIfPossible` locks the
|
|
163
|
+
// comp's sizing modes during the overlay append so inflation is
|
|
164
|
+
// impossible.
|
|
165
|
+
applyRingIfPossible(comp, stateRow);
|
|
308
166
|
}
|
|
309
167
|
|
|
310
168
|
variantSection.appendChild(stateRow);
|
|
@@ -416,9 +274,11 @@ export function createStateComponentSet(parent: FrameNode | PageNode, def: Compo
|
|
|
416
274
|
fill: { r: placeholderColor.r, g: placeholderColor.g, b: placeholderColor.b }
|
|
417
275
|
});
|
|
418
276
|
comp.appendChild(placeholder);
|
|
419
|
-
|
|
277
|
+
const ringInfo = getRingInfoFromClasses(stateClasses, colorGroup);
|
|
278
|
+
if (ringInfo) markRingNode(comp, ringInfo);
|
|
420
279
|
|
|
421
280
|
stateRow.appendChild(comp);
|
|
281
|
+
applyRingIfPossible(comp, stateRow);
|
|
422
282
|
}
|
|
423
283
|
|
|
424
284
|
container.appendChild(stateRow);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { splitClassName, applyTailwindStylesToFrame, tailwindClassesToStyle, type TailwindStyle } from '../tailwind';
|
|
2
2
|
import { parseColor, bindColorVariable } from '../tokens';
|
|
3
3
|
import { createTextNode } from '../text';
|
|
4
|
-
import {
|
|
4
|
+
import { getRingInfoFromClasses, markRingNode, applyRingIfPossible, enforceFixedBoxSizingAfterLayout } from '../layout';
|
|
5
5
|
import { MASTER_ICON_NAME_KEY } from '../components/component-instance';
|
|
6
6
|
import { findChildByName, getFrameHash, setFrameHash, hashString, hashDef } from '../cache';
|
|
7
7
|
import { RENDER_ENGINE_VERSION } from '../render-engine-version';
|
|
@@ -359,7 +359,7 @@ export function ensureCvaComponentSet(
|
|
|
359
359
|
// ignore if plugin runtime rejects the assignment
|
|
360
360
|
}
|
|
361
361
|
} else {
|
|
362
|
-
// applyTailwindStylesToFrame /
|
|
362
|
+
// applyTailwindStylesToFrame / markRingNode / applyStyleToFrame all
|
|
363
363
|
// touch the shared frame mixins (fills, strokes, opacity, layout). Cast
|
|
364
364
|
// ComponentNode → FrameNode at the boundary; no `any`.
|
|
365
365
|
const compFrame = comp as unknown as FrameNode;
|
|
@@ -386,13 +386,17 @@ export function ensureCvaComponentSet(
|
|
|
386
386
|
text.textDecoration = 'UNDERLINE';
|
|
387
387
|
}
|
|
388
388
|
comp.appendChild(text);
|
|
389
|
-
|
|
389
|
+
const ringInfo = getRingInfoFromClasses(classes, colorGroup);
|
|
390
|
+
if (ringInfo) markRingNode(compFrame, ringInfo);
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
enforceFixedBoxSizingAfterLayout(comp, classes);
|
|
393
394
|
|
|
394
395
|
// combineAsVariants expects components to exist in the document tree.
|
|
395
396
|
themeLibrary.appendChild(comp);
|
|
397
|
+
// Comp is now in the theme library and its sizing has been finalised by
|
|
398
|
+
// `enforceFixedBoxSizingAfterLayout` — apply the (marked) ring overlay.
|
|
399
|
+
applyRingIfPossible(comp as unknown as FrameNode, themeLibrary);
|
|
396
400
|
components.push(comp);
|
|
397
401
|
}
|
|
398
402
|
|
|
@@ -323,4 +323,12 @@ export async function buildDesignSystemSinglePage(
|
|
|
323
323
|
// top-level sections.
|
|
324
324
|
removeDuplicateTopLevelSections(ds);
|
|
325
325
|
removeOrphanedTopLevelNodes(ds);
|
|
326
|
+
|
|
327
|
+
// Frame the freshly-built page so the user lands on the result instead of
|
|
328
|
+
// wherever the viewport was before the run. Filter out invisible children
|
|
329
|
+
// defensively — scrollAndZoomIntoView throws on an empty selection.
|
|
330
|
+
const visibleChildren = ds.children.filter((node) => node.visible);
|
|
331
|
+
if (visibleChildren.length > 0) {
|
|
332
|
+
figma.viewport.scrollAndZoomIntoView(visibleChildren);
|
|
333
|
+
}
|
|
326
334
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { getBaseClass, mergeClasses, parseUtilityClass } from '../tailwind';
|
|
2
2
|
import { parseSquareSizeToken } from '../layout';
|
|
3
3
|
import { getComponentDefByName } from '../components';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isAccordionRootTag,
|
|
6
|
+
isAccordionItemTag,
|
|
7
|
+
isSelectItemIndicatorTag,
|
|
8
|
+
isRadioGroupIndicatorTag,
|
|
9
|
+
} from './tag-predicates';
|
|
5
10
|
import type { NodeIR } from '../tailwind';
|
|
6
11
|
import type { RenderContext } from './render-context';
|
|
7
12
|
|
|
@@ -30,6 +35,15 @@ export function unwrapTransparentWrapper(node: NodeIR): NodeIR {
|
|
|
30
35
|
if (!current.children || current.children.length !== 1) return current;
|
|
31
36
|
if (current.tagName.toLowerCase().endsWith('trigger')) return current;
|
|
32
37
|
if (getComponentDefByName(current.tagName)) return current;
|
|
38
|
+
// Context-sensitive indicator wrappers (Select/RadioGroup item indicators)
|
|
39
|
+
// gate their child's visibility on the parent Item being selected/checked.
|
|
40
|
+
// The gating check lives in `buildFigmaNode` and only runs when this
|
|
41
|
+
// function preserves the wrapper — without this guard, the wrapper was
|
|
42
|
+
// unwrapped to its check/dot icon BEFORE the suppression could fire, and
|
|
43
|
+
// every non-selected item rendered a stale indicator (the recurring
|
|
44
|
+
// "checkmarks on every Select item" bug).
|
|
45
|
+
if (isSelectItemIndicatorTag(current.tagName)) return current;
|
|
46
|
+
if (isRadioGroupIndicatorTag(current.tagName)) return current;
|
|
33
47
|
current = current.children[0];
|
|
34
48
|
}
|
|
35
49
|
return current;
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from '../tailwind';
|
|
28
28
|
import { parseColor } from '../tokens';
|
|
29
29
|
import { createTextNode, type CreateTextOptions } from '../text';
|
|
30
|
-
import {
|
|
30
|
+
import { getRingInfoFromClasses, markRingNode, applyRingIfPossible } from '../layout';
|
|
31
31
|
import type {
|
|
32
32
|
ComponentDef,
|
|
33
33
|
ComponentStory,
|
|
@@ -194,8 +194,6 @@ export function createStatePreviewBlock(
|
|
|
194
194
|
const label = buildStatePreviewLabel(def, instance);
|
|
195
195
|
|
|
196
196
|
function createStateCell(stateName: string, classes: string[], columnLabel: string): FrameNode {
|
|
197
|
-
const ringInfo = getRingInfoFromClasses(classes, colorGroup);
|
|
198
|
-
const ringPadding = ringInfo && ringInfo.width > 0 ? Math.ceil(ringInfo.width) : 0;
|
|
199
197
|
if (String(def && def.type ? def.type : '').toLowerCase() === 'state') {
|
|
200
198
|
const baseProps = Object.assign({}, props || {});
|
|
201
199
|
const originalExtraClasses = splitClassName(props && props.className);
|
|
@@ -256,12 +254,6 @@ export function createStatePreviewBlock(
|
|
|
256
254
|
cell.fills = [];
|
|
257
255
|
cell.strokes = [];
|
|
258
256
|
ctx.applyClipBehavior(cell, []);
|
|
259
|
-
if (ringPadding > 0) {
|
|
260
|
-
cell.paddingTop = ringPadding;
|
|
261
|
-
cell.paddingRight = ringPadding;
|
|
262
|
-
cell.paddingBottom = ringPadding;
|
|
263
|
-
cell.paddingLeft = ringPadding;
|
|
264
|
-
}
|
|
265
257
|
cell.appendChild(stateNode);
|
|
266
258
|
return cell;
|
|
267
259
|
}
|
|
@@ -306,7 +298,8 @@ export function createStatePreviewBlock(
|
|
|
306
298
|
}
|
|
307
299
|
break; // one sample is enough
|
|
308
300
|
}
|
|
309
|
-
|
|
301
|
+
const ringInfo = getRingInfoFromClasses(classes, colorGroup);
|
|
302
|
+
if (ringInfo) markRingNode(comp, ringInfo);
|
|
310
303
|
|
|
311
304
|
const cell = figma.createFrame();
|
|
312
305
|
cell.name = def.name + '/' + columnLabel + '/' + stateName + '/Cell';
|
|
@@ -318,13 +311,11 @@ export function createStatePreviewBlock(
|
|
|
318
311
|
cell.fills = [];
|
|
319
312
|
cell.strokes = [];
|
|
320
313
|
ctx.applyClipBehavior(cell, []);
|
|
321
|
-
if (ringPadding > 0) {
|
|
322
|
-
cell.paddingTop = ringPadding;
|
|
323
|
-
cell.paddingRight = ringPadding;
|
|
324
|
-
cell.paddingBottom = ringPadding;
|
|
325
|
-
cell.paddingLeft = ringPadding;
|
|
326
|
-
}
|
|
327
314
|
cell.appendChild(comp);
|
|
315
|
+
// Comp is now in the cell — its dimensions are final, so we can safely
|
|
316
|
+
// apply the marked ring overlay. The host-FIXED-toggle inside
|
|
317
|
+
// applyRingIfPossible ensures appending the overlay can't inflate comp.
|
|
318
|
+
applyRingIfPossible(comp, cell);
|
|
328
319
|
return cell;
|
|
329
320
|
}
|
|
330
321
|
|
|
@@ -661,11 +652,13 @@ export function createResponsivePreviewBlock(
|
|
|
661
652
|
const preview = renderStandaloneStory(storyOverride, theme, ctx, viewportWidth);
|
|
662
653
|
if (preview) {
|
|
663
654
|
const colsOverride = treeOverride ? extractRootGridColsFromTree(treeOverride as JsxNode) : null;
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
655
|
+
// The `cols > 1` part of the gate now lives in
|
|
656
|
+
// `applyGridColumnsIfPossible`, so a `cols === 1` forward is a
|
|
657
|
+
// safe no-op. Keep the `preview.children.length === 1` check —
|
|
658
|
+
// that's preview-builder-specific (only reflow when the
|
|
659
|
+
// standalone-story render produced a single root frame to
|
|
660
|
+
// re-grid).
|
|
661
|
+
if (colsOverride != null && preview.children.length === 1) {
|
|
669
662
|
const target = preview.children[0];
|
|
670
663
|
if ('layoutMode' in target) {
|
|
671
664
|
const previewPadding = (preview.paddingLeft || 0) + (preview.paddingRight || 0);
|
|
@@ -743,27 +736,3 @@ export function shouldRenderStatesForStory(def: ComponentDef, story: ComponentSt
|
|
|
743
736
|
return storyIndex === 0;
|
|
744
737
|
}
|
|
745
738
|
|
|
746
|
-
export function shouldRenderResponsiveForStory(def: ComponentDef, story: ComponentStory, storyIndex: number): boolean {
|
|
747
|
-
const layoutClasses = normalizeLayoutClasses(story && story.layoutClasses);
|
|
748
|
-
if (hasSignificantResponsiveChanges(layoutClasses)) return true;
|
|
749
|
-
if (story && story.jsxTree && treeHasResponsiveClasses(story.jsxTree as JsxNode)) return true;
|
|
750
|
-
const instance = findMatchingInstance(def, story);
|
|
751
|
-
const instanceClasses = buildStoryInstanceClasses(def, instance);
|
|
752
|
-
if (hasSignificantResponsiveChanges(instanceClasses)) return true;
|
|
753
|
-
|
|
754
|
-
const name = String(story && story.name ? story.name : '').toLowerCase();
|
|
755
|
-
if (name && (name === 'default' || name.indexOf('default') !== -1)) {
|
|
756
|
-
return true;
|
|
757
|
-
}
|
|
758
|
-
const stories = def && def.stories ? def.stories : [];
|
|
759
|
-
let hasDefault = false;
|
|
760
|
-
for (let i = 0; i < stories.length; i++) {
|
|
761
|
-
const storyName = String(stories[i] && stories[i].name ? stories[i].name : '').toLowerCase();
|
|
762
|
-
if (storyName && (storyName === 'default' || storyName.indexOf('default') !== -1)) {
|
|
763
|
-
hasDefault = true;
|
|
764
|
-
break;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
if (hasDefault) return false;
|
|
768
|
-
return storyIndex === 0;
|
|
769
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findChildByName, getFrameHash, setFrameHash, hashString, hashDef } from '../cache';
|
|
2
|
-
import { enforceFixedBoxSizingAfterLayout } from '../layout';
|
|
2
|
+
import { enforceFixedBoxSizingAfterLayout, applyRingIfPossible, getRingNode, markRingNode } from '../layout';
|
|
3
3
|
import { splitClassName } from '../tailwind';
|
|
4
4
|
import { RENDER_ENGINE_VERSION } from '../render-engine-version';
|
|
5
5
|
import { tagGeneratedNode } from './generated-node';
|
|
@@ -187,6 +187,18 @@ export function ensureStateComponentSet(
|
|
|
187
187
|
);
|
|
188
188
|
themeLibrary.appendChild(sourceNode);
|
|
189
189
|
|
|
190
|
+
// `createStateStoryFrame` → `applyTailwindStylesToFrame` calls
|
|
191
|
+
// `markRingNode(sourceNode, ringInfo)` for ring classes. We must
|
|
192
|
+
// capture that ring info BEFORE `createComponentFromNode` — it
|
|
193
|
+
// creates a NEW node identity, so the `RING_NODES` WeakMap entry
|
|
194
|
+
// keyed on `sourceNode` is unreachable from the resulting component.
|
|
195
|
+
// Without this transfer the focus / error variants of state masters
|
|
196
|
+
// (Input, Textarea) render with no ring overlay — only the host's
|
|
197
|
+
// 1px border shows. Confirmed 2026-05-17 from a side-by-side
|
|
198
|
+
// Storybook (correct: 1px border + 3px halo) vs Figma (broken: 1px
|
|
199
|
+
// border alone) comparison.
|
|
200
|
+
const ringInfo = getRingNode(sourceNode);
|
|
201
|
+
|
|
190
202
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
203
|
let component: any = null;
|
|
192
204
|
try {
|
|
@@ -200,10 +212,20 @@ export function ensureStateComponentSet(
|
|
|
200
212
|
continue;
|
|
201
213
|
}
|
|
202
214
|
if (!component) continue;
|
|
215
|
+
// Transfer the captured ring mark onto the new component identity.
|
|
216
|
+
if (ringInfo) {
|
|
217
|
+
markRingNode(component, ringInfo);
|
|
218
|
+
}
|
|
203
219
|
enforceFixedBoxSizingAfterLayout(
|
|
204
220
|
component,
|
|
205
221
|
splitClassName(stateVariantProps && stateVariantProps.className ? String(stateVariantProps.className) : '')
|
|
206
222
|
);
|
|
223
|
+
// Now that the component's dimensions are finalised by
|
|
224
|
+
// `enforceFixedBoxSizingAfterLayout`, build the ring overlay against
|
|
225
|
+
// those final dimensions. Idempotent: if the comp already has an
|
|
226
|
+
// `__inkbridge-ring__` child from an earlier pass, it's removed and
|
|
227
|
+
// recreated at the correct size.
|
|
228
|
+
applyRingIfPossible(component as unknown as FrameNode, themeLibrary);
|
|
207
229
|
|
|
208
230
|
const nameParts = [
|
|
209
231
|
toFigmaVariantPropertyName('state') + '=' + toFigmaVariantPropertyValue(stateName),
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
applyAspectRatioIfPossible,
|
|
41
41
|
applyFullWidthIfPossible,
|
|
42
|
+
applyRingIfPossible,
|
|
42
43
|
applyVerticalFlexGrowIfPossible,
|
|
43
44
|
enforceGrowChildPrimaryFixed,
|
|
44
45
|
extractGridColumns,
|
|
@@ -70,7 +71,7 @@ import { getActivePack } from '../plugin';
|
|
|
70
71
|
import { normalizeLayoutClasses } from './story-layout';
|
|
71
72
|
import { isGeneratedDesignSystemNode, setGeneratedFallbackReason, tagGeneratedNode } from './generated-node';
|
|
72
73
|
import { type StoryBuilderContext, type StoryRenderContext } from './story-builder-context';
|
|
73
|
-
import { createStatePreviewBlock, createResponsivePreviewBlock, shouldRenderStatesForStory
|
|
74
|
+
import { createStatePreviewBlock, createResponsivePreviewBlock, shouldRenderStatesForStory } from './preview-builder';
|
|
74
75
|
import {
|
|
75
76
|
getComponentSectionName,
|
|
76
77
|
groupComponentDefs,
|
|
@@ -308,6 +309,45 @@ function populateStoryLayout(
|
|
|
308
309
|
const useStoryTree = !!effectiveJsxTree
|
|
309
310
|
&& !shouldPreferInstanceRendering(def, story)
|
|
310
311
|
&& !shouldSkipStoryJsxTree(def, story);
|
|
312
|
+
// The scanner extracts `story.layoutClasses` from the root JSX node's
|
|
313
|
+
// className regardless of whether the root is a plain <div> wrapper or a
|
|
314
|
+
// real component (`<ScrollArea className="rounded-md border bg-background
|
|
315
|
+
// h-48 w-60 p-3">…`). For div roots the unwrap path below replaces the
|
|
316
|
+
// wrapper with the bench, so applying the visual classes to the bench
|
|
317
|
+
// matches the consumer's intent. For component roots the component
|
|
318
|
+
// renders ITS OWN visual styling — so leaving the same `rounded-md border
|
|
319
|
+
// bg-background` on the bench paints a phantom rounded rectangle behind
|
|
320
|
+
// the real component (the symptom: two overlapping bordered boxes, the
|
|
321
|
+
// bench's padding shifting the component out of register). Reset the
|
|
322
|
+
// bench's visual styling here when we detect this overlap; the bench's
|
|
323
|
+
// sizing was already pinned by resolveStoryLayoutWidth via the layout
|
|
324
|
+
// classes, so the responsive math is unaffected.
|
|
325
|
+
const rootIsOverpaintingComponent =
|
|
326
|
+
!!effectiveJsxTree
|
|
327
|
+
&& (effectiveJsxTree as JsxElement).type === 'element'
|
|
328
|
+
&& !!(effectiveJsxTree as JsxElement).isComponent
|
|
329
|
+
&& layoutClasses.length > 0
|
|
330
|
+
&& (() => {
|
|
331
|
+
const rootClasses = splitClassName(
|
|
332
|
+
(effectiveJsxTree as JsxElement).props && (effectiveJsxTree as JsxElement).props.className
|
|
333
|
+
);
|
|
334
|
+
if (rootClasses.length === 0) return false;
|
|
335
|
+
const set: Record<string, true> = {};
|
|
336
|
+
for (const c of rootClasses) set[c] = true;
|
|
337
|
+
for (const c of layoutClasses) if (!set[c]) return false;
|
|
338
|
+
return true;
|
|
339
|
+
})();
|
|
340
|
+
if (rootIsOverpaintingComponent) {
|
|
341
|
+
try { layout.fills = []; } catch { /* ignore */ }
|
|
342
|
+
try { layout.strokes = []; } catch { /* ignore */ }
|
|
343
|
+
try { layout.cornerRadius = 0; } catch { /* ignore */ }
|
|
344
|
+
try {
|
|
345
|
+
layout.paddingLeft = 0;
|
|
346
|
+
layout.paddingRight = 0;
|
|
347
|
+
layout.paddingTop = 0;
|
|
348
|
+
layout.paddingBottom = 0;
|
|
349
|
+
} catch { /* ignore */ }
|
|
350
|
+
}
|
|
311
351
|
const layoutPadding = (layout.paddingLeft || 0) + (layout.paddingRight || 0);
|
|
312
352
|
const contentWidth = effectiveWidth ? effectiveWidth - layoutPadding : undefined;
|
|
313
353
|
let added = 0;
|
|
@@ -349,6 +389,7 @@ function populateStoryLayout(
|
|
|
349
389
|
? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
|
|
350
390
|
: undefined;
|
|
351
391
|
applyFullWidthIfPossible(childNode, layout, fullWidthOptions);
|
|
392
|
+
applyRingIfPossible(childNode, layout);
|
|
352
393
|
applyAspectRatioIfPossible(childNode);
|
|
353
394
|
enforceGrowChildPrimaryFixed(childNode, layout);
|
|
354
395
|
const childRootGridCols = extractRootGridColsFromTree(child);
|
|
@@ -387,6 +428,7 @@ function populateStoryLayout(
|
|
|
387
428
|
? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
|
|
388
429
|
: undefined;
|
|
389
430
|
applyFullWidthIfPossible(childNode, layout, fullWidthOptions);
|
|
431
|
+
applyRingIfPossible(childNode, layout);
|
|
390
432
|
applyAspectRatioIfPossible(childNode);
|
|
391
433
|
enforceGrowChildPrimaryFixed(childNode, layout);
|
|
392
434
|
rendered = true;
|
|
@@ -411,6 +453,7 @@ function populateStoryLayout(
|
|
|
411
453
|
? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
|
|
412
454
|
: undefined;
|
|
413
455
|
applyFullWidthIfPossible(treeNode, layout, fullWidthOptions);
|
|
456
|
+
applyRingIfPossible(treeNode, layout);
|
|
414
457
|
applyAspectRatioIfPossible(treeNode);
|
|
415
458
|
enforceGrowChildPrimaryFixed(treeNode, layout);
|
|
416
459
|
const treeRootGridCols = extractRootGridColsFromTree(effectiveJsxTree);
|
|
@@ -448,6 +491,10 @@ function populateStoryLayout(
|
|
|
448
491
|
}
|
|
449
492
|
}
|
|
450
493
|
|
|
494
|
+
// `applyGridColumnsIfPossible` guards on `cols <= 1` internally, so
|
|
495
|
+
// forwarding `layoutGridCols === 1` (the Tailwind default for plain
|
|
496
|
+
// `grid` with no `grid-cols-N`) is a no-op. The truthiness check
|
|
497
|
+
// here just avoids a function call when there's no grid at all.
|
|
451
498
|
if (layoutGridCols) {
|
|
452
499
|
ctx.applyGridColumnsWithReflow(layout, effectiveWidth || layout.width, layoutGridCols);
|
|
453
500
|
}
|
|
@@ -809,10 +856,13 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
|
|
|
809
856
|
const statesBlock = createStatePreviewBlock(def, story, theme, ctx);
|
|
810
857
|
if (statesBlock) storyWrap.appendChild(statesBlock);
|
|
811
858
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
859
|
+
// No pre-flight gate — `createResponsivePreviewBlock` already
|
|
860
|
+
// returns null when the story has no responsive signals (layout /
|
|
861
|
+
// tree / instance classes all lack `sm:`/`md:`/`lg:` etc.) or when
|
|
862
|
+
// the breakpoint set collapses to one, so the gate would have been
|
|
863
|
+
// a strict subset of the renderer's existing check.
|
|
864
|
+
const responsiveBlock = createResponsivePreviewBlock(def, story, theme, ctx);
|
|
865
|
+
if (responsiveBlock) storyWrap.appendChild(responsiveBlock);
|
|
816
866
|
block.appendChild(storyWrap);
|
|
817
867
|
}
|
|
818
868
|
|