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.
- package/README.md +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- 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
|
-
|
|
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, [
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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 +
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
|
|
865
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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 &&
|
|
181
|
-
? Math.min(maxWidthFromLayout,
|
|
182
|
-
: (
|
|
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;
|