inkbridge 0.1.0-beta.2 → 0.1.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
import { resolvePortalAwareStoryTree, jsxTreeIsPortalTriggerOnly } from './portal-handling';
|
|
2
|
+
import { getThemeContext, BOARD_LAYOUT } from './theme-context';
|
|
3
|
+
import { getNonCvaSymbolFallbackReason } from './symbol-fallback';
|
|
4
|
+
import {
|
|
5
|
+
removeDirectTextChildren,
|
|
6
|
+
removeDuplicateChildrenByName,
|
|
7
|
+
hasNodeChildren,
|
|
8
|
+
} from './frame-utils';
|
|
9
|
+
import {
|
|
10
|
+
ENABLE_SYMBOL_MASTERS,
|
|
11
|
+
findExistingComponentLibraryRoot,
|
|
12
|
+
shouldCreateNonCvaMaster,
|
|
13
|
+
} from './master-shared';
|
|
14
|
+
import {
|
|
15
|
+
shouldCreateStateMaster,
|
|
16
|
+
} from './state-master';
|
|
17
|
+
import { shouldCreateCvaMaster } from './cva-master';
|
|
18
|
+
import {
|
|
19
|
+
normalizeComponentName,
|
|
20
|
+
} from './story-tree-search';
|
|
21
|
+
import { warmSymbolMasters } from './non-cva-master';
|
|
22
|
+
import {
|
|
23
|
+
componentInstanceBackend,
|
|
24
|
+
createCVAStoryInstance,
|
|
25
|
+
createStateStoryInstance,
|
|
26
|
+
createSimpleStoryInstance,
|
|
27
|
+
} from './story-instance';
|
|
28
|
+
// External modules.
|
|
29
|
+
import {
|
|
30
|
+
COMPONENT_DEFS,
|
|
31
|
+
TOKENS,
|
|
32
|
+
debug,
|
|
33
|
+
extractTextColorToken,
|
|
34
|
+
getThemeNames,
|
|
35
|
+
isMultiModeEnabled,
|
|
36
|
+
resolveTextColorValue,
|
|
37
|
+
setThemeMode,
|
|
38
|
+
} from '../tokens';
|
|
39
|
+
import {
|
|
40
|
+
applyAspectRatioIfPossible,
|
|
41
|
+
applyFullWidthIfPossible,
|
|
42
|
+
applyRingIfPossible,
|
|
43
|
+
applyVerticalFlexGrowIfPossible,
|
|
44
|
+
enforceGrowChildPrimaryFixed,
|
|
45
|
+
extractGridColumns,
|
|
46
|
+
extractMaxWidth,
|
|
47
|
+
reapplyRightPositionedChildren,
|
|
48
|
+
reflowDeferredAbsolutePositioningTree,
|
|
49
|
+
shouldSkipFullWidthForClasses,
|
|
50
|
+
} from '../layout';
|
|
51
|
+
import {
|
|
52
|
+
extractRootGridColsFromTree,
|
|
53
|
+
propagateChildSelectorClasses,
|
|
54
|
+
splitClassName,
|
|
55
|
+
tailwindClassesToStyle,
|
|
56
|
+
type JsxElement,
|
|
57
|
+
} from '../tailwind';
|
|
58
|
+
import {
|
|
59
|
+
createCompoundComponent,
|
|
60
|
+
tryCreateNonCvaComponentInstance as tryCreateNonCvaComponentInstanceShared,
|
|
61
|
+
type ComponentDef,
|
|
62
|
+
type ComponentStory,
|
|
63
|
+
type LayoutInfo,
|
|
64
|
+
} from '../components';
|
|
65
|
+
import { createTextNode } from '../text';
|
|
66
|
+
import { findChildByName, findChildIndexByName, getFrameHash, hashDef, hashString, setFrameHash } from '../cache';
|
|
67
|
+
import { RENDER_ENGINE_VERSION } from '../render-engine-version';
|
|
68
|
+
import { getActivePack } from '../plugin';
|
|
69
|
+
|
|
70
|
+
// Sibling design-system modules.
|
|
71
|
+
import { normalizeLayoutClasses } from './story-layout';
|
|
72
|
+
import { isGeneratedDesignSystemNode, setGeneratedFallbackReason, tagGeneratedNode } from './generated-node';
|
|
73
|
+
import { type StoryBuilderContext, type StoryRenderContext } from './story-builder-context';
|
|
74
|
+
import { createStatePreviewBlock, createResponsivePreviewBlock, shouldRenderStatesForStory } from './preview-builder';
|
|
75
|
+
import {
|
|
76
|
+
getComponentSectionName,
|
|
77
|
+
groupComponentDefs,
|
|
78
|
+
inferComponentSection,
|
|
79
|
+
} from './component-sections';
|
|
80
|
+
import {
|
|
81
|
+
blockHashMatchesCurrent,
|
|
82
|
+
findComponentBlocksInPage,
|
|
83
|
+
normalizeForPreflight,
|
|
84
|
+
} from './block-cache';
|
|
85
|
+
import {
|
|
86
|
+
resolveStoryLayoutHeight,
|
|
87
|
+
resolveStoryLayoutWidth,
|
|
88
|
+
} from './story-dimensioning';
|
|
89
|
+
import {
|
|
90
|
+
INSTANCE_FALLBACK_COMPONENTS,
|
|
91
|
+
shouldPreferInstanceRendering,
|
|
92
|
+
shouldSkipStoryJsxTree,
|
|
93
|
+
} from './story-render-strategy';
|
|
94
|
+
import { setStoryRenderDiagnostics } from './story-diagnostics';
|
|
95
|
+
import { type StatusUpdate } from './design-system';
|
|
96
|
+
import {
|
|
97
|
+
appendResolvedInstance,
|
|
98
|
+
renderGuardedPortalStoryInstances,
|
|
99
|
+
renderStoryInstances,
|
|
100
|
+
} from './instance-rendering';
|
|
101
|
+
|
|
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
|
+
function ensureHeaderBlock(
|
|
111
|
+
parent: FrameNode,
|
|
112
|
+
frameName: string,
|
|
113
|
+
title: string,
|
|
114
|
+
description: string | null,
|
|
115
|
+
opts?: { titleSize?: number; titleLineHeight?: number; descriptionSize?: number }
|
|
116
|
+
): FrameNode {
|
|
117
|
+
removeDuplicateChildrenByName(parent, frameName, 'FRAME');
|
|
118
|
+
let frame = findChildByName(parent, frameName);
|
|
119
|
+
if (!frame) {
|
|
120
|
+
frame = figma.createFrame();
|
|
121
|
+
frame.name = frameName;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
frame.layoutMode = 'VERTICAL';
|
|
125
|
+
frame.primaryAxisSizingMode = 'AUTO';
|
|
126
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
127
|
+
frame.counterAxisAlignItems = 'MIN';
|
|
128
|
+
frame.itemSpacing = description ? 10 : 0;
|
|
129
|
+
frame.fills = [];
|
|
130
|
+
|
|
131
|
+
const titleNode = createTextNode(title, {
|
|
132
|
+
fontSize: opts && opts.titleSize ? opts.titleSize : 20,
|
|
133
|
+
lineHeight: opts && opts.titleLineHeight ? opts.titleLineHeight : undefined,
|
|
134
|
+
bold: true,
|
|
135
|
+
});
|
|
136
|
+
titleNode.name = 'Title';
|
|
137
|
+
|
|
138
|
+
frame.children.slice().forEach(function(child: SceneNode) { child.remove(); });
|
|
139
|
+
frame.appendChild(titleNode);
|
|
140
|
+
|
|
141
|
+
if (description) {
|
|
142
|
+
const descriptionNode = createTextNode(description, {
|
|
143
|
+
fontSize: opts && opts.descriptionSize ? opts.descriptionSize : 14,
|
|
144
|
+
lineHeight: 20,
|
|
145
|
+
opacity: 0.62,
|
|
146
|
+
});
|
|
147
|
+
descriptionNode.name = 'Description';
|
|
148
|
+
frame.appendChild(descriptionNode);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return frame;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function pruneGeneratedComponentLibrary(themeNames: string[], ctx: StoryBuilderContext): void {
|
|
155
|
+
if (!ENABLE_SYMBOL_MASTERS) return;
|
|
156
|
+
const root = findExistingComponentLibraryRoot();
|
|
157
|
+
if (!root || !Array.isArray(root.children)) return;
|
|
158
|
+
|
|
159
|
+
const requestedThemes = Array.isArray(themeNames)
|
|
160
|
+
? themeNames
|
|
161
|
+
.filter(function(theme: string) { return typeof theme === 'string' && theme.trim().length > 0; })
|
|
162
|
+
.map(function(theme: string) { return theme.trim(); })
|
|
163
|
+
: [];
|
|
164
|
+
const themeSet: Record<string, boolean> = {};
|
|
165
|
+
for (let i = 0; i < requestedThemes.length; i++) themeSet[requestedThemes[i]] = true;
|
|
166
|
+
|
|
167
|
+
const defsRaw = (COMPONENT_DEFS && Array.isArray(COMPONENT_DEFS.components)) ? COMPONENT_DEFS.components : [];
|
|
168
|
+
const defs = defsRaw
|
|
169
|
+
.map(ctx.normalizeComponentDef)
|
|
170
|
+
.filter(function(def: ComponentDef) {
|
|
171
|
+
return !!def && Array.isArray(def.stories) && def.stories.length > 0;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const expectedByTheme: Record<string, Record<string, boolean>> = {};
|
|
175
|
+
for (let i = 0; i < requestedThemes.length; i++) {
|
|
176
|
+
expectedByTheme[requestedThemes[i]] = {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < defs.length; i++) {
|
|
180
|
+
const def = defs[i];
|
|
181
|
+
for (let ti = 0; ti < requestedThemes.length; ti++) {
|
|
182
|
+
const theme = requestedThemes[ti];
|
|
183
|
+
if (shouldCreateCvaMaster(def) || shouldCreateStateMaster(def) || shouldCreateNonCvaMaster(def)) {
|
|
184
|
+
expectedByTheme[theme][def.name + ' [' + theme + ']'] = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rootChildren = root.children.slice();
|
|
190
|
+
const seenThemeFrames: Record<string, boolean> = {};
|
|
191
|
+
for (let i = 0; i < rootChildren.length; i++) {
|
|
192
|
+
const child = rootChildren[i];
|
|
193
|
+
if (!child || child.type !== 'FRAME') continue;
|
|
194
|
+
const name = String(child.name || '');
|
|
195
|
+
if (!name.endsWith(' Masters')) continue;
|
|
196
|
+
|
|
197
|
+
if (seenThemeFrames[name]) {
|
|
198
|
+
if (isGeneratedDesignSystemNode(child)) child.remove();
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
seenThemeFrames[name] = true;
|
|
202
|
+
|
|
203
|
+
const theme = name.slice(0, -' Masters'.length);
|
|
204
|
+
if (!themeSet[theme]) {
|
|
205
|
+
if (isGeneratedDesignSystemNode(child)) child.remove();
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const expectedNames = expectedByTheme[theme] || {};
|
|
210
|
+
if (!Array.isArray(child.children)) continue;
|
|
211
|
+
|
|
212
|
+
const childMasters = child.children.slice();
|
|
213
|
+
const seenMasterNames: Record<string, boolean> = {};
|
|
214
|
+
for (let mi = 0; mi < childMasters.length; mi++) {
|
|
215
|
+
const master = childMasters[mi];
|
|
216
|
+
if (!master) continue;
|
|
217
|
+
const masterName = String(master.name || '');
|
|
218
|
+
const isExpected = !!expectedNames[masterName];
|
|
219
|
+
const isGenerated = isGeneratedDesignSystemNode(master) || masterName.endsWith(' [' + theme + ']');
|
|
220
|
+
|
|
221
|
+
if (seenMasterNames[masterName]) {
|
|
222
|
+
if (isGenerated) master.remove();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
seenMasterNames[masterName] = true;
|
|
226
|
+
|
|
227
|
+
if (!isExpected && isGenerated) {
|
|
228
|
+
master.remove();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
function appendTypeDefaultPreview(
|
|
236
|
+
layout: LayoutInfo,
|
|
237
|
+
def: ComponentDef,
|
|
238
|
+
story: ComponentStory,
|
|
239
|
+
theme: string,
|
|
240
|
+
ctx: StoryBuilderContext
|
|
241
|
+
): boolean {
|
|
242
|
+
if (!def) return false;
|
|
243
|
+
if (def.type === 'compound') {
|
|
244
|
+
const symbolInstance = tryCreateNonCvaComponentInstanceShared(def, { props: {} }, theme, ctx, componentInstanceBackend, story);
|
|
245
|
+
if (symbolInstance) {
|
|
246
|
+
layout.appendChild(symbolInstance);
|
|
247
|
+
} else {
|
|
248
|
+
const comp = createCompoundComponent(layout, def, theme);
|
|
249
|
+
if (comp) {
|
|
250
|
+
setGeneratedFallbackReason(comp, getNonCvaSymbolFallbackReason(def, { props: {} }));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
if (def.type === 'simple') {
|
|
256
|
+
layout.appendChild(createSimpleStoryInstance(def, { props: {} }, theme, ctx, story));
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (def.type === 'cva') {
|
|
260
|
+
layout.appendChild(createCVAStoryInstance(def, { props: {} }, theme, ctx));
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (def.type === 'state') {
|
|
264
|
+
layout.appendChild(createStateStoryInstance(def, { props: {} }, theme, ctx, story));
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function populateStoryLayout(
|
|
271
|
+
layout: LayoutInfo,
|
|
272
|
+
def: ComponentDef | null,
|
|
273
|
+
story: ComponentStory,
|
|
274
|
+
theme: string,
|
|
275
|
+
colorGroup: Record<string, string>,
|
|
276
|
+
radiusGroup: Record<string, string> | null,
|
|
277
|
+
ctx: StoryBuilderContext,
|
|
278
|
+
effectiveWidth: number,
|
|
279
|
+
opts?: { viewportWidth?: number; allowTypeDefaultFallback?: boolean }
|
|
280
|
+
): { added: number; renderMode: string; useStoryTree: boolean } {
|
|
281
|
+
const layoutClasses = normalizeLayoutClasses(story.layoutClasses);
|
|
282
|
+
const skipLayoutFullWidth = shouldSkipFullWidthForClasses(layoutClasses);
|
|
283
|
+
const layoutGridCols = extractGridColumns(layoutClasses);
|
|
284
|
+
const allowAbsolute = ctx.hasExplicitHeight(layoutClasses);
|
|
285
|
+
// Story-level viewport width — passed down via context so deeper
|
|
286
|
+
// elements resolve responsive breakpoints against the viewport (matches
|
|
287
|
+
// CSS @media), not against their container's narrowed maxWidth.
|
|
288
|
+
const storyViewportWidth = opts?.viewportWidth != null && opts.viewportWidth > 0
|
|
289
|
+
? opts.viewportWidth
|
|
290
|
+
: (Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : undefined);
|
|
291
|
+
// When the tree has both a trigger element AND portal-unwrapped content:
|
|
292
|
+
// - closed-state stories (Default): portal is NOT rendered → no __fromPortal nodes → tree unchanged
|
|
293
|
+
// - open-state stories (OpenPanel): portal IS rendered → extract only the portal content so
|
|
294
|
+
// the popup panel renders in Figma rather than the trigger button
|
|
295
|
+
// When the tree has only a trigger (no __fromPortal): render as-is (trigger button).
|
|
296
|
+
// Three cases for stories that have both a trigger and portal content:
|
|
297
|
+
// 1. Open-state (defaultOpen=true): extract portal content → show popup in Figma
|
|
298
|
+
// 2. Closed-state (no defaultOpen, Radix SSR renders portal anyway): filter portal out → show trigger
|
|
299
|
+
// 3. No portal content at all (base-ui closed state): show tree as-is → shows trigger
|
|
300
|
+
const rawJsxTree = story.jsxTree
|
|
301
|
+
? resolvePortalAwareStoryTree(story.jsxTree)
|
|
302
|
+
: null;
|
|
303
|
+
// Normalize child-wildcard selector utilities (e.g. *:w-full sm:*:w-auto) into
|
|
304
|
+
// explicit per-child classes before rendering. This keeps main story renders and
|
|
305
|
+
// responsive preview renders behaviorally identical.
|
|
306
|
+
const effectiveJsxTree = rawJsxTree
|
|
307
|
+
? propagateChildSelectorClasses(rawJsxTree)
|
|
308
|
+
: null;
|
|
309
|
+
const useStoryTree = !!effectiveJsxTree
|
|
310
|
+
&& !shouldPreferInstanceRendering(def, story)
|
|
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
|
+
}
|
|
351
|
+
const layoutPadding = (layout.paddingLeft || 0) + (layout.paddingRight || 0);
|
|
352
|
+
const contentWidth = effectiveWidth ? effectiveWidth - layoutPadding : undefined;
|
|
353
|
+
let added = 0;
|
|
354
|
+
let renderMode = 'none';
|
|
355
|
+
|
|
356
|
+
if (useStoryTree) {
|
|
357
|
+
let rendered = false;
|
|
358
|
+
const rootNode = effectiveJsxTree as JsxElement | null;
|
|
359
|
+
if (layoutClasses.length > 0 && rootNode && rootNode.type === 'element' && rootNode.tagName === 'div') {
|
|
360
|
+
const rootClasses = splitClassName(rootNode.props && rootNode.props.className);
|
|
361
|
+
const sameClasses = rootClasses.length > 0 && rootClasses.join(' ') === layoutClasses.join(' ');
|
|
362
|
+
if (sameClasses) {
|
|
363
|
+
const rootStyle = tailwindClassesToStyle(rootClasses, 'default', colorGroup);
|
|
364
|
+
const rootTextToken = rootStyle.textToken || extractTextColorToken(rootClasses) || undefined;
|
|
365
|
+
const rootTextColor = resolveTextColorValue(rootStyle.text, rootTextToken, colorGroup, theme) || undefined;
|
|
366
|
+
let gridChildWidth: number | undefined;
|
|
367
|
+
const rootGridCols = extractGridColumns(rootClasses, contentWidth);
|
|
368
|
+
const gridColsForChildren = layoutGridCols || rootGridCols;
|
|
369
|
+
if (gridColsForChildren && contentWidth != null && contentWidth > 0) {
|
|
370
|
+
const gap = layout.itemSpacing || 0;
|
|
371
|
+
const available = contentWidth - gap * Math.max(0, gridColsForChildren - 1);
|
|
372
|
+
if (available > 0) {
|
|
373
|
+
gridChildWidth = Math.max(0, available / gridColsForChildren);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const rootContext: StoryRenderContext = {
|
|
377
|
+
textColor: rootTextColor,
|
|
378
|
+
textColorToken: rootTextToken,
|
|
379
|
+
parentLayout: layout.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
380
|
+
maxWidth: gridChildWidth != null ? gridChildWidth : contentWidth,
|
|
381
|
+
viewportWidth: storyViewportWidth,
|
|
382
|
+
};
|
|
383
|
+
for (const child of rootNode.children || []) {
|
|
384
|
+
const childNode = ctx.renderJsxTree(child, colorGroup, radiusGroup, theme, 0, rootContext);
|
|
385
|
+
if (!childNode) continue;
|
|
386
|
+
layout.appendChild(childNode);
|
|
387
|
+
ctx.applyAbsoluteIfAllowed(childNode, layout, allowAbsolute);
|
|
388
|
+
const fullWidthOptions = (skipLayoutFullWidth || contentWidth != null)
|
|
389
|
+
? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
|
|
390
|
+
: undefined;
|
|
391
|
+
applyFullWidthIfPossible(childNode, layout, fullWidthOptions);
|
|
392
|
+
applyRingIfPossible(childNode, layout);
|
|
393
|
+
applyAspectRatioIfPossible(childNode);
|
|
394
|
+
enforceGrowChildPrimaryFixed(childNode, layout);
|
|
395
|
+
const childRootGridCols = extractRootGridColsFromTree(child);
|
|
396
|
+
if ('layoutMode' in childNode && childRootGridCols != null) {
|
|
397
|
+
ctx.applyGridColumnsWithReflow(childNode as FrameNode, contentWidth);
|
|
398
|
+
}
|
|
399
|
+
rendered = true;
|
|
400
|
+
}
|
|
401
|
+
if (rendered) {
|
|
402
|
+
renderMode = 'tree-root-children';
|
|
403
|
+
added = 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Transparent context-provider roots (Radix/Base UI *.Root, *.Provider) have
|
|
409
|
+
// no className or component def — they exist only to supply React context.
|
|
410
|
+
// When such a root has 2+ children (e.g. Trigger + Content in `defaultOpen`
|
|
411
|
+
// stories), rendering the wrapper adds an empty frame around the scene.
|
|
412
|
+
// Iterate its children directly into the Story Layout instead.
|
|
413
|
+
if (!rendered && rootNode && rootNode.type === 'element' && rootNode.isComponent
|
|
414
|
+
&& (!rootNode.props || !rootNode.props.className)
|
|
415
|
+
&& !ctx.getComponentDefByName(rootNode.tagName)
|
|
416
|
+
&& Array.isArray(rootNode.children) && rootNode.children.length > 1) {
|
|
417
|
+
const rootContext: StoryRenderContext = {
|
|
418
|
+
parentLayout: layout.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
419
|
+
maxWidth: contentWidth,
|
|
420
|
+
viewportWidth: storyViewportWidth,
|
|
421
|
+
};
|
|
422
|
+
for (const child of rootNode.children) {
|
|
423
|
+
const childNode = ctx.renderJsxTree(child, colorGroup, radiusGroup, theme, 0, rootContext);
|
|
424
|
+
if (!childNode) continue;
|
|
425
|
+
layout.appendChild(childNode);
|
|
426
|
+
ctx.applyAbsoluteIfAllowed(childNode, layout, allowAbsolute);
|
|
427
|
+
const fullWidthOptions = (skipLayoutFullWidth || contentWidth != null)
|
|
428
|
+
? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
|
|
429
|
+
: undefined;
|
|
430
|
+
applyFullWidthIfPossible(childNode, layout, fullWidthOptions);
|
|
431
|
+
applyRingIfPossible(childNode, layout);
|
|
432
|
+
applyAspectRatioIfPossible(childNode);
|
|
433
|
+
enforceGrowChildPrimaryFixed(childNode, layout);
|
|
434
|
+
rendered = true;
|
|
435
|
+
}
|
|
436
|
+
if (rendered) {
|
|
437
|
+
renderMode = 'tree-root-children-unwrapped';
|
|
438
|
+
added = 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!rendered) {
|
|
443
|
+
const treeContext: StoryRenderContext = {
|
|
444
|
+
maxWidth: contentWidth,
|
|
445
|
+
parentLayout: layout.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
446
|
+
viewportWidth: storyViewportWidth,
|
|
447
|
+
};
|
|
448
|
+
const treeNode = ctx.renderJsxTree(effectiveJsxTree, colorGroup, radiusGroup, theme, 0, treeContext);
|
|
449
|
+
if (treeNode) {
|
|
450
|
+
layout.appendChild(treeNode);
|
|
451
|
+
ctx.applyAbsoluteIfAllowed(treeNode, layout, allowAbsolute);
|
|
452
|
+
const fullWidthOptions = (skipLayoutFullWidth || contentWidth != null)
|
|
453
|
+
? { skipFullWidth: skipLayoutFullWidth, widthOverride: contentWidth }
|
|
454
|
+
: undefined;
|
|
455
|
+
applyFullWidthIfPossible(treeNode, layout, fullWidthOptions);
|
|
456
|
+
applyRingIfPossible(treeNode, layout);
|
|
457
|
+
applyAspectRatioIfPossible(treeNode);
|
|
458
|
+
enforceGrowChildPrimaryFixed(treeNode, layout);
|
|
459
|
+
const treeRootGridCols = extractRootGridColsFromTree(effectiveJsxTree);
|
|
460
|
+
if ('layoutMode' in treeNode && treeRootGridCols != null) {
|
|
461
|
+
ctx.applyGridColumnsWithReflow(treeNode as FrameNode, contentWidth);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (effectiveWidth && rootNode && rootNode.type === 'element') {
|
|
465
|
+
const rootClasses = splitClassName(rootNode.props && rootNode.props.className);
|
|
466
|
+
const wantsFullWidth = rootClasses.includes('w-full');
|
|
467
|
+
if (wantsFullWidth && 'resize' in treeNode) {
|
|
468
|
+
try {
|
|
469
|
+
// Respect max-w-* constraint: clamp to min(layout.width, max-width).
|
|
470
|
+
const maxWConstraint = extractMaxWidth(rootClasses);
|
|
471
|
+
const targetWidth = maxWConstraint != null ? Math.min(layout.width, maxWConstraint) : layout.width;
|
|
472
|
+
treeNode.resize(targetWidth, treeNode.height);
|
|
473
|
+
const treeLayout = ('layoutMode' in treeNode) ? treeNode.layoutMode : 'VERTICAL';
|
|
474
|
+
if (treeLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in treeNode) {
|
|
475
|
+
treeNode.primaryAxisSizingMode = 'FIXED';
|
|
476
|
+
} else if (treeLayout === 'VERTICAL' && 'counterAxisSizingMode' in treeNode) {
|
|
477
|
+
treeNode.counterAxisSizingMode = 'FIXED';
|
|
478
|
+
}
|
|
479
|
+
// Reapply right-anchored absolute children (e.g. DialogPrimitive.Close) after resize.
|
|
480
|
+
if (treeNode.type === 'FRAME') {
|
|
481
|
+
reapplyRightPositionedChildren(treeNode, targetWidth);
|
|
482
|
+
}
|
|
483
|
+
} catch (_err) {
|
|
484
|
+
// ignore resize errors
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
added = 1;
|
|
489
|
+
renderMode = 'tree-root';
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
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.
|
|
498
|
+
if (layoutGridCols) {
|
|
499
|
+
ctx.applyGridColumnsWithReflow(layout, effectiveWidth || layout.width, layoutGridCols);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const layoutHasChildren = hasNodeChildren(layout);
|
|
503
|
+
const normalizedDefName = normalizeComponentName(def && def.name ? def.name : '');
|
|
504
|
+
const isGuardedPortalDef =
|
|
505
|
+
(normalizedDefName && INSTANCE_FALLBACK_COMPONENTS.has(normalizedDefName))
|
|
506
|
+
|| jsxTreeIsPortalTriggerOnly(story && story.jsxTree);
|
|
507
|
+
if (added === 0 && !layoutHasChildren && isGuardedPortalDef) {
|
|
508
|
+
added += renderGuardedPortalStoryInstances(layout, def, story, theme, colorGroup, radiusGroup, ctx);
|
|
509
|
+
if (added > 0) {
|
|
510
|
+
renderMode = 'guarded-portal';
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (added === 0 && !layoutHasChildren && !useStoryTree) {
|
|
515
|
+
if (def) {
|
|
516
|
+
for (const instance of story.instances || []) {
|
|
517
|
+
if (!instance || !instance.componentName) continue;
|
|
518
|
+
const normalizedInstanceName = instance.componentName.toLowerCase().replace(/-/g, '');
|
|
519
|
+
const normalizedCurrentDefName = def.name.toLowerCase().replace(/-/g, '');
|
|
520
|
+
if (normalizedInstanceName !== normalizedCurrentDefName) continue;
|
|
521
|
+
if (isGuardedPortalDef) continue;
|
|
522
|
+
if (appendResolvedInstance(layout, def, instance, story, theme, colorGroup, radiusGroup, ctx)) {
|
|
523
|
+
added++;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
added = renderStoryInstances(layout, story, theme, colorGroup, radiusGroup, ctx);
|
|
528
|
+
}
|
|
529
|
+
if (added > 0) {
|
|
530
|
+
renderMode = 'instances';
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const finalLayoutChildren = layout.children;
|
|
535
|
+
const hasFinalLayoutChildren = !!finalLayoutChildren
|
|
536
|
+
&& typeof finalLayoutChildren.length === 'number'
|
|
537
|
+
&& finalLayoutChildren.length > 0;
|
|
538
|
+
if (added === 0 && !hasFinalLayoutChildren && opts?.allowTypeDefaultFallback && def) {
|
|
539
|
+
if (appendTypeDefaultPreview(layout, def, story, theme, ctx)) {
|
|
540
|
+
renderMode = 'type-default';
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (added === 0 && !hasNodeChildren(layout) && !opts?.allowTypeDefaultFallback) {
|
|
545
|
+
layout.appendChild(createTextNode(story.name || 'Story', { fontSize: 12, opacity: 0.6 }));
|
|
546
|
+
renderMode = 'placeholder';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Distribute remaining vertical space among flex-1/grow children inside
|
|
550
|
+
// every viewport-anchored VERTICAL frame (Story Layout itself plus any
|
|
551
|
+
// nested fixed-height sub-frame). Runs before absolute-reflow so deferred
|
|
552
|
+
// positions settle against the final heights.
|
|
553
|
+
walkVerticalFlexGrow(layout);
|
|
554
|
+
|
|
555
|
+
// Final pass: resolve deferred absolute positioning after all story-level
|
|
556
|
+
// width/height/layout operations have completed.
|
|
557
|
+
reflowDeferredAbsolutePositioningTree(layout);
|
|
558
|
+
|
|
559
|
+
return { added, renderMode, useStoryTree };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function walkVerticalFlexGrow(node: SceneNode): void {
|
|
563
|
+
if (!node || typeof node !== 'object') return;
|
|
564
|
+
if (node.type === 'FRAME' && node.layoutMode === 'VERTICAL' && node.primaryAxisSizingMode === 'FIXED') {
|
|
565
|
+
applyVerticalFlexGrowIfPossible(node);
|
|
566
|
+
}
|
|
567
|
+
if (!('children' in node) || !Array.isArray(node.children)) return;
|
|
568
|
+
for (let i = 0; i < node.children.length; i++) walkVerticalFlexGrow(node.children[i]);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function renderStandaloneStory(
|
|
572
|
+
story: ComponentStory,
|
|
573
|
+
theme: string,
|
|
574
|
+
ctx: StoryBuilderContext,
|
|
575
|
+
viewportWidth?: number
|
|
576
|
+
): FrameNode {
|
|
577
|
+
const themeContext = getThemeContext(theme);
|
|
578
|
+
const colorGroup = themeContext.colorGroup;
|
|
579
|
+
const radiusGroup = themeContext.radiusGroup;
|
|
580
|
+
const layoutClasses = normalizeLayoutClasses(story.layoutClasses);
|
|
581
|
+
|
|
582
|
+
const layout = figma.createFrame();
|
|
583
|
+
layout.name = 'Story Layout';
|
|
584
|
+
layout.primaryAxisSizingMode = 'AUTO';
|
|
585
|
+
layout.counterAxisSizingMode = 'AUTO';
|
|
586
|
+
layout.fills = [];
|
|
587
|
+
ctx.applyClipBehavior(layout, layoutClasses);
|
|
588
|
+
|
|
589
|
+
ctx.applyLayoutClasses(layout, layoutClasses, colorGroup, radiusGroup, theme);
|
|
590
|
+
const effectiveWidth = resolveStoryLayoutWidth(story, layout, layoutClasses, ctx, viewportWidth);
|
|
591
|
+
resolveStoryLayoutHeight(story, layout, layoutClasses, ctx);
|
|
592
|
+
const result = populateStoryLayout(layout, null, story, theme, colorGroup, radiusGroup, ctx, effectiveWidth, {
|
|
593
|
+
viewportWidth,
|
|
594
|
+
allowTypeDefaultFallback: false,
|
|
595
|
+
});
|
|
596
|
+
expandStoryLayoutForPortalContent(layout);
|
|
597
|
+
setStoryRenderDiagnostics(layout, null, story, result.renderMode, result.added, result.useStoryTree);
|
|
598
|
+
|
|
599
|
+
return layout;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Portal panels (marked via `inkbridge:portal-panel` pluginData) visually escape
|
|
604
|
+
* their parent in the browser. In Figma we render them inside the Story Layout
|
|
605
|
+
* frame, so if the layout is narrower than the panel it clips. Walk the subtree,
|
|
606
|
+
* find portal panels, grow the layout to contain them.
|
|
607
|
+
*/
|
|
608
|
+
function expandStoryLayoutForPortalContent(layout: FrameNode): void {
|
|
609
|
+
if (!layout || typeof layout.width !== 'number') return;
|
|
610
|
+
const layoutBox = layout.absoluteBoundingBox;
|
|
611
|
+
if (!layoutBox) return;
|
|
612
|
+
let requiredWidth = layout.width;
|
|
613
|
+
const stack: SceneNode[] = Array.isArray(layout.children) ? layout.children.slice() : [];
|
|
614
|
+
while (stack.length > 0) {
|
|
615
|
+
const node = stack.pop();
|
|
616
|
+
if (!node) continue;
|
|
617
|
+
try {
|
|
618
|
+
const tag = typeof node.getPluginData === 'function'
|
|
619
|
+
? node.getPluginData('inkbridge:portal-panel')
|
|
620
|
+
: '';
|
|
621
|
+
if (tag === '1' && node.absoluteBoundingBox) {
|
|
622
|
+
const overflow = (node.absoluteBoundingBox.x + node.absoluteBoundingBox.width)
|
|
623
|
+
- (layoutBox.x + layoutBox.width);
|
|
624
|
+
if (overflow > 0) requiredWidth = Math.max(requiredWidth, layout.width + overflow);
|
|
625
|
+
}
|
|
626
|
+
} catch { /* ignore */ }
|
|
627
|
+
if ('children' in node && Array.isArray(node.children) && node.children.length > 0) {
|
|
628
|
+
for (const child of node.children) stack.push(child);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (requiredWidth > layout.width) {
|
|
632
|
+
try {
|
|
633
|
+
layout.resize(requiredWidth, layout.height);
|
|
634
|
+
if (layout.layoutMode === 'HORIZONTAL' && 'primaryAxisSizingMode' in layout) {
|
|
635
|
+
layout.primaryAxisSizingMode = 'FIXED';
|
|
636
|
+
} else if ('counterAxisSizingMode' in layout) {
|
|
637
|
+
layout.counterAxisSizingMode = 'FIXED';
|
|
638
|
+
}
|
|
639
|
+
} catch { /* ignore */ }
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Build (or incrementally update) a UI components section on the Design System page.
|
|
645
|
+
*
|
|
646
|
+
* ## Incremental strategy
|
|
647
|
+
* The section frame and per-theme column frames are reused across runs — only
|
|
648
|
+
* their contents are updated. Inside each column, component blocks are diffed
|
|
649
|
+
* individually:
|
|
650
|
+
*
|
|
651
|
+
* blockHash = defHash + ":" + tokenHash
|
|
652
|
+
*
|
|
653
|
+
* - Hash match → block is left completely untouched (node ID preserved).
|
|
654
|
+
* - Hash change → block is removed and rebuilt at the same auto-layout index.
|
|
655
|
+
* - New block → appended to the end of the component list.
|
|
656
|
+
* - Removed → blocks whose component no longer exists in defs are deleted.
|
|
657
|
+
*
|
|
658
|
+
* Pack stories are grouped in a named "Pack Stories" container and rebuilt
|
|
659
|
+
* whenever the pack story list changes (compared by hash).
|
|
660
|
+
*
|
|
661
|
+
* Section x/y are only set on first creation. Existing sections keep their
|
|
662
|
+
* current position so designers can reposition without it resetting.
|
|
663
|
+
*/
|
|
664
|
+
export interface CreateUIComponentsOptions {
|
|
665
|
+
primary?: boolean;
|
|
666
|
+
secondary?: boolean;
|
|
667
|
+
themeNames?: string[];
|
|
668
|
+
sectionTitle?: string;
|
|
669
|
+
tokenHash?: string;
|
|
670
|
+
showSectionHeader?: boolean;
|
|
671
|
+
sectionPaddingX?: number;
|
|
672
|
+
sectionPaddingY?: number;
|
|
673
|
+
yOffset?: number;
|
|
674
|
+
xOffset?: number;
|
|
675
|
+
excludeComponents?: string[];
|
|
676
|
+
preserveUnselectedComponents?: boolean;
|
|
677
|
+
storyTagFilter?: string[];
|
|
678
|
+
onlyComponents?: string[];
|
|
679
|
+
/**
|
|
680
|
+
* Optional progress hook. Called at phase boundaries so the loading
|
|
681
|
+
* view can show finer-grained status than the single
|
|
682
|
+
* "Building components…" message. Pair with `Promise.resolve()` and
|
|
683
|
+
* `setTimeout(0)` upstream so the iframe gets a paint frame between
|
|
684
|
+
* synchronous build chunks. Each call costs ~50ms of yield — limit to
|
|
685
|
+
* per-theme and per-atomic-section (atoms / molecules / organisms),
|
|
686
|
+
* not per-component, or the overhead dominates the savings.
|
|
687
|
+
*
|
|
688
|
+
* Accepts either a flat string (legacy) or `{ phase, detail }`. emit()
|
|
689
|
+
* below sends detail-only updates so the parent-set phase
|
|
690
|
+
* ("Building components…") stays visible as a sticky header.
|
|
691
|
+
*/
|
|
692
|
+
onStatus?: (status: StatusUpdate) => Promise<void> | void;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export async function createUIComponents(parent: FrameNode | PageNode, opts: CreateUIComponentsOptions, ctx: StoryBuilderContext): Promise<FrameNode> {
|
|
696
|
+
const options = {
|
|
697
|
+
primary: true,
|
|
698
|
+
secondary: true,
|
|
699
|
+
themeNames: getThemeNames(TOKENS),
|
|
700
|
+
...(opts || {}),
|
|
701
|
+
};
|
|
702
|
+
debug('createUIComponents start', { opts: options });
|
|
703
|
+
|
|
704
|
+
// Progress hook + a soft yield between phases. `onStatus` posts a
|
|
705
|
+
// status update to the loading view. `noYield: true` posts the message
|
|
706
|
+
// (the iframe runs on its own thread and updates the panel) but skips
|
|
707
|
+
// the 50ms wait — without that, every fine-grained emit would let
|
|
708
|
+
// Figma's canvas paint a half-built scene-graph state, producing the
|
|
709
|
+
// theme-column overlap the user sees mid-build. Coarse phases in
|
|
710
|
+
// main.ts (Reading components / Building tokens / Building components)
|
|
711
|
+
// still yield 50ms so the canvas paints between top-level phases.
|
|
712
|
+
const onStatus = options.onStatus;
|
|
713
|
+
const phaseStart = Date.now();
|
|
714
|
+
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
|
+
if (onStatus) {
|
|
720
|
+
await Promise.resolve(onStatus({ detail: message, noYield: true }));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const sectionTitle: string = options.sectionTitle || 'UI Components';
|
|
725
|
+
|
|
726
|
+
// tokenHash is computed once per run in design-system.ts and forwarded here.
|
|
727
|
+
// It covers all TOKENS so any token change invalidates all component blocks.
|
|
728
|
+
const tokenHash: string = typeof options.tokenHash === 'string' ? options.tokenHash : '';
|
|
729
|
+
const showSectionHeader = options.showSectionHeader !== false;
|
|
730
|
+
const sectionPaddingX = typeof options.sectionPaddingX === 'number'
|
|
731
|
+
? options.sectionPaddingX
|
|
732
|
+
: (showSectionHeader ? BOARD_LAYOUT.sectionPaddingX : 0);
|
|
733
|
+
const sectionPaddingY = typeof options.sectionPaddingY === 'number'
|
|
734
|
+
? options.sectionPaddingY
|
|
735
|
+
: (showSectionHeader ? BOARD_LAYOUT.sectionPaddingY : 0);
|
|
736
|
+
|
|
737
|
+
// --- Find or create the section frame ---
|
|
738
|
+
// On first run it is created and positioned. On subsequent runs the existing
|
|
739
|
+
// frame is reused so its position and any designer annotations are preserved.
|
|
740
|
+
removeDuplicateChildrenByName(parent, sectionTitle, 'FRAME');
|
|
741
|
+
let section: FrameNode = findChildByName(parent, sectionTitle);
|
|
742
|
+
if (!section) {
|
|
743
|
+
section = figma.createFrame();
|
|
744
|
+
section.name = sectionTitle;
|
|
745
|
+
const offsetY = typeof options.yOffset === 'number' ? options.yOffset : 0;
|
|
746
|
+
const offsetX = typeof options.xOffset === 'number' ? options.xOffset : 0;
|
|
747
|
+
section.x = offsetX;
|
|
748
|
+
section.y = offsetY;
|
|
749
|
+
parent.appendChild(section);
|
|
750
|
+
}
|
|
751
|
+
tagGeneratedNode(section, 'section:' + sectionTitle);
|
|
752
|
+
section.layoutMode = 'VERTICAL';
|
|
753
|
+
section.itemSpacing = showSectionHeader ? BOARD_LAYOUT.boardGap : 0;
|
|
754
|
+
section.primaryAxisSizingMode = 'AUTO';
|
|
755
|
+
section.counterAxisSizingMode = 'AUTO';
|
|
756
|
+
section.paddingLeft = section.paddingRight = sectionPaddingX;
|
|
757
|
+
section.paddingTop = section.paddingBottom = sectionPaddingY;
|
|
758
|
+
section.counterAxisAlignItems = 'MIN';
|
|
759
|
+
section.fills = [];
|
|
760
|
+
ctx.applyClipBehavior(section, []);
|
|
761
|
+
removeDirectTextChildren(section);
|
|
762
|
+
|
|
763
|
+
// Early reflow guard against the Design Tokens row: re-anchor section.y
|
|
764
|
+
// before any visible content is built so a previously-created section
|
|
765
|
+
// (with stale y from before the tokens row grew) doesn't paint
|
|
766
|
+
// overlapping tokens. design-system.ts runs the same guard AFTER
|
|
767
|
+
// `createUIComponents` returns; this one front-loads it. Mirrors the
|
|
768
|
+
// post-build math (only pushes y down, never up) so designer-set
|
|
769
|
+
// positions further down are preserved.
|
|
770
|
+
const tokensSibling = findChildByName(parent, 'Design Tokens') as FrameNode | null;
|
|
771
|
+
if (tokensSibling) {
|
|
772
|
+
const tokensY = typeof tokensSibling.y === 'number' ? tokensSibling.y : 0;
|
|
773
|
+
const tokensHeight = typeof tokensSibling.height === 'number' ? tokensSibling.height : 0;
|
|
774
|
+
const minY = tokensY + tokensHeight + 80;
|
|
775
|
+
if (section.y < minY) section.y = minY;
|
|
776
|
+
const tokensX = typeof tokensSibling.x === 'number' ? tokensSibling.x : 0;
|
|
777
|
+
if (section.x < tokensX) section.x = tokensX;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
const existingSectionHeader = findChildByName(section, 'Section Header');
|
|
782
|
+
if (showSectionHeader) {
|
|
783
|
+
const sectionHeader = ensureHeaderBlock(section, 'Section Header', sectionTitle, null, {
|
|
784
|
+
titleSize: 32,
|
|
785
|
+
titleLineHeight: 38,
|
|
786
|
+
});
|
|
787
|
+
if (findChildIndexByName(section, 'Section Header') !== 0) {
|
|
788
|
+
section.insertChild(0, sectionHeader);
|
|
789
|
+
}
|
|
790
|
+
} else if (existingSectionHeader) {
|
|
791
|
+
existingSectionHeader.remove();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function formatThemeLabel(theme: string): string {
|
|
795
|
+
if (!theme) return 'Theme';
|
|
796
|
+
return theme.charAt(0).toUpperCase() + theme.slice(1);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Build a single component block frame (all stories for one component def).
|
|
800
|
+
// Returns the newly created block frame.
|
|
801
|
+
function buildComponentBlock(def: ComponentDef, theme: string, _colFrame: FrameNode): FrameNode {
|
|
802
|
+
const themeContext = getThemeContext(theme);
|
|
803
|
+
const colorGroup = themeContext.colorGroup;
|
|
804
|
+
const radiusGroup = themeContext.radiusGroup;
|
|
805
|
+
|
|
806
|
+
const block = figma.createFrame();
|
|
807
|
+
block.name = def.name;
|
|
808
|
+
block.layoutMode = 'VERTICAL';
|
|
809
|
+
block.itemSpacing = BOARD_LAYOUT.componentTitleGap;
|
|
810
|
+
block.primaryAxisSizingMode = 'AUTO';
|
|
811
|
+
block.counterAxisSizingMode = 'AUTO';
|
|
812
|
+
block.counterAxisAlignItems = 'MIN';
|
|
813
|
+
block.fills = [];
|
|
814
|
+
ctx.applyClipBehavior(block, []);
|
|
815
|
+
|
|
816
|
+
block.appendChild(createTextNode(def.name, { fontSize: 22, lineHeight: 28, bold: true }));
|
|
817
|
+
|
|
818
|
+
const storyList = def.stories || [];
|
|
819
|
+
for (let storyIndex = 0; storyIndex < storyList.length; storyIndex++) {
|
|
820
|
+
const story = storyList[storyIndex];
|
|
821
|
+
const storyWrap = figma.createFrame();
|
|
822
|
+
storyWrap.name = story.name;
|
|
823
|
+
storyWrap.layoutMode = 'VERTICAL';
|
|
824
|
+
storyWrap.itemSpacing = BOARD_LAYOUT.storyGap;
|
|
825
|
+
storyWrap.primaryAxisSizingMode = 'AUTO';
|
|
826
|
+
storyWrap.counterAxisSizingMode = 'AUTO';
|
|
827
|
+
storyWrap.counterAxisAlignItems = 'MIN';
|
|
828
|
+
storyWrap.fills = [];
|
|
829
|
+
ctx.applyClipBehavior(storyWrap, []);
|
|
830
|
+
|
|
831
|
+
storyWrap.appendChild(createTextNode(story.name, { fontSize: 16, lineHeight: 22, opacity: 0.6, textAlignHorizontal: 'LEFT' }));
|
|
832
|
+
|
|
833
|
+
const layout = figma.createFrame();
|
|
834
|
+
layout.name = 'Story Layout';
|
|
835
|
+
layout.primaryAxisSizingMode = 'AUTO';
|
|
836
|
+
layout.counterAxisSizingMode = 'AUTO';
|
|
837
|
+
layout.fills = [];
|
|
838
|
+
|
|
839
|
+
const layoutClasses = normalizeLayoutClasses(story.layoutClasses);
|
|
840
|
+
ctx.applyClipBehavior(layout, layoutClasses);
|
|
841
|
+
ctx.applyLayoutClasses(layout, layoutClasses, colorGroup, radiusGroup, theme);
|
|
842
|
+
|
|
843
|
+
const effectiveWidth = resolveStoryLayoutWidth(story, layout, layoutClasses, ctx);
|
|
844
|
+
resolveStoryLayoutHeight(story, layout, layoutClasses, ctx);
|
|
845
|
+
const populated = populateStoryLayout(layout, def, story, theme, colorGroup, radiusGroup, ctx, effectiveWidth, {
|
|
846
|
+
allowTypeDefaultFallback: true,
|
|
847
|
+
});
|
|
848
|
+
expandStoryLayoutForPortalContent(layout);
|
|
849
|
+
const added = populated.added;
|
|
850
|
+
const renderMode = populated.renderMode;
|
|
851
|
+
const useStoryTree = populated.useStoryTree;
|
|
852
|
+
setStoryRenderDiagnostics(layout, def, story, renderMode, added, useStoryTree);
|
|
853
|
+
|
|
854
|
+
storyWrap.appendChild(layout);
|
|
855
|
+
if (shouldRenderStatesForStory(def, story, storyIndex)) {
|
|
856
|
+
const statesBlock = createStatePreviewBlock(def, story, theme, ctx);
|
|
857
|
+
if (statesBlock) storyWrap.appendChild(statesBlock);
|
|
858
|
+
}
|
|
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);
|
|
866
|
+
block.appendChild(storyWrap);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return block;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Build (or incrementally update) a theme column frame inside the Columns container.
|
|
873
|
+
async function addColumn(label: string, theme: string): Promise<FrameNode> {
|
|
874
|
+
await emit('Building ' + label.toLowerCase() + ' theme…');
|
|
875
|
+
// --- Find or create the column frame ---
|
|
876
|
+
// Column frames live inside the "Columns" container (built below) and are
|
|
877
|
+
// identified by name. Existing columns are reused so their position in the
|
|
878
|
+
// auto-layout and any sub-frames with preserved node IDs stay intact.
|
|
879
|
+
const columnsContainer: FrameNode | null = findChildByName(section, 'Columns') as FrameNode | null;
|
|
880
|
+
if (columnsContainer) {
|
|
881
|
+
removeDuplicateChildrenByName(columnsContainer, label + ' Column', 'FRAME');
|
|
882
|
+
}
|
|
883
|
+
let colFrame: FrameNode | null = columnsContainer ? findChildByName(columnsContainer, label + ' Column') as FrameNode | null : null;
|
|
884
|
+
if (!colFrame) {
|
|
885
|
+
colFrame = figma.createFrame();
|
|
886
|
+
colFrame.name = label + ' Column';
|
|
887
|
+
// Park the new column far off-screen. `figma.createFrame()` in
|
|
888
|
+
// API 1.0 auto-appends to `figma.currentPage`, so the freshly-
|
|
889
|
+
// created node is *not* orphan — it sits as a direct child of
|
|
890
|
+
// the Design System page at default `(0, 0)` until the outer
|
|
891
|
+
// loop's `columns.insertChild(ti, themeCol)` re-parents it
|
|
892
|
+
// AFTER addColumn returns. Between then and now the per-section
|
|
893
|
+
// yields let Figma paint that intermediate state. (0, 0) on the
|
|
894
|
+
// design system page is right inside the Design Tokens row, so
|
|
895
|
+
// the user sees the column overlap tokens for the entire build.
|
|
896
|
+
// Auto-layout overrides x/y when the column lands in the
|
|
897
|
+
// `Columns` HORIZONTAL container, so this park has no effect on
|
|
898
|
+
// the final position. See also `componentList` / `packStoriesFrame`
|
|
899
|
+
// / `sectionFrame` below — they get appended into the off-screen
|
|
900
|
+
// colFrame immediately for the same reason.
|
|
901
|
+
colFrame.x = -100000;
|
|
902
|
+
colFrame.y = -100000;
|
|
903
|
+
}
|
|
904
|
+
tagGeneratedNode(colFrame, 'theme-column:' + sectionTitle + ':' + theme);
|
|
905
|
+
colFrame.layoutMode = 'VERTICAL';
|
|
906
|
+
colFrame.itemSpacing = BOARD_LAYOUT.columnGap;
|
|
907
|
+
colFrame.primaryAxisSizingMode = 'AUTO';
|
|
908
|
+
colFrame.counterAxisSizingMode = 'AUTO';
|
|
909
|
+
colFrame.counterAxisAlignItems = 'MIN';
|
|
910
|
+
colFrame.paddingLeft = colFrame.paddingRight = BOARD_LAYOUT.columnPaddingX;
|
|
911
|
+
colFrame.paddingTop = colFrame.paddingBottom = BOARD_LAYOUT.columnPaddingY;
|
|
912
|
+
colFrame.fills = [];
|
|
913
|
+
// Keep columns transparent; story surfaces are responsible for their own backgrounds.
|
|
914
|
+
ctx.applyClipBehavior(colFrame, []);
|
|
915
|
+
removeDirectTextChildren(colFrame);
|
|
916
|
+
const themeHeader = ensureHeaderBlock(
|
|
917
|
+
colFrame,
|
|
918
|
+
'Theme Header',
|
|
919
|
+
label + ' Theme',
|
|
920
|
+
'Generated component board with grouped stories and larger comparison spacing.',
|
|
921
|
+
{ titleSize: 32, titleLineHeight: 38, descriptionSize: 14 }
|
|
922
|
+
);
|
|
923
|
+
if (findChildIndexByName(colFrame, 'Theme Header') !== 0) {
|
|
924
|
+
colFrame.insertChild(0, themeHeader);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// --- Pack stories (incremental by hash) ---
|
|
928
|
+
// Wrapped in a named container so we can cleanly replace them without
|
|
929
|
+
// disturbing the component blocks below.
|
|
930
|
+
const pack = getActivePack();
|
|
931
|
+
const packStories = pack && pack.stories ? pack.stories : [];
|
|
932
|
+
const storyTagFilter = options && options.storyTagFilter ? String(options.storyTagFilter) : '';
|
|
933
|
+
const filteredStories = packStories.filter(function(s: ComponentStory) {
|
|
934
|
+
if (!storyTagFilter) return true;
|
|
935
|
+
const tags = s.tags || [];
|
|
936
|
+
return tags.indexOf(storyTagFilter) !== -1;
|
|
937
|
+
});
|
|
938
|
+
const packStoriesHash = hashString(JSON.stringify(filteredStories) + theme);
|
|
939
|
+
removeDuplicateChildrenByName(colFrame, 'Showcase Stories', 'FRAME');
|
|
940
|
+
let packStoriesFrame: FrameNode | null = findChildByName(colFrame, 'Showcase Stories');
|
|
941
|
+
if (!packStoriesFrame || getFrameHash(packStoriesFrame) !== packStoriesHash) {
|
|
942
|
+
if (packStoriesFrame) packStoriesFrame.remove();
|
|
943
|
+
const stalePackFrame = findChildByName(colFrame, 'Pack Stories');
|
|
944
|
+
if (stalePackFrame) stalePackFrame.remove();
|
|
945
|
+
if (filteredStories.length > 0) {
|
|
946
|
+
packStoriesFrame = figma.createFrame();
|
|
947
|
+
packStoriesFrame.name = 'Showcase Stories';
|
|
948
|
+
// Append into colFrame immediately so the API-1.0 auto-append to
|
|
949
|
+
// currentPage doesn't leave it at ds-page (0,0) during the
|
|
950
|
+
// synchronous build of its children (same class of bug as
|
|
951
|
+
// `componentList` above).
|
|
952
|
+
colFrame.appendChild(packStoriesFrame);
|
|
953
|
+
packStoriesFrame.layoutMode = 'VERTICAL';
|
|
954
|
+
packStoriesFrame.itemSpacing = BOARD_LAYOUT.showcaseGap;
|
|
955
|
+
packStoriesFrame.primaryAxisSizingMode = 'AUTO';
|
|
956
|
+
packStoriesFrame.counterAxisSizingMode = 'AUTO';
|
|
957
|
+
packStoriesFrame.counterAxisAlignItems = 'MIN';
|
|
958
|
+
packStoriesFrame.fills = [];
|
|
959
|
+
ctx.applyClipBehavior(packStoriesFrame, []);
|
|
960
|
+
packStoriesFrame.appendChild(ensureHeaderBlock(
|
|
961
|
+
packStoriesFrame,
|
|
962
|
+
'Section Header',
|
|
963
|
+
'Showcase Stories',
|
|
964
|
+
'Larger composite stories rendered before the component catalog.',
|
|
965
|
+
{ titleSize: 20, titleLineHeight: 26, descriptionSize: 14 }
|
|
966
|
+
));
|
|
967
|
+
for (let si = 0; si < filteredStories.length; si++) {
|
|
968
|
+
const story = filteredStories[si];
|
|
969
|
+
const storyBlock = figma.createFrame();
|
|
970
|
+
storyBlock.name = story.name || 'Story';
|
|
971
|
+
storyBlock.layoutMode = 'VERTICAL';
|
|
972
|
+
storyBlock.itemSpacing = BOARD_LAYOUT.componentTitleGap;
|
|
973
|
+
storyBlock.primaryAxisSizingMode = 'AUTO';
|
|
974
|
+
storyBlock.counterAxisSizingMode = 'AUTO';
|
|
975
|
+
storyBlock.counterAxisAlignItems = 'MIN';
|
|
976
|
+
storyBlock.fills = [];
|
|
977
|
+
ctx.applyClipBehavior(storyBlock, []);
|
|
978
|
+
storyBlock.appendChild(createTextNode(story.name || 'Story', { fontSize: 16, lineHeight: 22, bold: true }));
|
|
979
|
+
storyBlock.appendChild(renderStandaloneStory(story, theme, ctx));
|
|
980
|
+
packStoriesFrame.appendChild(storyBlock);
|
|
981
|
+
}
|
|
982
|
+
setFrameHash(packStoriesFrame, packStoriesHash);
|
|
983
|
+
tagGeneratedNode(packStoriesFrame, 'showcase-stories:' + sectionTitle + ':' + theme);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
packStoriesFrame = findChildByName(colFrame, 'Showcase Stories');
|
|
987
|
+
if (packStoriesFrame && findChildIndexByName(colFrame, 'Showcase Stories') !== 1) {
|
|
988
|
+
colFrame.insertChild(1, packStoriesFrame);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// --- Component blocks (per-component hash diff) ---
|
|
992
|
+
// "Component Blocks" is a named container holding one block per component def.
|
|
993
|
+
// We find or create it, then diff its contents against the current defs list.
|
|
994
|
+
removeDuplicateChildrenByName(colFrame, 'Component Blocks', 'FRAME');
|
|
995
|
+
let componentList: FrameNode = findChildByName(colFrame, 'Component Blocks');
|
|
996
|
+
if (!componentList) {
|
|
997
|
+
componentList = figma.createFrame();
|
|
998
|
+
componentList.name = 'Component Blocks';
|
|
999
|
+
componentList.layoutMode = 'VERTICAL';
|
|
1000
|
+
// Append into colFrame IMMEDIATELY so the API-1.0 auto-append to
|
|
1001
|
+
// figma.currentPage doesn't leave it as a ds-page direct child at
|
|
1002
|
+
// (0, 0) — that's the frame we observed sitting ON TOP OF the
|
|
1003
|
+
// Design Tokens row during the entire build (47k px tall, exactly
|
|
1004
|
+
// where the user reported overlap). Attaching it to colFrame
|
|
1005
|
+
// (parked at -100000, -100000) means the build's incremental
|
|
1006
|
+
// content is built in the off-screen tree.
|
|
1007
|
+
colFrame.appendChild(componentList);
|
|
1008
|
+
}
|
|
1009
|
+
tagGeneratedNode(componentList, 'component-blocks:' + sectionTitle + ':' + theme);
|
|
1010
|
+
componentList.primaryAxisSizingMode = 'AUTO';
|
|
1011
|
+
componentList.counterAxisSizingMode = 'AUTO';
|
|
1012
|
+
componentList.counterAxisAlignItems = 'MIN';
|
|
1013
|
+
componentList.itemSpacing = BOARD_LAYOUT.sectionGroupGap;
|
|
1014
|
+
componentList.fills = [];
|
|
1015
|
+
ctx.applyClipBehavior(componentList, []);
|
|
1016
|
+
|
|
1017
|
+
const defsRaw = (COMPONENT_DEFS && COMPONENT_DEFS.components) ? COMPONENT_DEFS.components : [];
|
|
1018
|
+
const onlyComponents: string[] | undefined = options.onlyComponents;
|
|
1019
|
+
const excludeComponents: string[] | undefined = options.excludeComponents;
|
|
1020
|
+
const preserveUnselectedComponents = options.preserveUnselectedComponents === true;
|
|
1021
|
+
const defs = defsRaw
|
|
1022
|
+
.map(ctx.normalizeComponentDef)
|
|
1023
|
+
.filter(function(d: ComponentDef) {
|
|
1024
|
+
if (!d || !d.stories || d.stories.length === 0) return false;
|
|
1025
|
+
if (onlyComponents && onlyComponents.length > 0) return onlyComponents.includes(d.name);
|
|
1026
|
+
if (excludeComponents && excludeComponents.length > 0) return !excludeComponents.includes(d.name);
|
|
1027
|
+
return true;
|
|
1028
|
+
})
|
|
1029
|
+
.sort(function(a: ComponentDef, b: ComponentDef) { return a.name.localeCompare(b.name); });
|
|
1030
|
+
|
|
1031
|
+
warmSymbolMasters(defs, theme, ctx);
|
|
1032
|
+
|
|
1033
|
+
const groupedDefs = groupComponentDefs(defs);
|
|
1034
|
+
const expectedSectionNames: Record<string, boolean> = {};
|
|
1035
|
+
for (let gi = 0; gi < groupedDefs.length; gi++) {
|
|
1036
|
+
expectedSectionNames[getComponentSectionName(groupedDefs[gi].section)] = true;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const existingGroups: SceneNode[] = componentList.children ? Array.from(componentList.children) : [];
|
|
1040
|
+
const seenSectionFrames: Record<string, boolean> = {};
|
|
1041
|
+
for (let gi = 0; gi < existingGroups.length; gi++) {
|
|
1042
|
+
const existingGroup = existingGroups[gi];
|
|
1043
|
+
const duplicate = !!seenSectionFrames[existingGroup.name];
|
|
1044
|
+
const stale = !expectedSectionNames[existingGroup.name];
|
|
1045
|
+
if (duplicate || (stale && !preserveUnselectedComponents)) {
|
|
1046
|
+
existingGroup.remove();
|
|
1047
|
+
} else {
|
|
1048
|
+
seenSectionFrames[existingGroup.name] = true;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
for (let gi = 0; gi < groupedDefs.length; gi++) {
|
|
1053
|
+
const group = groupedDefs[gi];
|
|
1054
|
+
const sectionName = getComponentSectionName(group.section);
|
|
1055
|
+
// Per-atomic-section status (atoms / molecules / organisms / …).
|
|
1056
|
+
// Includes the theme label so a multi-theme run shows clearly which
|
|
1057
|
+
// pass is in flight. Yields the JS thread so the iframe repaints.
|
|
1058
|
+
await emit(label + ' theme · ' + sectionName + ' (' + group.defs.length + ' components)');
|
|
1059
|
+
removeDuplicateChildrenByName(componentList, sectionName, 'FRAME');
|
|
1060
|
+
let sectionFrame: FrameNode = findChildByName(componentList, sectionName);
|
|
1061
|
+
if (!sectionFrame) {
|
|
1062
|
+
sectionFrame = figma.createFrame();
|
|
1063
|
+
sectionFrame.name = sectionName;
|
|
1064
|
+
// Append into componentList immediately (componentList is now
|
|
1065
|
+
// inside the off-screen colFrame, so sectionFrame inherits the
|
|
1066
|
+
// off-screen position). Same class of bug as `componentList`
|
|
1067
|
+
// and `packStoriesFrame` above — without this, sectionFrame
|
|
1068
|
+
// sits at ds-page (0,0) during the synchronous build of its
|
|
1069
|
+
// component blocks. The later `componentList.appendChild` /
|
|
1070
|
+
// `componentList.insertChild` calls then no-op or re-order.
|
|
1071
|
+
componentList.appendChild(sectionFrame);
|
|
1072
|
+
}
|
|
1073
|
+
tagGeneratedNode(sectionFrame, 'atomic-section:' + sectionTitle + ':' + theme + ':' + group.section.key);
|
|
1074
|
+
|
|
1075
|
+
sectionFrame.layoutMode = 'VERTICAL';
|
|
1076
|
+
sectionFrame.primaryAxisSizingMode = 'AUTO';
|
|
1077
|
+
sectionFrame.counterAxisSizingMode = 'AUTO';
|
|
1078
|
+
sectionFrame.counterAxisAlignItems = 'MIN';
|
|
1079
|
+
sectionFrame.itemSpacing = BOARD_LAYOUT.sectionTitleGap;
|
|
1080
|
+
sectionFrame.fills = [];
|
|
1081
|
+
ctx.applyClipBehavior(sectionFrame, []);
|
|
1082
|
+
|
|
1083
|
+
const groupHeader = ensureHeaderBlock(
|
|
1084
|
+
sectionFrame,
|
|
1085
|
+
'Section Header',
|
|
1086
|
+
group.section.title,
|
|
1087
|
+
group.section.description,
|
|
1088
|
+
{ titleSize: 26, titleLineHeight: 32, descriptionSize: 14 }
|
|
1089
|
+
);
|
|
1090
|
+
if (findChildIndexByName(sectionFrame, 'Section Header') !== 0) {
|
|
1091
|
+
sectionFrame.insertChild(0, groupHeader);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
removeDuplicateChildrenByName(sectionFrame, 'Blocks', 'FRAME');
|
|
1095
|
+
let groupBlocks: FrameNode = findChildByName(sectionFrame, 'Blocks');
|
|
1096
|
+
if (!groupBlocks) {
|
|
1097
|
+
groupBlocks = figma.createFrame();
|
|
1098
|
+
groupBlocks.name = 'Blocks';
|
|
1099
|
+
sectionFrame.appendChild(groupBlocks);
|
|
1100
|
+
}
|
|
1101
|
+
tagGeneratedNode(groupBlocks, 'atomic-section-blocks:' + sectionTitle + ':' + theme + ':' + group.section.key);
|
|
1102
|
+
groupBlocks.layoutMode = 'VERTICAL';
|
|
1103
|
+
groupBlocks.primaryAxisSizingMode = 'AUTO';
|
|
1104
|
+
groupBlocks.counterAxisSizingMode = 'AUTO';
|
|
1105
|
+
groupBlocks.counterAxisAlignItems = 'MIN';
|
|
1106
|
+
groupBlocks.itemSpacing = BOARD_LAYOUT.componentBlockGap;
|
|
1107
|
+
groupBlocks.fills = [];
|
|
1108
|
+
ctx.applyClipBehavior(groupBlocks, []);
|
|
1109
|
+
|
|
1110
|
+
const expectedNames: Record<string, boolean> = {};
|
|
1111
|
+
for (let di = 0; di < group.defs.length; di++) {
|
|
1112
|
+
expectedNames[group.defs[di].name] = true;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const existingBlocks: SceneNode[] = groupBlocks.children ? Array.from(groupBlocks.children) : [];
|
|
1116
|
+
const seenComponentBlocks: Record<string, boolean> = {};
|
|
1117
|
+
for (let ei = 0; ei < existingBlocks.length; ei++) {
|
|
1118
|
+
const existingBlock = existingBlocks[ei];
|
|
1119
|
+
const duplicate = !!seenComponentBlocks[existingBlock.name];
|
|
1120
|
+
const stale = !expectedNames[existingBlock.name];
|
|
1121
|
+
if (duplicate || (stale && !preserveUnselectedComponents)) {
|
|
1122
|
+
existingBlock.remove();
|
|
1123
|
+
debug('Removed stale component block', { name: existingBlock.name });
|
|
1124
|
+
} else {
|
|
1125
|
+
seenComponentBlocks[existingBlock.name] = true;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
for (let di = 0; di < group.defs.length; di++) {
|
|
1130
|
+
const def = group.defs[di];
|
|
1131
|
+
const blockHash = hashDef(def) + ':' + tokenHash + ':' + RENDER_ENGINE_VERSION;
|
|
1132
|
+
const existingBlock: FrameNode = findChildByName(groupBlocks, def.name);
|
|
1133
|
+
const existingBlockIndex = existingBlock ? findChildIndexByName(groupBlocks, def.name) : -1;
|
|
1134
|
+
|
|
1135
|
+
if (existingBlock && getFrameHash(existingBlock) === blockHash) {
|
|
1136
|
+
const currentIndex = existingBlockIndex;
|
|
1137
|
+
if (!preserveUnselectedComponents && currentIndex !== di) {
|
|
1138
|
+
groupBlocks.insertChild(di, existingBlock);
|
|
1139
|
+
}
|
|
1140
|
+
debug('Component block unchanged — skipped', { name: def.name });
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
let insertIndex = di;
|
|
1145
|
+
if (preserveUnselectedComponents) {
|
|
1146
|
+
insertIndex = existingBlockIndex >= 0 ? existingBlockIndex : groupBlocks.children.length;
|
|
1147
|
+
}
|
|
1148
|
+
if (existingBlock) {
|
|
1149
|
+
existingBlock.remove();
|
|
1150
|
+
debug('Component block changed — rebuilding', { name: def.name });
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Per-component timing log to the plugin console (no toast,
|
|
1154
|
+
// no yield — would balloon overhead). Lets us see in dev tools
|
|
1155
|
+
// which components dominate build time without slowing the run.
|
|
1156
|
+
const componentStart = Date.now();
|
|
1157
|
+
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
|
+
setFrameHash(newBlock, blockHash);
|
|
1163
|
+
tagGeneratedNode(newBlock, 'component-block:' + sectionTitle + ':' + theme + ':' + def.name);
|
|
1164
|
+
|
|
1165
|
+
if (insertIndex >= 0 && insertIndex < groupBlocks.children.length) {
|
|
1166
|
+
groupBlocks.insertChild(insertIndex, newBlock);
|
|
1167
|
+
} else {
|
|
1168
|
+
groupBlocks.appendChild(newBlock);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const currentSectionIndex = findChildIndexByName(componentList, sectionName);
|
|
1173
|
+
if (currentSectionIndex === -1) {
|
|
1174
|
+
// Newly created section: always attach, even when preserving unselected.
|
|
1175
|
+
// Without this, a subset run on a fresh file produces a section frame
|
|
1176
|
+
// that never enters the component list and no blocks are rendered.
|
|
1177
|
+
componentList.appendChild(sectionFrame);
|
|
1178
|
+
} else if (!preserveUnselectedComponents && currentSectionIndex !== gi) {
|
|
1179
|
+
componentList.insertChild(gi, sectionFrame);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (componentList.children.length > 0 && !findChildByName(colFrame, 'Component Blocks')) {
|
|
1184
|
+
colFrame.appendChild(componentList);
|
|
1185
|
+
}
|
|
1186
|
+
if (findChildByName(colFrame, 'Component Blocks')) {
|
|
1187
|
+
const componentListIndex = packStoriesFrame ? 2 : 1;
|
|
1188
|
+
if (findChildIndexByName(colFrame, 'Component Blocks') !== componentListIndex) {
|
|
1189
|
+
colFrame.insertChild(componentListIndex, componentList);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
return colFrame;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// --- Find or create the Columns container ---
|
|
1197
|
+
// Named "Columns" so it survives across runs. Column frames inside it are
|
|
1198
|
+
// found/created by addColumn using the theme label as a stable key.
|
|
1199
|
+
let columns: FrameNode | null = findChildByName(section, 'Columns');
|
|
1200
|
+
if (!columns) {
|
|
1201
|
+
columns = figma.createFrame();
|
|
1202
|
+
columns.name = 'Columns';
|
|
1203
|
+
section.appendChild(columns);
|
|
1204
|
+
}
|
|
1205
|
+
tagGeneratedNode(columns, 'columns:' + sectionTitle);
|
|
1206
|
+
columns.layoutMode = 'HORIZONTAL';
|
|
1207
|
+
columns.itemSpacing = BOARD_LAYOUT.columnsGap;
|
|
1208
|
+
columns.primaryAxisSizingMode = 'AUTO';
|
|
1209
|
+
columns.counterAxisSizingMode = 'AUTO';
|
|
1210
|
+
columns.counterAxisAlignItems = 'MIN';
|
|
1211
|
+
columns.fills = [];
|
|
1212
|
+
ctx.applyClipBehavior(columns, []);
|
|
1213
|
+
|
|
1214
|
+
const requestedThemes: string[] = Array.isArray(options.themeNames)
|
|
1215
|
+
? options.themeNames.filter(function(theme: string, index: number, list: string[]) {
|
|
1216
|
+
return typeof theme === 'string' &&
|
|
1217
|
+
theme.trim().length > 0 &&
|
|
1218
|
+
list.indexOf(theme) === index;
|
|
1219
|
+
})
|
|
1220
|
+
: [];
|
|
1221
|
+
if (requestedThemes.length === 0) {
|
|
1222
|
+
if (options.primary) requestedThemes.push('primary');
|
|
1223
|
+
if (options.secondary) requestedThemes.push('secondary');
|
|
1224
|
+
}
|
|
1225
|
+
const expectedColumnNames: Record<string, boolean> = {};
|
|
1226
|
+
const multiMode = isMultiModeEnabled();
|
|
1227
|
+
if (multiMode) {
|
|
1228
|
+
const activeTheme = requestedThemes[0] || 'primary';
|
|
1229
|
+
expectedColumnNames['Theme Column'] = true;
|
|
1230
|
+
const singleCol = await addColumn('Theme', activeTheme);
|
|
1231
|
+
if (findChildIndexByName(columns, 'Theme Column') !== 0) {
|
|
1232
|
+
columns.insertChild(0, singleCol);
|
|
1233
|
+
}
|
|
1234
|
+
setThemeMode(singleCol, activeTheme);
|
|
1235
|
+
} else {
|
|
1236
|
+
for (let ti = 0; ti < requestedThemes.length; ti++) {
|
|
1237
|
+
const themeName = requestedThemes[ti];
|
|
1238
|
+
const themeCol = await addColumn(formatThemeLabel(themeName), themeName);
|
|
1239
|
+
const columnName = formatThemeLabel(themeName) + ' Column';
|
|
1240
|
+
expectedColumnNames[columnName] = true;
|
|
1241
|
+
if (findChildIndexByName(columns, columnName) !== ti) {
|
|
1242
|
+
columns.insertChild(ti, themeCol);
|
|
1243
|
+
}
|
|
1244
|
+
setThemeMode(themeCol, themeName);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const existingColumns: SceneNode[] = columns.children ? Array.from(columns.children) : [];
|
|
1248
|
+
const seenColumns: Record<string, boolean> = {};
|
|
1249
|
+
for (let i = 0; i < existingColumns.length; i++) {
|
|
1250
|
+
const col = existingColumns[i];
|
|
1251
|
+
if (!expectedColumnNames[col.name] || seenColumns[col.name]) {
|
|
1252
|
+
col.remove();
|
|
1253
|
+
} else {
|
|
1254
|
+
seenColumns[col.name] = true;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
debug('UI section updated', { columns: columns.children.length });
|
|
1258
|
+
return section;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// ====================================================================
|
|
1262
|
+
// Pre-flight data computation
|
|
1263
|
+
// ====================================================================
|
|
1264
|
+
|
|
1265
|
+
export interface PreflightItem {
|
|
1266
|
+
name: string;
|
|
1267
|
+
status: 'new' | 'changed' | 'unchanged';
|
|
1268
|
+
section: string;
|
|
1269
|
+
sectionTitle: string;
|
|
1270
|
+
/**
|
|
1271
|
+
* Optional human-readable label. Used when `name` is a synthetic identifier
|
|
1272
|
+
* (e.g. the design tokens toggle) that isn't suitable for direct display.
|
|
1273
|
+
*/
|
|
1274
|
+
displayName?: string;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
export function computePreflightData(tokenHash: string): PreflightItem[] {
|
|
1278
|
+
let ds: PageNode | null = null;
|
|
1279
|
+
if (figma.root && Array.isArray(figma.root.children)) {
|
|
1280
|
+
for (let pi = 0; pi < figma.root.children.length; pi++) {
|
|
1281
|
+
if (figma.root.children[pi].name === 'Design System') {
|
|
1282
|
+
ds = figma.root.children[pi];
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const defsRaw = (COMPONENT_DEFS && COMPONENT_DEFS.components) ? COMPONENT_DEFS.components : [];
|
|
1289
|
+
const result: PreflightItem[] = [];
|
|
1290
|
+
|
|
1291
|
+
for (let i = 0; i < defsRaw.length; i++) {
|
|
1292
|
+
const def = normalizeForPreflight(defsRaw[i]);
|
|
1293
|
+
if (!def || !def.stories || def.stories.length === 0) continue;
|
|
1294
|
+
|
|
1295
|
+
const section = inferComponentSection(def);
|
|
1296
|
+
const defHash = hashDef(def);
|
|
1297
|
+
|
|
1298
|
+
let status: 'new' | 'changed' | 'unchanged' = 'new';
|
|
1299
|
+
if (ds) {
|
|
1300
|
+
const matchingBlocks = findComponentBlocksInPage(ds, def.name);
|
|
1301
|
+
if (matchingBlocks.length > 0) {
|
|
1302
|
+
let hasCurrentHash = false;
|
|
1303
|
+
for (let bi = 0; bi < matchingBlocks.length; bi++) {
|
|
1304
|
+
if (blockHashMatchesCurrent(getFrameHash(matchingBlocks[bi]), defHash, tokenHash, RENDER_ENGINE_VERSION)) {
|
|
1305
|
+
hasCurrentHash = true;
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
status = hasCurrentHash ? 'unchanged' : 'changed';
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
result.push({
|
|
1314
|
+
name: def.name,
|
|
1315
|
+
status: status,
|
|
1316
|
+
section: section.key,
|
|
1317
|
+
sectionTitle: section.title,
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return result;
|
|
1322
|
+
}
|