inkbridge 0.1.0-beta.21 → 0.1.0-beta.23

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 (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -33,6 +33,7 @@ import {
33
33
  } from '../tokens';
34
34
  import { createUIComponents, pruneGeneratedComponentLibrary } from './ui-builder';
35
35
  import { hashString, stableStringify, getFrameHash, setFrameHash, findChildByName } from '../cache';
36
+ import { RENDER_ENGINE_VERSION } from '../render-engine-version';
36
37
  import { isGeneratedDesignSystemNode, tagGeneratedNode } from './generated-node';
37
38
 
38
39
  const DESIGN_SYSTEM_PAGE_NAME = 'Design System';
@@ -53,11 +54,26 @@ function removeStalePageLabels(page: PageNode | null, labels: string[]): void {
53
54
  }
54
55
  }
55
56
 
57
+ /**
58
+ * Frame name used for the design-tokens row (the section showing the
59
+ * consumer's custom-defined colors, fonts, radii, etc.). Renamed from
60
+ * the historical "Design Tokens" to make it clear in Figma's frame
61
+ * label that the contents are the consumer's OVERRIDES — values they
62
+ * wrote on top of Tailwind's defaults — not the full theme.
63
+ *
64
+ * Legacy name kept in `KNOWN_TOP_LEVEL_SECTIONS` + `removeStalePageLabels`
65
+ * so existing files with a "Design Tokens" frame get cleaned up on the
66
+ * next run instead of leaving an orphan next to the new frame.
67
+ */
68
+ const TOKENS_ROW_NAME = 'Custom Tokens';
69
+ const LEGACY_TOKENS_ROW_NAME = 'Design Tokens';
70
+
56
71
  // Remove orphaned top-level nodes left behind by failed/interrupted runs.
57
72
  // The Design System page is plugin-managed: only these named sections are
58
73
  // expected as direct children. Anything else is stale generator output.
59
74
  const KNOWN_TOP_LEVEL_SECTIONS = new Set([
60
- 'Design Tokens',
75
+ TOKENS_ROW_NAME,
76
+ LEGACY_TOKENS_ROW_NAME,
61
77
  'UI Components',
62
78
  COMPONENT_LIBRARY_ROOT_NAME,
63
79
  ]);
