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.
Files changed (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * design-system.ts — Design System Page Generation
3
+ *
4
+ * Orchestrates building the "Design System" page in Figma.
5
+ *
6
+ * ## Incremental re-render
7
+ * Frames are no longer rebuilt from scratch on every run. Instead:
8
+ *
9
+ * - The "Design Tokens" row is rebuilt only when token values change
10
+ * (detected via a hash stored in Figma plugin data).
11
+ * - Component section frames are preserved and
12
+ * their contents are diffed at the per-component-block level — see
13
+ * story-builder.ts and frame-cache.ts for details.
14
+ *
15
+ * When a frame is unchanged its node ID is preserved, so designer annotations
16
+ * and manual repositioning survive plugin re-runs.
17
+ *
18
+ * yOffset / xOffset in opts are only applied when a section is created for the
19
+ * first time. Existing sections keep their current position.
20
+ */
21
+
22
+ import { debug } from '../tokens';
23
+ import { getThemeNames, TOKENS } from '../tokens';
24
+ import {
25
+ demoFrameColors,
26
+ demoFrameRadii,
27
+ demoFrameFonts,
28
+ demoFrameSpacing,
29
+ demoFrameFontSizes,
30
+ demoFrameShadows,
31
+ demoFrameBreakpoints,
32
+ buildTokensCategorySection,
33
+ } from '../tokens';
34
+ import { createUIComponents, pruneGeneratedComponentLibrary } from './ui-builder';
35
+ import { hashString, stableStringify, getFrameHash, setFrameHash, findChildByName } from '../cache';
36
+ import { isGeneratedDesignSystemNode, tagGeneratedNode } from './generated-node';
37
+
38
+ const DESIGN_SYSTEM_PAGE_NAME = 'Design System';
39
+ const COMPONENT_LIBRARY_ROOT_NAME = '__Inkbridge Component Library';
40
+ // Synthetic preflight identifier — must match the constant in main.ts.
41
+ const DESIGN_TOKENS_TOGGLE = '__design_tokens__';
42
+
43
+ function removeStalePageLabels(page: PageNode | null, labels: string[]): void {
44
+ if (!page || !Array.isArray(page.children)) return;
45
+ const targets = new Set(labels);
46
+ const staleNodes = page.children.filter((node): node is TextNode =>
47
+ node.type === 'TEXT'
48
+ && typeof node.characters === 'string'
49
+ && targets.has(node.characters)
50
+ );
51
+ for (let i = 0; i < staleNodes.length; i++) {
52
+ staleNodes[i].remove();
53
+ }
54
+ }
55
+
56
+ // Remove orphaned top-level nodes left behind by failed/interrupted runs.
57
+ // The Design System page is plugin-managed: only these named sections are
58
+ // expected as direct children. Anything else is stale generator output.
59
+ const KNOWN_TOP_LEVEL_SECTIONS = new Set([
60
+ 'Design Tokens',
61
+ 'UI Components',
62
+ COMPONENT_LIBRARY_ROOT_NAME,
63
+ ]);
64
+
65
+ function removeDuplicateTopLevelSections(page: PageNode | null): void {
66
+ if (!page || !Array.isArray(page.children)) return;
67
+ const seen: Record<string, boolean> = {};
68
+ const children = page.children.slice();
69
+ for (let i = 0; i < children.length; i++) {
70
+ const node = children[i];
71
+ if (!node || !KNOWN_TOP_LEVEL_SECTIONS.has(node.name)) continue;
72
+ if (seen[node.name]) {
73
+ node.remove();
74
+ continue;
75
+ }
76
+ seen[node.name] = true;
77
+ }
78
+ }
79
+
80
+ function removeOrphanedTopLevelNodes(page: PageNode | null): void {
81
+ if (!page || !Array.isArray(page.children)) return;
82
+ const orphans = page.children.filter(function(node) {
83
+ if (!node) return false;
84
+ if (KNOWN_TOP_LEVEL_SECTIONS.has(node.name)) return false;
85
+ // Primary path: generated metadata marks this node as plugin output.
86
+ if (isGeneratedDesignSystemNode(node)) return true;
87
+ // Legacy fallback: older plugin runs didn't tag generated nodes.
88
+ if (isLegacyGeneratedOrphan(node)) return true;
89
+ // Defensive cleanup: plugin-managed page should not contain any additional
90
+ // direct children at root level, regardless of node type.
91
+ return true;
92
+ });
93
+ for (let i = 0; i < orphans.length; i++) {
94
+ orphans[i].remove();
95
+ }
96
+ }
97
+
98
+ function nodeHasTextDescendantLike(node: BaseNode, phrase: string): boolean {
99
+ if (!node) return false;
100
+ const value = String(phrase || '').trim().toLowerCase();
101
+ if (!value) return false;
102
+ if (node.type === 'TEXT' && typeof node.characters === 'string') {
103
+ return node.characters.trim().toLowerCase().indexOf(value) !== -1;
104
+ }
105
+ if (!('children' in node) || !Array.isArray(node.children)) return false;
106
+ for (let i = 0; i < node.children.length; i++) {
107
+ if (nodeHasTextDescendantLike(node.children[i], value)) return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function isLegacyGeneratedOrphan(node: BaseNode): boolean {
113
+ if (!node) return false;
114
+ if (node.name === COMPONENT_LIBRARY_ROOT_NAME) return true;
115
+ if (node.name === 'States' && nodeHasTextDescendantLike(node, 'state matrix')) return true;
116
+ if (node.name === 'Responsive' && nodeHasTextDescendantLike(node, 'responsive')) return true;
117
+ if (node.name === 'State Axes' || node.name === 'State Table') return true;
118
+ if (typeof node.name === 'string' && node.name.startsWith('State Row/')) return true;
119
+ if (node.type === 'TEXT' && typeof node.characters === 'string') {
120
+ const text = node.characters.trim().toLowerCase();
121
+ if (text.indexOf('state matrix') !== -1 || text === 'responsive') return true;
122
+ }
123
+ // Catch detached/renamed fragments that still carry legacy marker labels.
124
+ if (nodeHasTextDescendantLike(node, 'state matrix') || nodeHasTextDescendantLike(node, 'responsive')) return true;
125
+ return false;
126
+ }
127
+
128
+ function removeLegacyArtifactsFromNonDesignSystemPages(): void {
129
+ const pages = figma.root && Array.isArray(figma.root.children) ? figma.root.children : [];
130
+ for (let i = 0; i < pages.length; i++) {
131
+ const page = pages[i];
132
+ if (!page || page.type !== 'PAGE' || page.name === DESIGN_SYSTEM_PAGE_NAME || !Array.isArray(page.children)) continue;
133
+ const staleNodes = page.children.filter(function(node: SceneNode) {
134
+ return isGeneratedDesignSystemNode(node) || isLegacyGeneratedOrphan(node);
135
+ });
136
+ for (let j = 0; j < staleNodes.length; j++) {
137
+ staleNodes[j].remove();
138
+ }
139
+ }
140
+ }
141
+
142
+ function removeGeneratedArtifactsInNode(node: BaseNode): number {
143
+ if (!node || !('children' in node) || !Array.isArray(node.children)) return 0;
144
+ let removed = 0;
145
+ const children = node.children.slice();
146
+ for (let i = 0; i < children.length; i++) {
147
+ const child = children[i];
148
+ if (!child) continue;
149
+ if (child.type === 'PAGE') {
150
+ removed += removeGeneratedArtifactsInNode(child);
151
+ continue;
152
+ }
153
+
154
+ const generated = isGeneratedDesignSystemNode(child);
155
+ const legacy = isLegacyGeneratedOrphan(child);
156
+ if (generated || legacy) {
157
+ child.remove();
158
+ removed += 1;
159
+ continue;
160
+ }
161
+
162
+ if ('children' in child && Array.isArray(child.children)) {
163
+ removed += removeGeneratedArtifactsInNode(child);
164
+ }
165
+ }
166
+ return removed;
167
+ }
168
+
169
+ export function cleanGeneratedDesignSystemArtifacts(): { removedNodes: number; touchedPages: number } {
170
+ const pages = figma.root && Array.isArray(figma.root.children) ? figma.root.children : [];
171
+ let removedNodes = 0;
172
+ let touchedPages = 0;
173
+ for (let i = 0; i < pages.length; i++) {
174
+ const page = pages[i];
175
+ if (!page || page.type !== 'PAGE') continue;
176
+ const removedInPage = removeGeneratedArtifactsInNode(page);
177
+ if (removedInPage > 0) {
178
+ removedNodes += removedInPage;
179
+ touchedPages += 1;
180
+ }
181
+ }
182
+ return { removedNodes, touchedPages };
183
+ }
184
+
185
+ /**
186
+ * Loading-panel status callback signature. Accepts either a flat string
187
+ * (legacy single-line status) or `{ phase, detail }` where the phase is
188
+ * sticky context across detail-only updates (so "Building components…"
189
+ * stays as the header while detail messages cycle through theme /
190
+ * section names). Both forms are honoured by `main.ts:setStatus`.
191
+ */
192
+ export type StatusUpdate =
193
+ | string
194
+ | { phase?: string; detail?: string; noYield?: boolean };
195
+
196
+ export interface BuildDesignSystemOptions {
197
+ onStatus?: (status: StatusUpdate) => void | Promise<void>;
198
+ }
199
+
200
+ export async function buildDesignSystemSinglePage(
201
+ excluded?: string[],
202
+ options?: BuildDesignSystemOptions,
203
+ ): Promise<void> {
204
+ const onStatus = options && options.onStatus
205
+ ? options.onStatus
206
+ : function (_s: StatusUpdate): void | Promise<void> { return; };
207
+ let ds: PageNode | null = figma.root.children.find(p => p.name === DESIGN_SYSTEM_PAGE_NAME) ?? null;
208
+ if (!ds) { ds = figma.createPage(); ds.name = DESIGN_SYSTEM_PAGE_NAME; }
209
+ tagGeneratedNode(ds, 'design-system-page');
210
+ removeLegacyArtifactsFromNonDesignSystemPages();
211
+ figma.currentPage = ds;
212
+ removeDuplicateTopLevelSections(ds);
213
+ removeOrphanedTopLevelNodes(ds);
214
+ removeStalePageLabels(ds, ['Design Tokens', 'UI Components']);
215
+
216
+ const themeNames = getThemeNames(TOKENS);
217
+
218
+ // Compute a single token hash for this run. Passed into createUIComponents
219
+ // so component blocks can include token state in their own hashes.
220
+ const tokenHash = hashString(stableStringify(TOKENS));
221
+
222
+ // The design-tokens toggle is a synthetic preflight item; strip it before
223
+ // forwarding the excluded list to the component builder so it isn't treated
224
+ // as a missing component.
225
+ const skipDesignTokens = (excluded || []).indexOf(DESIGN_TOKENS_TOGGLE) !== -1;
226
+ excluded = (excluded || []).filter(function (name) { return name !== DESIGN_TOKENS_TOGGLE; });
227
+
228
+ // --- Design Tokens row (incremental) ---
229
+ // Rebuilt only when token values have changed since the last run, and only
230
+ // when the design-tokens preflight toggle is selected.
231
+ let tokensRow: FrameNode | null = findChildByName(ds, 'Design Tokens') as FrameNode | null;
232
+ const tokensNeedRebuild = !skipDesignTokens && (!tokensRow || getFrameHash(tokensRow) !== tokenHash);
233
+ if (tokensNeedRebuild) await onStatus('Building design tokens…');
234
+ if (tokensNeedRebuild) {
235
+ const prevX: number = tokensRow ? tokensRow.x : 48;
236
+ const prevY: number = tokensRow ? tokensRow.y : 48;
237
+ if (tokensRow) tokensRow.remove();
238
+
239
+ tokensRow = figma.createFrame();
240
+ tokensRow.name = 'Design Tokens';
241
+ tokensRow.layoutMode = 'VERTICAL';
242
+ tokensRow.primaryAxisSizingMode = 'AUTO';
243
+ tokensRow.counterAxisSizingMode = 'AUTO';
244
+ tokensRow.itemSpacing = 32;
245
+ tokensRow.paddingLeft = tokensRow.paddingRight = 32;
246
+ tokensRow.paddingTop = tokensRow.paddingBottom = 24;
247
+ tokensRow.fills = [];
248
+ tokensRow.strokes = [];
249
+ const appendIfPresent = (node: SceneNode | null): void => {
250
+ if (node && tokensRow) tokensRow.appendChild(node);
251
+ };
252
+ appendIfPresent(buildTokensCategorySection(
253
+ 'Colors',
254
+ themeNames.map((themeName) => demoFrameColors(themeName))
255
+ ));
256
+ appendIfPresent(buildTokensCategorySection(
257
+ 'Fonts',
258
+ themeNames.map((themeName) => demoFrameFonts(themeName))
259
+ ));
260
+ appendIfPresent(buildTokensCategorySection('Font sizes', [demoFrameFontSizes()]));
261
+ appendIfPresent(buildTokensCategorySection('Radius', [demoFrameRadii()]));
262
+ appendIfPresent(buildTokensCategorySection('Spacing', [demoFrameSpacing()]));
263
+ appendIfPresent(buildTokensCategorySection('Breakpoints', [demoFrameBreakpoints()]));
264
+ appendIfPresent(buildTokensCategorySection(
265
+ 'Shadows',
266
+ themeNames.map((themeName) => demoFrameShadows(themeName))
267
+ ));
268
+ tokensRow.x = prevX;
269
+ tokensRow.y = prevY;
270
+ ds.appendChild(tokensRow);
271
+ setFrameHash(tokensRow, tokenHash);
272
+ tagGeneratedNode(tokensRow, 'design-tokens-row');
273
+ debug('Tokens row rebuilt', { columns: tokensRow.children.length, hash: tokenHash });
274
+ } else if (tokensRow) {
275
+ tagGeneratedNode(tokensRow, 'design-tokens-row');
276
+ debug('Tokens row unchanged — skipped', { hash: tokenHash, skipped: skipDesignTokens });
277
+ }
278
+
279
+ // Default y for a new UI Components section (below tokens row).
280
+ // Only used when the section does not yet exist on the page. When the design
281
+ // tokens row was skipped and never existed, fall back to the page top.
282
+ const defaultUiY = tokensRow ? (tokensRow.y + tokensRow.height + 80) : 48;
283
+
284
+ // Keep hidden master library aligned with current scanner output and themes.
285
+ // Removes stale/duplicate generated masters from old runs.
286
+ pruneGeneratedComponentLibrary(themeNames);
287
+
288
+ const excludedNames: string[] = (excluded && excluded.length > 0) ? excluded : [];
289
+
290
+ // Set the phase as a sticky header; createUIComponents below emits
291
+ // detail-only updates per theme / section. Both lines stay visible
292
+ // together so the user always knows what bigger phase is in flight
293
+ // even as the detail cycles through theme + section names.
294
+ // Phase text is sticky; ui.html animates trailing dots via CSS so we
295
+ // don't bake them into the string here.
296
+ await onStatus({ phase: 'Building components', detail: '' });
297
+ await createUIComponents(ds, {
298
+ themeNames,
299
+ yOffset: defaultUiY,
300
+ xOffset: 48,
301
+ excludeComponents: excludedNames,
302
+ preserveUnselectedComponents: excludedNames.length > 0,
303
+ tokenHash,
304
+ showSectionHeader: false,
305
+ onStatus,
306
+ });
307
+
308
+ // Reflow guard: the UI Components section y is only set on first creation.
309
+ // Partial-selection or subset runs previously produced an overlap with the
310
+ // Design Tokens row; enforce a minimum y every run while preserving any
311
+ // further-down position a designer may have set. Also align x so the
312
+ // section never drifts above/left of the tokens row.
313
+ const uiSection = findChildByName(ds, 'UI Components') as FrameNode | null;
314
+ if (uiSection && tokensRow) {
315
+ const minUiY = tokensRow.y + tokensRow.height + 80;
316
+ if (uiSection.y < minUiY) uiSection.y = minUiY;
317
+ if (uiSection.x < tokensRow.x) uiSection.x = tokensRow.x;
318
+ }
319
+
320
+ // Post-build orphan sweep: frames can be abandoned during a partial run
321
+ // (e.g. a rebuild path that errors out mid-way leaves a dangling frame at
322
+ // the page root). Rerun the sweeps so nothing survives outside the known
323
+ // top-level sections.
324
+ removeDuplicateTopLevelSections(ds);
325
+ removeOrphanedTopLevelNodes(ds);
326
+
327
+ // Frame the freshly-built page so the user lands on the result instead of
328
+ // wherever the viewport was before the run. Filter out invisible children
329
+ // defensively — scrollAndZoomIntoView throws on an empty selection.
330
+ const visibleChildren = ds.children.filter((node) => node.visible);
331
+ if (visibleChildren.length > 0) {
332
+ figma.viewport.scrollAndZoomIntoView(visibleChildren);
333
+ }
334
+ }
@@ -0,0 +1,191 @@
1
+ import { getBaseClass, isElementLikeNode, type NodeIR } from '../tailwind';
2
+ import { isTabsRootTag, isTabsListTag, isTabsContentTag } from './tag-predicates';
3
+ import { getNodeEffectiveClasses, getNodeMarginTopPx } from './node-helpers';
4
+
5
+ /**
6
+ * Post-render frame stabilizers. Each function takes a frame (and possibly
7
+ * its parent) that's already been auto-laid-out and applies a fix that
8
+ * couldn't be expressed cleanly during initial render — usually because
9
+ * the fix depends on the parent's resolved width or on sibling layout.
10
+ *
11
+ * - `constrainSingleHorizontalTextChild`: when a fixed-width row has one
12
+ * text child and other in-flow siblings, cap the text width so it doesn't
13
+ * overflow and steal space from a sibling.
14
+ * - `stabilizeHorizontalStretchChild`: pin a STRETCH'd horizontal child to
15
+ * its parent's content width when the parent is a fixed-width column,
16
+ * so the child renders at the same width Figma would compute on its own.
17
+ * - `reflowMxAutoChildren`: re-flow children that the layout pass tagged
18
+ * as 'mx-auto' so they fill the parent's content box width.
19
+ * - `applyVerticalMarginSpacing`: when stacked vertical children carry
20
+ * `mt-*` utilities, reflect the largest margin-top into the frame's
21
+ * itemSpacing (Figma can't apply per-child margin in auto-layout).
22
+ * - `enforceTabsChildSizing`: stretch a Tabs.List / Tabs.Content child to
23
+ * its parent's content width so the tab bar visually fills the row.
24
+ */
25
+
26
+ export function constrainSingleHorizontalTextChild(node: SceneNode): void {
27
+ if (node.type !== 'FRAME') return;
28
+ const frame = node;
29
+ if (
30
+ frame.layoutMode !== 'HORIZONTAL'
31
+ || !Array.isArray(frame.children)
32
+ || frame.children.length < 2
33
+ ) {
34
+ return;
35
+ }
36
+
37
+ // Keep naturally centered CTA/action rows intact.
38
+ // Constraining text width in CENTER rows makes one text child consume the
39
+ // remaining width, which shifts sibling controls off-center.
40
+ if (frame.primaryAxisAlignItems === 'CENTER') {
41
+ return;
42
+ }
43
+
44
+ // Width constraints only make sense once the row width is fixed.
45
+ if (frame.primaryAxisSizingMode !== 'FIXED') {
46
+ return;
47
+ }
48
+
49
+ const textChildren = frame.children.filter((child): child is TextNode => child?.type === 'TEXT');
50
+ if (textChildren.length !== 1) return;
51
+
52
+ // CSS flex: absolute/fixed siblings are out-of-flow and don't consume space
53
+ // in the row. Including them would steal width from the text and force wrap
54
+ // (e.g. a Select item's absolute check-indicator shrinking the label text).
55
+ const inFlowNonText = frame.children.filter((child) => {
56
+ if (!child || child.type === 'TEXT') return false;
57
+ if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') return false;
58
+ return true;
59
+ });
60
+ const nonTextWidth = inFlowNonText.reduce((sum, child) => {
61
+ return sum + ('width' in child ? child.width : 0);
62
+ }, 0);
63
+ const gapContributingCount = inFlowNonText.length + textChildren.length;
64
+
65
+ const availableWidth = Math.max(
66
+ 0,
67
+ frame.width
68
+ - (frame.paddingLeft || 0)
69
+ - (frame.paddingRight || 0)
70
+ - (frame.itemSpacing || 0) * Math.max(0, gapContributingCount - 1)
71
+ - nonTextWidth,
72
+ );
73
+
74
+ if (availableWidth <= 0) return;
75
+
76
+ const textChild = textChildren[0];
77
+ // CSS-default behavior for text in a horizontal flex row is to overflow, not
78
+ // wrap. Only constrain if the text would actually exceed the available space
79
+ // AND there's another in-flow sibling competing for it.
80
+ if (inFlowNonText.length === 0) return;
81
+ if ((textChild.width || 0) <= availableWidth) return;
82
+ try {
83
+ textChild.textAutoResize = 'HEIGHT';
84
+ textChild.resize(availableWidth, textChild.height);
85
+ } catch (_err) {
86
+ // ignore resize errors
87
+ }
88
+ }
89
+
90
+ export function stabilizeHorizontalStretchChild(child: SceneNode, parent: SceneNode): void {
91
+ if (!parent || parent.type !== 'FRAME') return;
92
+ if (parent.layoutMode !== 'VERTICAL') return;
93
+ if (
94
+ !child
95
+ || child.type !== 'FRAME'
96
+ || child.layoutPositioning === 'ABSOLUTE'
97
+ || child.layoutMode !== 'HORIZONTAL'
98
+ || child.layoutAlign !== 'STRETCH'
99
+ || child.primaryAxisSizingMode === 'FIXED'
100
+ || child.name === 'mx-auto'
101
+ ) {
102
+ return;
103
+ }
104
+
105
+ const parentContentWidth = Math.max(
106
+ 0,
107
+ (parent.width || 0) - (parent.paddingLeft || 0) - (parent.paddingRight || 0),
108
+ );
109
+ if (parentContentWidth <= 0) return;
110
+
111
+ try {
112
+ child.resize(parentContentWidth, Math.max(1, child.height || 1));
113
+ child.primaryAxisSizingMode = 'FIXED';
114
+ } catch (_err) {
115
+ // ignore resize errors
116
+ }
117
+ }
118
+
119
+ export function reflowMxAutoChildren(parent: SceneNode): void {
120
+ if (!parent || parent.type !== 'FRAME') return;
121
+ if (!Array.isArray(parent.children) || parent.children.length === 0) return;
122
+ const contentWidth = Math.max(0, (parent.width || 0) - (parent.paddingLeft || 0) - (parent.paddingRight || 0));
123
+ if (!contentWidth) return;
124
+ for (const child of parent.children) {
125
+ if (!child || child.name !== 'mx-auto') continue;
126
+ if (!('resize' in child)) continue;
127
+ try {
128
+ child.resize(contentWidth, Math.max(1, child.height || 1));
129
+ if ('layoutAlign' in child) child.layoutAlign = 'STRETCH';
130
+ if ('primaryAxisSizingMode' in child) child.primaryAxisSizingMode = 'FIXED';
131
+ if ('layoutSizingHorizontal' in child) child.layoutSizingHorizontal = 'FIXED';
132
+ } catch (_err) {
133
+ // ignore
134
+ }
135
+ }
136
+ }
137
+
138
+ export function applyVerticalMarginSpacing(frame: FrameNode, children: NodeIR[]): void {
139
+ if (!frame || frame.layoutMode !== 'VERTICAL' || !children || children.length <= 1) return;
140
+ let maxMarginTop = 0;
141
+ for (const child of children) {
142
+ if (!isElementLikeNode(child)) continue;
143
+ const mt = getNodeMarginTopPx(child);
144
+ if (mt > maxMarginTop) maxMarginTop = mt;
145
+ }
146
+ if (maxMarginTop > 0 && (!frame.itemSpacing || frame.itemSpacing < maxMarginTop)) {
147
+ frame.itemSpacing = maxMarginTop;
148
+ }
149
+ }
150
+
151
+ export function enforceTabsChildSizing(
152
+ parentNode: NodeIR,
153
+ child: NodeIR,
154
+ childNode: SceneNode,
155
+ parentContentWidth?: number
156
+ ): void {
157
+ if ((parentNode.kind !== 'element' && parentNode.kind !== 'component') || !isTabsRootTag(parentNode.tagName)) return;
158
+ if (child.kind !== 'element' && child.kind !== 'component') return;
159
+
160
+ const isTabsList = isTabsListTag(child.tagName);
161
+ const isTabsContent = isTabsContentTag(child.tagName);
162
+ if (!isTabsList && !isTabsContent) return;
163
+
164
+ const childClasses = getNodeEffectiveClasses(child);
165
+ const hasFullWidth = childClasses.some(cls => getBaseClass(cls) === 'w-full');
166
+ const shouldStretch = isTabsContent || (isTabsList && hasFullWidth);
167
+ if (!shouldStretch) return;
168
+
169
+ if ('layoutAlign' in childNode) {
170
+ try {
171
+ childNode.layoutAlign = 'STRETCH';
172
+ } catch (_err) {
173
+ // ignore
174
+ }
175
+ }
176
+
177
+ if (parentContentWidth == null || !Number.isFinite(parentContentWidth) || parentContentWidth <= 0) return;
178
+ if (childNode.type !== 'FRAME') return;
179
+
180
+ try {
181
+ const targetWidth = Math.max(1, parentContentWidth);
182
+ childNode.resize(targetWidth, Math.max(1, childNode.height || 1));
183
+ if (childNode.layoutMode === 'HORIZONTAL') {
184
+ childNode.primaryAxisSizingMode = 'FIXED';
185
+ } else if (childNode.layoutMode === 'VERTICAL') {
186
+ childNode.counterAxisSizingMode = 'FIXED';
187
+ }
188
+ } catch (_err) {
189
+ // ignore resize errors
190
+ }
191
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Generic Figma frame helpers used by the design-system page builder.
3
+ * Kept distinct from layout helpers (which deal with auto-layout sizing)
4
+ * — these are children-array maintenance routines.
5
+ */
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ export function isTextNode(node: any): boolean {
9
+ return !!node && node.type === 'TEXT';
10
+ }
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ export function removeDirectTextChildren(parent: any, names?: string[]): void {
14
+ if (!parent || !Array.isArray(parent.children)) return;
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ const stale = parent.children.filter(function(child: any) {
17
+ if (!isTextNode(child)) return false;
18
+ if (!names || names.length === 0) return true;
19
+ return names.indexOf(child.name) !== -1 || names.indexOf(child.characters) !== -1;
20
+ });
21
+ for (let i = 0; i < stale.length; i++) {
22
+ stale[i].remove();
23
+ }
24
+ }
25
+
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ export function removeDuplicateChildrenByName(parent: any, name: string, type?: string): void {
28
+ if (!parent || !Array.isArray(parent.children)) return;
29
+ let seen = false;
30
+ const children = parent.children.slice();
31
+ for (let i = 0; i < children.length; i++) {
32
+ const child = children[i];
33
+ if (!child || child.name !== name) continue;
34
+ if (type && child.type !== type) continue;
35
+ if (!seen) {
36
+ seen = true;
37
+ continue;
38
+ }
39
+ child.remove();
40
+ }
41
+ }
42
+
43
+ export function hasNodeChildren(node: BaseNode | null | undefined): boolean {
44
+ if (!node || !('children' in node)) return false;
45
+ return Array.isArray(node.children) && node.children.length > 0;
46
+ }
@@ -0,0 +1,84 @@
1
+ const GENERATED_KEY = 'inkbridge:generated';
2
+ const GENERATED_SCOPE_KEY = 'inkbridge:scope';
3
+ const GENERATED_ROLE_KEY = 'inkbridge:role';
4
+ const FALLBACK_REASON_KEY = 'inkbridge:fallback-reason';
5
+ const SYMBOL_DECISION_KEY = 'inkbridge:symbol-decision';
6
+ const SYMBOL_IGNORED_PROPS_KEY = 'inkbridge:symbol-ignored-props';
7
+ const SYMBOL_TEXT_OVERRIDES_KEY = 'inkbridge:symbol-text-overrides';
8
+ const SYMBOL_SLOT_PROP_MAPPINGS_KEY = 'inkbridge:symbol-slot-prop-mappings';
9
+ const GENERATED_SCOPE_VALUE = 'design-system';
10
+
11
+ export function tagGeneratedNode(node: BaseNode | null | undefined, role: string): void {
12
+ if (!node) return;
13
+ try {
14
+ node.setPluginData(GENERATED_KEY, '1');
15
+ node.setPluginData(GENERATED_SCOPE_KEY, GENERATED_SCOPE_VALUE);
16
+ node.setPluginData(GENERATED_ROLE_KEY, role || 'unknown');
17
+ } catch (_error) {
18
+ // Not all node types support plugin data; ignore safely.
19
+ }
20
+ }
21
+
22
+ export function tagGeneratedSubtree(node: BaseNode | null | undefined, role: string): void {
23
+ if (!node) return;
24
+ tagGeneratedNode(node, role);
25
+ if (!('children' in node)) return;
26
+ for (let i = 0; i < node.children.length; i++) {
27
+ tagGeneratedSubtree(node.children[i], role);
28
+ }
29
+ }
30
+
31
+ export function isGeneratedDesignSystemNode(node: BaseNode | null | undefined): boolean {
32
+ if (!node) return false;
33
+ try {
34
+ return node.getPluginData(GENERATED_KEY) === '1'
35
+ && node.getPluginData(GENERATED_SCOPE_KEY) === GENERATED_SCOPE_VALUE;
36
+ } catch (_error) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export function setGeneratedFallbackReason(node: BaseNode | null | undefined, reason: string): void {
42
+ if (!node) return;
43
+ tagGeneratedNode(node, 'instance-fallback');
44
+ try {
45
+ node.setPluginData(FALLBACK_REASON_KEY, String(reason || 'unknown'));
46
+ } catch (_error) {
47
+ // ignore
48
+ }
49
+ }
50
+
51
+ function setPluginDataValue(node: BaseNode | null | undefined, key: string, value: string): void {
52
+ if (!node || !key) return;
53
+ try {
54
+ node.setPluginData(key, String(value || ''));
55
+ } catch (_error) {
56
+ // ignore
57
+ }
58
+ }
59
+
60
+ export function setGeneratedSymbolDebugData(
61
+ node: BaseNode | null | undefined,
62
+ data: {
63
+ decision?: string;
64
+ ignoredProps?: string[];
65
+ textOverrides?: Record<string, string>;
66
+ slotPropMappings?: Record<string, string>;
67
+ }
68
+ ): void {
69
+ if (!node) return;
70
+ tagGeneratedNode(node, 'symbol-instance');
71
+
72
+ if (data && data.decision) {
73
+ setPluginDataValue(node, SYMBOL_DECISION_KEY, data.decision);
74
+ }
75
+ if (data && data.ignoredProps && data.ignoredProps.length > 0) {
76
+ setPluginDataValue(node, SYMBOL_IGNORED_PROPS_KEY, JSON.stringify(data.ignoredProps));
77
+ }
78
+ if (data && data.textOverrides && Object.keys(data.textOverrides).length > 0) {
79
+ setPluginDataValue(node, SYMBOL_TEXT_OVERRIDES_KEY, JSON.stringify(data.textOverrides));
80
+ }
81
+ if (data && data.slotPropMappings && Object.keys(data.slotPropMappings).length > 0) {
82
+ setPluginDataValue(node, SYMBOL_SLOT_PROP_MAPPINGS_KEY, JSON.stringify(data.slotPropMappings));
83
+ }
84
+ }