inkbridge 0.1.0-beta.20 → 0.1.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +2 -1
  2. package/bin/inkbridge.mjs +64 -9
  3. package/code.js +11 -11
  4. package/package.json +8 -2
  5. package/scanner/adapter-utils-regression.ts +159 -0
  6. package/scanner/component-scanner.ts +276 -19
  7. package/scanner/font-family-extract-regression.ts +113 -0
  8. package/scanner/framework-adapter-shadcn-regression.ts +96 -1
  9. package/scanner/grid-cols-extraction-regression.ts +110 -0
  10. package/scanner/input-range-regression.ts +217 -0
  11. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  12. package/scanner/local-const-className-regression.ts +331 -0
  13. package/scanner/ring-utility-regression.ts +25 -4
  14. package/scanner/state-classification-regression.ts +38 -0
  15. package/scanner/stretch-to-parent-width-regression.ts +35 -1
  16. package/scanner/tailwind-parser.ts +38 -2
  17. package/src/components/component-gen.ts +11 -151
  18. package/src/design-system/cva-master.ts +7 -3
  19. package/src/design-system/design-system.ts +8 -0
  20. package/src/design-system/node-helpers.ts +15 -1
  21. package/src/design-system/preview-builder.ts +14 -45
  22. package/src/design-system/state-master.ts +23 -1
  23. package/src/design-system/story-builder.ts +55 -5
  24. package/src/design-system/ui-builder.ts +116 -6
  25. package/src/framework-adapters/index.ts +15 -2
  26. package/src/framework-adapters/shadcn.ts +83 -67
  27. package/src/layout/deferred-layout.ts +187 -1
  28. package/src/layout/layout-utils.ts +2 -1
  29. package/src/layout/ring-utils.ts +31 -82
  30. package/src/render-engine-version.ts +1 -1
  31. package/src/tailwind/adapter-utils.ts +137 -0
  32. package/src/tailwind/jsx-utils.ts +9 -0
  33. package/src/tailwind/node-ir.ts +172 -0
  34. package/src/tailwind/tailwind.ts +23 -16
  35. package/src/tokens/tokens.ts +11 -3
  36. package/templates/scan-components-route.ts +11 -1
@@ -166,7 +166,22 @@ export function resolveSpacing(value: string): number | undefined {
166
166
  // State Modifiers
167
167
  // ============================================================================
168
168
 
169
- export const STATE_MODIFIERS = [
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
- groups[stateName].push(parsed.utility);
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 { extractArbitraryValue, parseLength } from '../tailwind';
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
- applyRingEffect(comp, stateClasses, colorGroup);
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
- applyRingEffect(comp, stateClasses, colorGroup);
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 { applyRingEffect, enforceFixedBoxSizingAfterLayout } from '../layout';
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 / applyRingEffect / applyStyleToFrame all
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
- applyRingEffect(compFrame, classes, colorGroup);
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 { isAccordionRootTag, isAccordionItemTag } from './tag-predicates';
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 { applyRingEffect, getRingInfoFromClasses } from '../layout';
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
- applyRingEffect(comp, classes, colorGroup);
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
- // Only reflow for actual multi-column grids (cols > 1). A bare `grid` without an
665
- // explicit grid-cols-N returns cols=1 from extractRootGridColsFromTree, but that
666
- // represents a single-column vertical stack applying applyGridColumnsWithReflow
667
- // for it would incorrectly flip the frame to HORIZONTAL-WRAP and corrupt child layout.
668
- if (colsOverride != null && colsOverride > 1 && preview.children.length === 1) {
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, shouldRenderResponsiveForStory } from './preview-builder';
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
- if (shouldRenderResponsiveForStory(def, story, storyIndex)) {
813
- const responsiveBlock = createResponsivePreviewBlock(def, story, theme, ctx);
814
- if (responsiveBlock) storyWrap.appendChild(responsiveBlock);
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