@@ -211,13 +227,19 @@ export async function buildDesignSystemSinglePage(
211
227
  figma.currentPage = ds;
212
228
  removeDuplicateTopLevelSections(ds);
213
229
  removeOrphanedTopLevelNodes(ds);
214
- removeStalePageLabels(ds, ['Design Tokens', 'UI Components']);
230
+ removeStalePageLabels(ds, [TOKENS_ROW_NAME, LEGACY_TOKENS_ROW_NAME, 'UI Components']);
215
231
 
216
232
  const themeNames = getThemeNames(TOKENS);
217
233
 
218
234
  // Compute a single token hash for this run. Passed into createUIComponents
219
235
  // so component blocks can include token state in their own hashes.
220
- const tokenHash = hashString(stableStringify(TOKENS));
236
+ //
237
+ // Includes RENDER_ENGINE_VERSION so changes to the demoFrame* rendering
238
+ // code (e.g. wrapping fontsize cells into a row, swapping shadow layout)
239
+ // invalidate the cached row even when TOKENS values are unchanged. Same
240
+ // pattern buildStateComponentSetHash uses for state masters — without
241
+ // this, a "fix" lands in code.js but the row paints from the stale cache.
242
+ const tokenHash = hashString(stableStringify(TOKENS) + ':' + RENDER_ENGINE_VERSION);
221
243
 
222
244
  // The design-tokens toggle is a synthetic preflight item; strip it before
223
245
  // forwarding the excluded list to the component builder so it isn't treated
@@ -228,7 +250,11 @@ export async function buildDesignSystemSinglePage(
228
250
  // --- Design Tokens row (incremental) ---
229
251
  // Rebuilt only when token values have changed since the last run, and only
230
252
  // when the design-tokens preflight toggle is selected.
231
- let tokensRow: FrameNode | null = findChildByName(ds, 'Design Tokens') as FrameNode | null;
253
+ // Look for the renamed frame first; fall back to the legacy name so
254
+ // existing Figma files with a "Design Tokens" frame migrate in place
255
+ // (the next rebuild renames it via `.name = TOKENS_ROW_NAME` below).
256
+ let tokensRow: FrameNode | null = (findChildByName(ds, TOKENS_ROW_NAME)
257
+ || findChildByName(ds, LEGACY_TOKENS_ROW_NAME)) as FrameNode | null;
232
258
  const tokensNeedRebuild = !skipDesignTokens && (!tokensRow || getFrameHash(tokensRow) !== tokenHash);
233
259
  if (tokensNeedRebuild) await onStatus('Building design tokens…');
234
260
  if (tokensNeedRebuild) {
@@ -237,7 +263,7 @@ export async function buildDesignSystemSinglePage(
237
263
  if (tokensRow) tokensRow.remove();
238
264
 
239
265
  tokensRow = figma.createFrame();
240
- tokensRow.name = 'Design Tokens';
266
+ tokensRow.name = TOKENS_ROW_NAME;
241
267
  tokensRow.layoutMode = 'VERTICAL';
242
268
  tokensRow.primaryAxisSizingMode = 'AUTO';
243
269
  tokensRow.counterAxisSizingMode = 'AUTO';
@@ -258,9 +284,9 @@ export async function buildDesignSystemSinglePage(
258
284
  themeNames.map((themeName) => demoFrameFonts(themeName))
259
285
  ));
260
286
  appendIfPresent(buildTokensCategorySection('Font sizes', [demoFrameFontSizes()]));
261
- appendIfPresent(buildTokensCategorySection('Radius', [demoFrameRadii()]));
262
287
  appendIfPresent(buildTokensCategorySection('Spacing', [demoFrameSpacing()]));
263
288
  appendIfPresent(buildTokensCategorySection('Breakpoints', [demoFrameBreakpoints()]));
289
+ appendIfPresent(buildTokensCategorySection('Radius', [demoFrameRadii()]));
264
290
  appendIfPresent(buildTokensCategorySection(
265
291
  'Shadows',
266
292
  themeNames.map((themeName) => demoFrameShadows(themeName))
@@ -279,7 +305,10 @@ export async function buildDesignSystemSinglePage(
279
305
  // Default y for a new UI Components section (below tokens row).
280
306
  // Only used when the section does not yet exist on the page. When the design
281
307
  // tokens row was skipped and never existed, fall back to the page top.
282
- const defaultUiY = tokensRow ? (tokensRow.y + tokensRow.height + 80) : 48;
308
+ // The 240px gap is intentionally larger than the inter-row spacing inside
309
+ // each section so the visual hierarchy reads as two distinct top-level
310
+ // chapters (custom tokens vs. UI components) instead of a continuous stream.
311
+ const defaultUiY = tokensRow ? (tokensRow.y + tokensRow.height + 240) : 48;
283
312
 
284
313
  // Keep hidden master library aligned with current scanner output and themes.
285
314
  // Removes stale/duplicate generated masters from old runs.
@@ -23,6 +23,102 @@ import { getNodeEffectiveClasses, getNodeMarginTopPx } from './node-helpers';
23
23
  * its parent's content width so the tab bar visually fills the row.
24
24
  */
25
25
 
26
+ /**
27
+ * True when a scene node is "text-bearing" — either a TEXT leaf or a
28
+ * frame whose entire visible subtree is text (paragraphs, spans, text
29
+ * nodes wrapped in transparent frames). Buttons, inputs, icons, and
30
+ * other interactive primitives are NOT text-bearing — they're the
31
+ * shrink-resistant siblings we measure around.
32
+ *
33
+ * Used by `constrainSingleHorizontalTextChild` to treat shadcn's
34
+ * canonical `<div><p>Heading</p><p>Description…</p></div>` wrapper
35
+ * the same way as a direct `<p>` child: pick it as the shrink target
36
+ * in a `flex justify-between` row, constrain its width to the
37
+ * available space, and wrap the text inside.
38
+ */
39
+ function isTextBearingNode(node: SceneNode | undefined | null): boolean {
40
+ if (!node) return false;
41
+ if (node.type === 'TEXT') return true;
42
+ if (node.type !== 'FRAME') return false;
43
+ const children = (node as FrameNode).children;
44
+ if (!Array.isArray(children) || children.length === 0) return false;
45
+ for (const child of children) {
46
+ if (!child) return false;
47
+ if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') continue;
48
+ if (!isTextBearingNode(child)) return false;
49
+ }
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Recursively pin every TEXT descendant of a text-bearing wrapper to
55
+ * `textAutoResize = 'HEIGHT'` and resize it to the given width so the
56
+ * text wraps within the wrapper. Without this, Figma keeps text at
57
+ * its single-line content width and the text visually overflows even
58
+ * when its parent frame got the right width.
59
+ */
60
+ function wrapTextDescendants(node: SceneNode, width: number): void {
61
+ if (!node) return;
62
+ if (node.type === 'TEXT') {
63
+ if (node.textAutoResize === undefined) return;
64
+ try {
65
+ node.textAutoResize = 'HEIGHT';
66
+ node.resize(Math.max(1, width), node.height);
67
+ } catch (_err) {
68
+ // ignore resize errors
69
+ }
70
+ return;
71
+ }
72
+ if (node.type !== 'FRAME') return;
73
+ const innerWidth = Math.max(
74
+ 0,
75
+ width - ((node.paddingLeft || 0) + (node.paddingRight || 0)),
76
+ );
77
+ for (const child of node.children || []) {
78
+ wrapTextDescendants(child, innerWidth);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resize a text-bearing wrapper frame to `width` and force its
84
+ * sizing mode to FIXED on the axis the parent dictates. Mirrors
85
+ * what `applyFullWidthIfPossible` does for `w-full` children — but
86
+ * applied locally here without requiring the wrapper to be marked
87
+ * full-width.
88
+ */
89
+ function resizeTextBearingChild(child: SceneNode, width: number): void {
90
+ if (child.type === 'TEXT') {
91
+ try {
92
+ child.textAutoResize = 'HEIGHT';
93
+ child.resize(Math.max(1, width), child.height);
94
+ } catch (_err) {
95
+ // ignore resize errors
96
+ }
97
+ return;
98
+ }
99
+ if (child.type !== 'FRAME') return;
100
+ try {
101
+ child.resize(Math.max(1, width), child.height);
102
+ // The parent row is HORIZONTAL, so the wrapper's primary axis (its OWN
103
+ // width contribution) must be fixed on the horizontal axis. Whether that
104
+ // maps to primary or counter on the wrapper depends on the wrapper's
105
+ // own layoutMode.
106
+ if (child.layoutMode === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
107
+ child.primaryAxisSizingMode = 'FIXED';
108
+ } else if (child.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
109
+ child.counterAxisSizingMode = 'FIXED';
110
+ }
111
+ if ('layoutAlign' in child) {
112
+ // STRETCH would otherwise let the wrapper inherit the row height too,
113
+ // which collapses paragraph spacing.
114
+ child.layoutAlign = 'INHERIT';
115
+ }
116
+ } catch (_err) {
117
+ // ignore resize errors
118
+ }
119
+ wrapTextDescendants(child, width);
120
+ }
121
+
26
122
  export function constrainSingleHorizontalTextChild(node: SceneNode): void {
27
123
  if (node.type !== 'FRAME') return;
28
124
  const frame = node;
@@ -46,21 +142,26 @@ export function constrainSingleHorizontalTextChild(node: SceneNode): void {
46
142
  return;
47
143
  }
48
144
 
49
- const textChildren = frame.children.filter((child): child is TextNode => child?.type === 'TEXT');
50
- if (textChildren.length !== 1) return;
145
+ // Text-bearing children: either a direct TEXT node or a transparent
146
+ // wrapper frame whose entire subtree is text. Wrappers are needed for
147
+ // patterns like `<div><p>Title</p><p>Description…</p></div>` next to
148
+ // a `<Button>` — the description hugs to its one-line width and
149
+ // overlaps the button in `flex justify-between` rows.
150
+ const textBearing = frame.children.filter((child) => isTextBearingNode(child));
151
+ if (textBearing.length !== 1) return;
51
152
 
52
153
  // CSS flex: absolute/fixed siblings are out-of-flow and don't consume space
53
154
  // in the row. Including them would steal width from the text and force wrap
54
155
  // (e.g. a Select item's absolute check-indicator shrinking the label text).
55
156
  const inFlowNonText = frame.children.filter((child) => {
56
- if (!child || child.type === 'TEXT') return false;
157
+ if (!child || isTextBearingNode(child)) return false;
57
158
  if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') return false;
58
159
  return true;
59
160
  });
60
161
  const nonTextWidth = inFlowNonText.reduce((sum, child) => {
61
162
  return sum + ('width' in child ? child.width : 0);
62
163
  }, 0);
63
- const gapContributingCount = inFlowNonText.length + textChildren.length;
164
+ const gapContributingCount = inFlowNonText.length + textBearing.length;
64
165
 
65
166
  const availableWidth = Math.max(
66
167
  0,
@@ -73,18 +174,13 @@ export function constrainSingleHorizontalTextChild(node: SceneNode): void {
73
174
 
74
175
  if (availableWidth <= 0) return;
75
176
 
76
- const textChild = textChildren[0];
177
+ const textChild = textBearing[0];
77
178
  // CSS-default behavior for text in a horizontal flex row is to overflow, not
78
179
  // wrap. Only constrain if the text would actually exceed the available space
79
180
  // AND there's another in-flow sibling competing for it.
80
181
  if (inFlowNonText.length === 0) return;
81
- if ((textChild.width || 0) <= availableWidth) return;
82
- try {
83
- textChild.textAutoResize = 'HEIGHT';
84
- textChild.resize(availableWidth, textChild.height);
85
- } catch (_err) {
86
- // ignore resize errors
87
- }
182
+ if (!('width' in textChild) || (textChild.width || 0) <= availableWidth) return;
183
+ resizeTextBearingChild(textChild, availableWidth);
88
184
  }
89
185
 
90
186
  export function stabilizeHorizontalStretchChild(child: SceneNode, parent: SceneNode): void {
@@ -189,3 +285,4 @@ export function enforceTabsChildSizing(
189
285
  // ignore resize errors
190
286
  }
191
287
  }
288
+
@@ -736,3 +736,41 @@ export function shouldRenderStatesForStory(def: ComponentDef, story: ComponentSt
736
736
  return storyIndex === 0;
737
737
  }
738
738
 
739
+ /**
740
+ * Should the plugin render responsive-breakpoint frames for this story?
741
+ *
742
+ * Explicit opt-in/opt-out via Storybook's
743
+ * `parameters.inkbridge.responsive` (read by the scanner into
744
+ * `story.responsive`) wins. When unset, the default policy is to
745
+ * render responsive frames only for the canonical "Default" story of
746
+ * each component (or first export if no Default exists) — mirrors
747
+ * `shouldRenderStatesForStory`. Variant stories like `Loading`,
748
+ * `WithError`, `WithLegend` get a single non-responsive frame unless
749
+ * they explicitly opt in via `parameters: { inkbridge: { responsive: true } }`.
750
+ *
751
+ * Rationale: most variant stories repeat the same responsive shape as
752
+ * Default. Rendering responsive frames for every variant explodes the
753
+ * design-system page with redundant frames and increases build time.
754
+ */
755
+ export function shouldRenderResponsiveForStory(
756
+ def: ComponentDef,
757
+ story: ComponentStory,
758
+ storyIndex: number
759
+ ): boolean {
760
+ if (story && story.responsive === true) return true;
761
+ if (story && story.responsive === false) return false;
762
+ const name = String(story && story.name ? story.name : '').toLowerCase();
763
+ if (name === 'default' || name.indexOf('default') !== -1) return true;
764
+ const stories = def && def.stories ? def.stories : [];
765
+ let hasDefault = false;
766
+ for (let i = 0; i < stories.length; i++) {
767
+ const storyName = String(stories[i] && stories[i].name ? stories[i].name : '').toLowerCase();
768
+ if (storyName && (storyName === 'default' || storyName.indexOf('default') !== -1)) {
769
+ hasDefault = true;
770
+ break;
771
+ }
772
+ }
773
+ if (hasDefault) return false;
774
+ return storyIndex === 0;
775
+ }
776
+
@@ -1,5 +1,6 @@
1
1
  import { isTruthyStateProp } from './state-utils';
2
2
  import { isSelectItemTag } from './tag-predicates';
3
+ import { isEffectivelyHiddenOrSrOnly } from '../tailwind';
3
4
  import type { NodeIR, NodeIRElement } from '../tailwind';
4
5
  import type { RenderContext } from './render-context';
5
6
 
@@ -106,7 +107,13 @@ export function collectTextContent(node: NodeIR): string {
106
107
  walk(current.child);
107
108
  return;
108
109
  }
109
- if (current.classes.includes('hidden') || current.classes.includes('sr-only')) {
110
+ // Skip nodes that resolve to `display: hidden` or `sr-only` after
111
+ // the last-wins cascade. Uses the shared helper so this stays in
112
+ // lock-step with the outer hidden gate in `buildFigmaNode` — the
113
+ // recurring "<p hidden sm:block> disappeared" bug was exactly this
114
+ // function drifting away from the gate via a raw
115
+ // `classes.includes('hidden')` check.
116
+ if (isEffectivelyHiddenOrSrOnly(current.classes)) {
110
117
  return;
111
118
  }
112
119
  for (const child of current.children || []) {
@@ -40,7 +40,9 @@ import {
40
40
  applyAspectRatioIfPossible,
41
41
  applyFullWidthIfPossible,
42
42
  applyRingIfPossible,
43
+ applyTableColumnAlignment,
43
44
  applyVerticalFlexGrowIfPossible,
45
+ breakHugStretchDeadlocks,
44
46
  enforceGrowChildPrimaryFixed,
45
47
  extractGridColumns,
46
48
  extractMaxWidth,
@@ -71,7 +73,7 @@ import { getActivePack } from '../plugin';
71
73
  import { normalizeLayoutClasses } from './story-layout';
72
74
  import { isGeneratedDesignSystemNode, setGeneratedFallbackReason, tagGeneratedNode } from './generated-node';
73
75
  import { type StoryBuilderContext, type StoryRenderContext } from './story-builder-context';
74
- import { createStatePreviewBlock, createResponsivePreviewBlock, shouldRenderStatesForStory } from './preview-builder';
76
+ import { createStatePreviewBlock, createResponsivePreviewBlock, shouldRenderResponsiveForStory, shouldRenderStatesForStory } from './preview-builder';
75
77
  import {
76
78
  getComponentSectionName,
77
79
  groupComponentDefs,
@@ -99,14 +101,6 @@ import {
99
101
  renderStoryInstances,
100
102
  } from './instance-rendering';
101
103
 
102
- // Per-component + per-phase build timings to the Figma plugin console.
103
- // Off by default for end users — flip to `true` locally when you need to
104
- // see where build time goes during a "Generate Design System Page" run.
105
- // The loading-panel status messages are always emitted via `onStatus`;
106
- // this flag only gates the extra `console.log` lines that include
107
- // elapsed-seconds + per-component millisecond timings.
108
- const INKBRIDGE_PERF_LOGS = false;
109
-
110
104
  function ensureHeaderBlock(
111
105
  parent: FrameNode,
112
106
  frameName: string,
@@ -552,10 +546,34 @@ function populateStoryLayout(
552
546
  // positions settle against the final heights.
553
547
  walkVerticalFlexGrow(layout);
554
548
 
549
+ // Break the STRETCH-in-HUG-chain deadlock that CSS resolves via
550
+ // max-content but Figma cannot. For every HUG container in an
551
+ // unanchored chain that broadcasts STRETCH to its children, flip
552
+ // counterAxisAlignItems to MIN so children hug naturally and the
553
+ // container hugs to the widest child. See
554
+ // `src/layout/intrinsic-sizing.ts` for the predicate contract and
555
+ // `src/layout/intrinsic-applier.ts` for the transform rationale.
556
+ // Runs before the absolute-reflow pass so width readings downstream
557
+ // see the post-flip values.
558
+ breakHugStretchDeadlocks(layout);
559
+
555
560
  // Final pass: resolve deferred absolute positioning after all story-level
556
561
  // width/height/layout operations have completed.
557
562
  reflowDeferredAbsolutePositioningTree(layout);
558
563
 
564
+ // Unify column widths across rows of every <table> in the tree.
565
+ // CSS / `table-fixed` align columns globally; Figma sizes each row's
566
+ // cells independently, so rows whose content sums to different
567
+ // totals render with misaligned columns and `whitespace-nowrap`
568
+ // cells visually run into their neighbours. The pass picks per-
569
+ // column max-content widths from settled cell widths and resizes
570
+ // every cell to its column's max. Runs after the absolute-reflow
571
+ // pass so cell widths are final at the time we sample them.
572
+ // See `src/layout/table-layout.ts` for the predicate / transform
573
+ // rationale and known-skip cases (colspan>1, explicit width
574
+ // overrides).
575
+ applyTableColumnAlignment(layout);
576
+
559
577
  return { added, renderMode, useStoryTree };
560
578
  }
561
579
 
@@ -710,12 +728,7 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
710
728
  // main.ts (Reading components / Building tokens / Building components)
711
729
  // still yield 50ms so the canvas paints between top-level phases.
712
730
  const onStatus = options.onStatus;
713
- const phaseStart = Date.now();
714
731
  async function emit(message: string): Promise<void> {
715
- if (INKBRIDGE_PERF_LOGS) {
716
- const elapsed = ((Date.now() - phaseStart) / 1000).toFixed(1);
717
- console.log('[Inkbridge][build] +' + elapsed + 's ' + message);
718
- }
719
732
  if (onStatus) {
720
733
  await Promise.resolve(onStatus({ detail: message, noYield: true }));
721
734
  }
@@ -767,7 +780,9 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
767
780
  // `createUIComponents` returns; this one front-loads it. Mirrors the
768
781
  // post-build math (only pushes y down, never up) so designer-set
769
782
  // positions further down are preserved.
770
- const tokensSibling = findChildByName(parent, 'Design Tokens') as FrameNode | null;
783
+ // Renamed sibling name first, legacy fallback for in-flight migration.
784
+ const tokensSibling = (findChildByName(parent, 'Custom Tokens')
785
+ || findChildByName(parent, 'Design Tokens')) as FrameNode | null;
771
786
  if (tokensSibling) {
772
787
  const tokensY = typeof tokensSibling.y === 'number' ? tokensSibling.y : 0;
773
788
  const tokensHeight = typeof tokensSibling.height === 'number' ? tokensSibling.height : 0;
@@ -792,7 +807,11 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
792
807
  }
793
808
 
794
809
  function formatThemeLabel(theme: string): string {
795
- if (!theme) return 'Theme';
810
+ // Empty / missing theme 'Default'. With the trailing ' Theme'
811
+ // suffix in `ensureHeaderBlock` this becomes `Default Theme`
812
+ // rather than the double-word `Theme Theme` an empty fallback
813
+ // used to produce.
814
+ if (!theme) return 'Default';
796
815
  return theme.charAt(0).toUpperCase() + theme.slice(1);
797
816
  }
798
817
 
@@ -856,13 +875,16 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
856
875
  const statesBlock = createStatePreviewBlock(def, story, theme, ctx);
857
876
  if (statesBlock) storyWrap.appendChild(statesBlock);
858
877
  }
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);
878
+ // Two-stage gate:
879
+ // 1. Story-level opt-in/opt-out (parameters.inkbridge.responsive)
880
+ // with default = only the canonical Default story.
881
+ // 2. The renderer's own no-responsive-signals fallback inside
882
+ // createResponsivePreviewBlock (returns null when layout / tree
883
+ // / instance classes lack `sm:`/`md:`/`lg:` etc.).
884
+ if (shouldRenderResponsiveForStory(def, story, storyIndex)) {
885
+ const responsiveBlock = createResponsivePreviewBlock(def, story, theme, ctx);
886
+ if (responsiveBlock) storyWrap.appendChild(responsiveBlock);
887
+ }
866
888
  block.appendChild(storyWrap);
867
889
  }
868
890
 
@@ -908,7 +930,14 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
908
930
  colFrame.counterAxisSizingMode = 'AUTO';
909
931
  colFrame.counterAxisAlignItems = 'MIN';
910
932
  colFrame.paddingLeft = colFrame.paddingRight = BOARD_LAYOUT.columnPaddingX;
911
- colFrame.paddingTop = colFrame.paddingBottom = BOARD_LAYOUT.columnPaddingY;
933
+ // Top padding is 0 so the column's "Default Theme" header sits flush
934
+ // under the section's "UI Components" header (separated only by the
935
+ // section-level `boardGap`). The full `columnPaddingY` value still
936
+ // applies at the bottom as a tail buffer before the next section.
937
+ // Previously top == bottom == 160 left a 256-px gap between the two
938
+ // headers that made the section header look orphaned at the top.
939
+ colFrame.paddingTop = 0;
940
+ colFrame.paddingBottom = BOARD_LAYOUT.columnPaddingY;
912
941
  colFrame.fills = [];
913
942
  // Keep columns transparent; story surfaces are responsible for their own backgrounds.
914
943
  ctx.applyClipBehavior(colFrame, []);
@@ -1153,12 +1182,7 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
1153
1182
  // Per-component timing log to the plugin console (no toast,
1154
1183
  // no yield — would balloon overhead). Lets us see in dev tools
1155
1184
  // which components dominate build time without slowing the run.
1156
- const componentStart = Date.now();
1157
1185
  const newBlock = buildComponentBlock(def, theme, colFrame);
1158
- const componentMs = Date.now() - componentStart;
1159
- if (INKBRIDGE_PERF_LOGS && componentMs > 50) {
1160
- console.log('[Inkbridge][build] · ' + def.name + ' (' + theme + ') ' + componentMs + 'ms');
1161
- }
1162
1186
  setFrameHash(newBlock, blockHash);
1163
1187
  tagGeneratedNode(newBlock, 'component-block:' + sectionTitle + ':' + theme + ':' + def.name);
1164
1188
 
@@ -1226,9 +1250,15 @@ export async function createUIComponents(parent: FrameNode | PageNode, opts: Cre
1226
1250
  const multiMode = isMultiModeEnabled();
1227
1251
  if (multiMode) {
1228
1252
  const activeTheme = requestedThemes[0] || 'primary';
1229
- expectedColumnNames['Theme Column'] = true;
1230
- const singleCol = await addColumn('Theme', activeTheme);
1231
- if (findChildIndexByName(columns, 'Theme Column') !== 0) {
1253
+ // Single-column multi-mode rendering: there's one frame but its
1254
+ // contents flip via Figma variable modes. Use `'Default'` as the
1255
+ // generic label so the header reads `Default Theme` (not the
1256
+ // double-word `Theme Theme` that resulted from a literal `'Theme'`
1257
+ // label getting suffixed with ` Theme` by `ensureHeaderBlock`).
1258
+ // Also flows into the progress notice (`Building default theme…`).
1259
+ expectedColumnNames['Default Column'] = true;
1260
+ const singleCol = await addColumn('Default', activeTheme);
1261
+ if (findChildIndexByName(columns, 'Default Column') !== 0) {
1232
1262
  columns.insertChild(0, singleCol);
1233
1263
  }
1234
1264
  setThemeMode(singleCol, activeTheme);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  extractLeadingContainerMaxWidthFromTree,
3
+ findAnyMaxWidthInTree,
3
4
  splitClassName,
4
5
  treeHasFullWidth,
5
6
  type JsxElement,
@@ -176,10 +177,20 @@ export function resolveStoryLayoutWidth(
176
177
  const fixedWidth = extractFixedWidth(layoutClasses);
177
178
  const maxWidthFromLayout = extractMaxWidth(layoutClasses);
178
179
  const maxWidthFromTree = extractLeadingContainerMaxWidthFromTree(story.jsxTree);
180
+ // Looser fallback: any `max-w-*` anywhere in the tree. Catches portal-mounted
181
+ // dialogs (shadcn DialogContent: `sm:max-w-lg` without `w-full`, wrapped by
182
+ // a multi-child Dialog → leading-container helper bails). Only consulted
183
+ // when the strict helper returns null, so existing behaviour is unchanged.
184
+ const looseMaxWidthFromTree = maxWidthFromTree == null
185
+ ? findAnyMaxWidthInTree(story.jsxTree)
186
+ : null;
187
+ const effectiveMaxWidthFromTree = maxWidthFromTree != null
188
+ ? maxWidthFromTree
189
+ : looseMaxWidthFromTree;
179
190
  const constrainedMaxWidth = (
180
- maxWidthFromLayout != null && maxWidthFromTree != null
181
- ? Math.min(maxWidthFromLayout, maxWidthFromTree)
182
- : (maxWidthFromTree != null ? maxWidthFromTree : maxWidthFromLayout)
191
+ maxWidthFromLayout != null && effectiveMaxWidthFromTree != null
192
+ ? Math.min(maxWidthFromLayout, effectiveMaxWidthFromTree)
193
+ : (effectiveMaxWidthFromTree != null ? effectiveMaxWidthFromTree : maxWidthFromLayout)
183
194
  );
184
195
  const mobileOnlyWidth = mobileOnlyRootWidth(story, layoutClasses);
185
196
  const fallbackWidth = !fixedWidth && story.jsxTree && treeHasFullWidth(story.jsxTree, null, {
@@ -55,6 +55,14 @@ export function isSelectRootTag(tagName: string): boolean {
55
55
  return tagName === 'Select' || tagName === 'SelectPrimitive.Root';
56
56
  }
57
57
 
58
+ export function isDialogRootTag(tagName: string): boolean {
59
+ return tagName === 'Dialog' || tagName === 'DialogPrimitive.Root';
60
+ }
61
+
62
+ export function isDialogContentTag(tagName: string): boolean {
63
+ return tagName === 'DialogContent' || tagName === 'DialogPrimitive.Content';
64
+ }
65
+
58
66
  export function isSelectContentTag(tagName: string): boolean {
59
67
  return tagName === 'SelectContent' || tagName === 'SelectPrimitive.Content';
60
68
  }
@@ -86,6 +86,32 @@ export function getResolvedTextSizeFromClasses(classes: string[], fallback?: num
86
86
  return resolved;
87
87
  }
88
88
 
89
+ /**
90
+ * Map the active Tailwind `font-*` family class to a theme font role
91
+ * (`sans` / `mono` / `serif` / `heading`). Falls back to the tag-based
92
+ * heading detection when no `font-*` family class is present.
93
+ *
94
+ * Without this, every text node — including `<span className="font-mono">`
95
+ * — was created with `fontRole: 'sans'`, so the mono family override
96
+ * never reached `createTextNode` and prices / numbers stayed in the
97
+ * surrounding sans face.
98
+ */
99
+ export function getFontRoleFromClasses(classes: string[], tagLower: string): string {
100
+ // Last `font-*` class in the array wins (mirrors CSS cascade — a
101
+ // child `font-mono` overrides a parent `font-sans` even when both
102
+ // appear in the same merged array).
103
+ for (let i = classes.length - 1; i >= 0; i--) {
104
+ const c = classes[i];
105
+ if (c === 'font-mono') return 'mono';
106
+ if (c === 'font-serif') return 'serif';
107
+ if (c === 'font-sans') return 'sans';
108
+ }
109
+ if (tagLower === 'h1' || tagLower === 'h2' || tagLower === 'h3' || tagLower === 'h4' || tagLower === 'h5' || tagLower === 'h6') {
110
+ return 'heading';
111
+ }
112
+ return 'sans';
113
+ }
114
+
89
115
  export function getResolvedBoldFromClasses(classes: string[], fallback?: boolean): boolean | undefined {
90
116
  let rank = fallback ? 7 : 4;
91
117
  let seenWeight = false;