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
package/src/main.ts CHANGED
@@ -3,24 +3,147 @@
3
3
  // Includes "Push to Code" feature for GitHub PR creation.
4
4
  // Universal plugin - configure for any project via Settings.
5
5
 
6
- import { loadConfig, saveConfig, GITHUB_CONFIG } from './config';
6
+ import { loadConfig, saveConfig, GITHUB_CONFIG } from './plugin';
7
7
  import { TOKENS, COMPONENT_DEFS, applyScannedTokens, getCoreFontFamily, getThemeFontFamily, getThemeNames } from './tokens';
8
- import { rebuildColorIndex } from './colors';
9
- import { handleUIReady, handleImageResult, prefetchImages } from './dev-server';
10
- import { setImageMap, setSvgMap } from './image-cache';
11
- import { applyPack, type Pack } from './packs';
12
- import { handlePackResult, refreshPack } from './pack-provider';
13
- import { createOrUpdateStyles } from './variables';
8
+ import { rebuildColorIndex } from './tokens';
9
+ import { handleUIReady, handleImageResult, prefetchImages } from './plugin';
10
+ import { setImageMap, setSvgMap } from './cache';
11
+ import { applyPack, type Pack, handlePackResult, refreshPack } from './plugin';
12
+ import { createOrUpdateStyles } from './tokens';
14
13
  import { tailwindForNode } from './tailwind';
15
- import { detectComponentChanges } from './change-detection';
14
+ import { detectComponentChanges } from './tokens';
16
15
  import { pushToGitHub, pushToGitHubWithComponents, handleGitHubFetchResult, handlePatchCssResult, previewTokenChanges } from './github';
17
- import { buildDesignSystemSinglePage } from './design-system';
18
- import { waitForAllFonts } from './text-builder';
19
- import { getKnownStylesForFamily, initializeFontStyleIndex } from './font-style-resolver';
20
- import type { ResolvedTokenSourceMode, ScannedTokenMap, TokenSourceMode } from './token-source';
16
+ import {
17
+ buildDesignSystemSinglePage,
18
+ cleanGeneratedDesignSystemArtifacts,
19
+ computePreflightData,
20
+ isGeneratedDesignSystemNode,
21
+ } from './design-system';
22
+ import { hashString, stableStringify, getFrameHash, findChildByName } from './cache';
23
+ import { waitForAllFonts } from './text';
24
+ import { getKnownStylesForFamily, initializeFontStyleIndex } from './text';
25
+ import type { ResolvedTokenSourceMode, ScannedTokenMap, TokenSourceMode } from './tokens';
26
+ import { collectImageSrcs } from './plugin/image-src-collector';
21
27
 
22
28
  const PACK_LOAD_ERROR = 'Dev server not reachable / pack not found.';
29
+ const PREFLIGHT_EXCLUDED_KEY = 'preflight_excluded_components';
30
+ // Synthetic preflight identifier for the Design Tokens row. Kept as a constant
31
+ // so design-system.ts and main.ts agree on the magic name. Double-underscore
32
+ // prefix avoids collision with real component names.
33
+ const DESIGN_TOKENS_TOGGLE = '__design_tokens__';
34
+
35
+ // Loading-phase status messages render inside the plugin panel (above the
36
+ // drop animation) instead of as Figma toasts. The panel must be visible
37
+ // for these to be seen, so the caller is responsible for showing the
38
+ // loading view before invoking setStatus. Errors and final success still
39
+ // use figma.notify so they're visible even if the panel is closed.
40
+ /**
41
+ * Loading-panel status. Accepts either a flat string (legacy single-line
42
+ * status) or `{ phase, detail }` where the phase is sticky context
43
+ * ("Building components") and the detail is the per-section sub-line
44
+ * ("Primary theme · Organisms (26)"). Both forms post a `status`
45
+ * message; the UI iframe renders them as one or two lines.
46
+ *
47
+ * Two yield modes:
48
+ *
49
+ * - `noYield: true` (fine-grained per-section emits) — does a
50
+ * `setTimeout(0)` yield, just long enough for the iframe to drain its
51
+ * postMessage queue and update the panel. Without any yield the
52
+ * iframe never gets a turn while code.js holds the thread, so the
53
+ * user only ever sees the parent phase ("Building components") even
54
+ * though 20+ detail emits fire. With a 0ms yield the panel updates
55
+ * but Figma's canvas typically can't complete a full paint pass in
56
+ * that window — combined with the column visibility-toggle in
57
+ * addColumn, no overlap is observable.
58
+ * - default (coarse phase boundaries in main.ts) — 50ms yield. Gives
59
+ * the canvas a paint frame so users see progress between top-level
60
+ * phases (Reading components → Building tokens → Building components).
61
+ */
62
+ type StatusInput =
63
+ | string
64
+ | { phase?: string; detail?: string; noYield?: boolean };
65
+
66
+ async function setStatus(input: StatusInput): Promise<void> {
67
+ const noYield = typeof input === 'object' && input !== null && input.noYield === true;
68
+ // Only include `phase` / `detail` in the message when the caller
69
+ // actually provided them. Sending `phase: ''` on a detail-only emit
70
+ // would clobber the sticky phase header in the UI — the panel relies
71
+ // on the absence of a `phase` key to keep the previous phase visible
72
+ // while detail messages cycle below.
73
+ //
74
+ // A bare string is treated as a NEW phase (sticky header) with the
75
+ // detail cleared. CSS animates trailing dots on the phase element, so
76
+ // we strip any baked-in `…` / `...` ellipsis from both phase AND
77
+ // detail — otherwise the visible text would carry static dots
78
+ // alongside the animated dots from CSS, masking the animation.
79
+ const stripTrailingDots = (text: string): string => text.replace(/(\u2026|\.\.\.)\s*$/, '').trim();
80
+ let message: {
81
+ type: 'status';
82
+ phase?: string;
83
+ detail?: string;
84
+ };
85
+ if (typeof input === 'string') {
86
+ message = { type: 'status', phase: stripTrailingDots(input), detail: '' };
87
+ } else {
88
+ message = { type: 'status' };
89
+ if (typeof input.phase === 'string') message.phase = stripTrailingDots(input.phase);
90
+ if (typeof input.detail === 'string') message.detail = stripTrailingDots(input.detail);
91
+ }
92
+ try {
93
+ figma.ui.postMessage(message);
94
+ } catch (_err) {
95
+ // ui not yet shown — caller should show the loading view first
96
+ }
97
+ const delay = noYield ? 0 : 50;
98
+ await new Promise(function (resolve) { setTimeout(resolve, delay); });
99
+ }
100
+
101
+ function clearStatus(): void {
102
+ try {
103
+ figma.ui.postMessage({ type: 'status', phase: '', detail: '' });
104
+ } catch (_err) {}
105
+ }
106
+
107
+
108
+ function errorMessage(e: unknown): string {
109
+ if (e instanceof Error) return errorMessage(e);
110
+ if (e && typeof e === 'object' && 'message' in e) return String((e as { message: unknown }).message);
111
+ return String(e);
112
+ }
113
+
114
+ async function loadExcludedComponents(): Promise<string[]> {
115
+ const docId = (figma.root && figma.root.id) ? String(figma.root.id) : '';
116
+ const key = docId ? PREFLIGHT_EXCLUDED_KEY + ':' + docId : PREFLIGHT_EXCLUDED_KEY;
117
+ try {
118
+ const stored = await figma.clientStorage.getAsync(key);
119
+ if (Array.isArray(stored)) return stored as string[];
120
+ } catch (_e) {}
121
+ return [];
122
+ }
123
+
124
+ async function saveExcludedComponents(excluded: string[]): Promise<void> {
125
+ const docId = (figma.root && figma.root.id) ? String(figma.root.id) : '';
126
+ const key = docId ? PREFLIGHT_EXCLUDED_KEY + ':' + docId : PREFLIGHT_EXCLUDED_KEY;
127
+ try {
128
+ await figma.clientStorage.setAsync(key, excluded);
129
+ } catch (_e) {}
130
+ }
23
131
  const TOKEN_SOURCE_INFO_KEY = 'token_source_info';
132
+ const DEBUG_PLUGIN_DATA_KEYS = [
133
+ 'inkbridge:generated',
134
+ 'inkbridge:scope',
135
+ 'inkbridge:role',
136
+ 'inkbridge:fallback-reason',
137
+ 'inkbridge:hash',
138
+ 'inkbridge:symbol-decision',
139
+ 'inkbridge:symbol-ignored-props',
140
+ 'inkbridge:symbol-text-overrides',
141
+ 'inkbridge:symbol-slot-prop-mappings',
142
+ 'inkbridge:story-name',
143
+ 'inkbridge:story-def',
144
+ 'inkbridge:story-scan',
145
+ 'inkbridge:story-render',
146
+ ];
24
147
 
25
148
  type TokenSourceInfo = {
26
149
  source: string;
@@ -30,6 +153,106 @@ type TokenSourceInfo = {
30
153
 
31
154
  let LAST_TOKEN_SOURCE_INFO: TokenSourceInfo | null = null;
32
155
 
156
+ function readNodePluginData(node: BaseNode): Record<string, string> {
157
+ const out: Record<string, string> = {};
158
+ if (!node || typeof node.getPluginData !== 'function') return out;
159
+ for (let i = 0; i < DEBUG_PLUGIN_DATA_KEYS.length; i++) {
160
+ const key = DEBUG_PLUGIN_DATA_KEYS[i];
161
+ try {
162
+ const value = node.getPluginData(key);
163
+ if (value) out[key] = value;
164
+ } catch (_err) {
165
+ // ignore plugin data read failures
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ type NodeDescription = {
172
+ id: string;
173
+ name: string;
174
+ type: string;
175
+ width?: number;
176
+ height?: number;
177
+ layoutMode?: string;
178
+ childCount?: number;
179
+ pluginData?: Record<string, string>;
180
+ };
181
+
182
+ function describeNode(node: BaseNode): NodeDescription {
183
+ const info: NodeDescription = {
184
+ id: node && node.id ? String(node.id) : '',
185
+ name: node && node.name ? String(node.name) : '',
186
+ type: node && node.type ? String(node.type) : '',
187
+ };
188
+ if (node && 'width' in node && typeof node.width === 'number') info.width = Math.round(node.width);
189
+ if (node && 'height' in node && typeof node.height === 'number') info.height = Math.round(node.height);
190
+ if (node && 'layoutMode' in node && node.layoutMode) info.layoutMode = String(node.layoutMode);
191
+ if (node && 'children' in node && Array.isArray(node.children)) {
192
+ info.childCount = node.children.length;
193
+ }
194
+ const pluginData = readNodePluginData(node);
195
+ if (Object.keys(pluginData).length > 0) {
196
+ info.pluginData = pluginData;
197
+ }
198
+ return info;
199
+ }
200
+
201
+ function collectChildrenSummary(node: BaseNode): NodeDescription[] {
202
+ if (!node || !('children' in node) || !Array.isArray(node.children)) return [];
203
+ const out: NodeDescription[] = [];
204
+ for (let i = 0; i < node.children.length; i++) {
205
+ out.push(describeNode(node.children[i]));
206
+ }
207
+ return out;
208
+ }
209
+
210
+ function findGeneratedDebugFallbackNode(): BaseNode | null {
211
+ const currentPage = figma.currentPage;
212
+ if (currentPage && isGeneratedDesignSystemNode(currentPage)) {
213
+ return currentPage;
214
+ }
215
+
216
+ if (figma.root && Array.isArray(figma.root.children)) {
217
+ for (let i = 0; i < figma.root.children.length; i++) {
218
+ const page = figma.root.children[i];
219
+ if (!page || page.type !== 'PAGE') continue;
220
+ if (isGeneratedDesignSystemNode(page)) return page;
221
+ }
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ function runDebugSelectionCommand(): void {
228
+ const selection = figma.currentPage && Array.isArray(figma.currentPage.selection)
229
+ ? figma.currentPage.selection
230
+ : [];
231
+ const fallbackNode = selection.length === 0 ? findGeneratedDebugFallbackNode() : null;
232
+ const targetNodes = selection.length > 0
233
+ ? selection
234
+ : (fallbackNode ? [fallbackNode] : []);
235
+ if (targetNodes.length === 0) {
236
+ figma.notify('No selected node and no generated Design System page found.');
237
+ return;
238
+ }
239
+
240
+ const report = targetNodes.map(function(node: BaseNode) {
241
+ return {
242
+ selected: describeNode(node),
243
+ children: collectChildrenSummary(node),
244
+ };
245
+ });
246
+
247
+ console.error('[Inkbridge][DebugSelection]', JSON.stringify(report, null, 2));
248
+ if (selection.length > 0) {
249
+ figma.notify('Debug report written to plugin console.');
250
+ } else {
251
+ figma.notify('Debug report written for generated Design System page.');
252
+ }
253
+ }
254
+
255
+
33
256
  function getPackLoadErrorMessage(error?: string): string {
34
257
  if (error === 'incompatible-schema-version' || error === 'incompatible-pack-version') {
35
258
  return 'Incompatible scanner contract. Update plugin and scanner to matching versions.';
@@ -44,18 +267,16 @@ function isOfflineError(error?: string): boolean {
44
267
  return !error || error === 'timeout' || error === 'no-result';
45
268
  }
46
269
 
47
- function coerceTokenSourceInfo(raw: any): TokenSourceInfo {
270
+ function coerceTokenSourceInfo(raw: unknown): TokenSourceInfo {
271
+ const obj = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : null;
272
+ const modeRaw = obj ? obj.mode : null;
48
273
  const mode: ResolvedTokenSourceMode =
49
- raw && (raw.mode === 'css' || raw.mode === 'dtcg' || raw.mode === 'embedded')
50
- ? raw.mode
51
- : 'embedded';
274
+ modeRaw === 'css' || modeRaw === 'dtcg' || modeRaw === 'embedded' ? modeRaw : 'embedded';
275
+ const requestedRaw = obj ? obj.requestedMode : null;
52
276
  const requestedMode: TokenSourceMode | undefined =
53
- raw && (raw.requestedMode === 'auto' || raw.requestedMode === 'css' || raw.requestedMode === 'dtcg')
54
- ? raw.requestedMode
55
- : undefined;
56
- const source = raw && typeof raw.source === 'string' && raw.source.trim()
57
- ? raw.source.trim()
58
- : 'embedded:tokens.ts';
277
+ requestedRaw === 'css' || requestedRaw === 'dtcg' ? requestedRaw : undefined;
278
+ const sourceRaw = obj && typeof obj.source === 'string' ? obj.source.trim() : '';
279
+ const source = sourceRaw || 'embedded:tokens.ts';
59
280
  return { source, mode, requestedMode };
60
281
  }
61
282
 
@@ -83,41 +304,11 @@ async function postTokenSourceInfoToUI(): Promise<void> {
83
304
  figma.ui.postMessage({ type: 'token-source-info', info: info });
84
305
  }
85
306
 
86
- // ---------------------------------------------------------------------------
87
- // Image src collection
88
- // ---------------------------------------------------------------------------
89
-
90
- function collectImageSrcsFromJsxTree(node: any, out: Set<string>): void {
91
- if (!node || typeof node !== 'object') return;
92
- const tag = node.tagName;
93
- if (tag === 'img' || tag === 'Image') {
94
- const src = node.props?.src;
95
- if (typeof src === 'string' && src.length > 0 && src !== 'src') {
96
- out.add(src);
97
- }
98
- }
99
- if (Array.isArray(node.children)) {
100
- for (const child of node.children) collectImageSrcsFromJsxTree(child, out);
101
- }
102
- }
103
-
104
- function collectImageSrcs(components: any[]): string[] {
105
- const srcs = new Set<string>();
106
- for (const comp of components) {
107
- const analysis = comp?.analysis;
108
- if (!analysis) continue;
109
- collectImageSrcsFromJsxTree(analysis.jsxTree, srcs);
110
- for (const story of analysis.stories ?? []) {
111
- collectImageSrcsFromJsxTree(story.jsxTree, srcs);
112
- }
113
- }
114
- return [...srcs];
115
- }
116
-
117
307
  // ----------------------- License Checking -----------------------
118
308
 
119
309
  let pendingLicenseResolve: ((tier: 'free' | 'pro') => void) | null = null;
120
310
  let licenseValidatingFromSettings = false;
311
+ let sessionTier: 'free' | 'pro' | null = null;
121
312
 
122
313
  async function checkLicenseOnline(key: string): Promise<'free' | 'pro'> {
123
314
  return new Promise<'free' | 'pro'>(function(resolve) {
@@ -133,8 +324,11 @@ async function checkLicenseOnline(key: string): Promise<'free' | 'pro'> {
133
324
  }
134
325
 
135
326
  async function checkLicense(): Promise<'free' | 'pro'> {
327
+ // Memory cache — valid for the lifetime of this plugin session
328
+ if (sessionTier !== null) return sessionTier;
329
+
136
330
  const key = await figma.clientStorage.getAsync('license_key');
137
- if (!key) return 'free';
331
+ if (!key) { sessionTier = 'free'; return 'free'; }
138
332
 
139
333
  const cachedTier = await figma.clientStorage.getAsync('license_tier') as string | null;
140
334
  const cachedAt = await figma.clientStorage.getAsync('license_cache_at') as string | null;
@@ -142,10 +336,13 @@ async function checkLicense(): Promise<'free' | 'pro'> {
142
336
 
143
337
  // Only trust cached 'pro' — never trust cached 'free' (avoids stale negatives)
144
338
  if (cachedTier === 'pro' && cachedAt && (Date.now() - Number(cachedAt)) < SEVEN_DAYS) {
339
+ sessionTier = 'pro';
145
340
  return 'pro';
146
341
  }
147
342
 
148
- return checkLicenseOnline(key as string);
343
+ const tier = await checkLicenseOnline(key as string);
344
+ sessionTier = tier;
345
+ return tier;
149
346
  }
150
347
 
151
348
  // ----------------------- Generate Command -----------------------
@@ -184,18 +381,46 @@ async function warmThemeFonts(): Promise<string[]> {
184
381
  return failedFonts;
185
382
  }
186
383
 
384
+ async function waitForFontsBeforeClose(timeoutMs = 4000): Promise<void> {
385
+ let timedOut = false;
386
+ await Promise.race([
387
+ waitForAllFonts(),
388
+ new Promise<void>((resolve) => {
389
+ setTimeout(() => {
390
+ timedOut = true;
391
+ resolve();
392
+ }, timeoutMs);
393
+ }),
394
+ ]);
395
+ if (timedOut) {
396
+ figma.notify('⚠️ Font load timeout; continuing.', { timeout: 2000 });
397
+ }
398
+ }
399
+
187
400
  async function runGenerate(): Promise<void> {
188
401
  let shouldClose = true;
189
402
  try {
190
403
  await loadConfig();
191
- // Show UI (hidden) so it can relay fetch requests
192
- // code.js sandbox has no network access - UI iframe does the fetching
193
- figma.showUI(__html__, { visible: false, width: 1, height: 1 });
194
-
195
- figma.notify('🔄 Loading pack...');
404
+ // Show the UI panel visibly with the loading view from the very start
405
+ // so progress messages render in-panel above the drop animation
406
+ // (handled by setStatus postMessage). The UI also relays fetch
407
+ // requests since the code.js sandbox has no network access. Match the
408
+ // preflight panel size (520) so the panel doesn't resize-jump when
409
+ // the preflight view replaces the loading view, and so the drop's
410
+ // fall (translateY -300 from `bottom:58`) starts BELOW the status
411
+ // text instead of above it (which happened at height 400, where the
412
+ // drop's start anchored at the top of the panel).
413
+ figma.showUI(__html__, { width: 360, height: 520 });
414
+ figma.ui.postMessage({ type: 'show-view', view: 'loading' });
415
+ // Yield so the UI mounts and applies the loading view before the
416
+ // first setStatus message lands.
417
+ await new Promise(function (resolve) { setTimeout(resolve, 0); });
418
+
419
+ await setStatus('Reading your components…');
196
420
  const result = await refreshPack(GITHUB_CONFIG || undefined);
197
421
 
198
422
  if (!result.success || !result.pack) {
423
+ clearStatus();
199
424
  const message = getPackLoadErrorMessage(result.error);
200
425
  figma.notify('❌ ' + message);
201
426
  figma.showUI(__html__, { width: 360, height: 400 });
@@ -217,15 +442,36 @@ async function runGenerate(): Promise<void> {
217
442
  applyScannedTokens(result.pack.tokens);
218
443
  await saveAndBroadcastTokenSourceInfo(getTokenSourceInfoFromPack(result.pack));
219
444
 
445
+ // Empty-state guard: the dev server is reachable but the scan
446
+ // returned no usable components (no `.stories.tsx` / `.stories.ts`
447
+ // found under the configured paths). Stop here and show an
448
+ // actionable message instead of generating an empty page.
449
+ if (!COMPONENT_DEFS.components || COMPONENT_DEFS.components.length === 0) {
450
+ clearStatus();
451
+ figma.showUI(__html__, { width: 360, height: 400 });
452
+ figma.ui.postMessage({ type: 'show-view', view: 'offline' });
453
+ figma.ui.postMessage({
454
+ type: 'pack-load-error',
455
+ message:
456
+ 'Connected to the dev server, but no Storybook stories were found. ' +
457
+ 'Check `inkbridge.config.json:componentPaths`, or add `.stories.tsx` / ' +
458
+ '`.stories.ts` files next to your components. The `pnpm inkbridge:scan` ' +
459
+ 'output names every file it inspected if you need details.',
460
+ });
461
+ shouldClose = false;
462
+ return;
463
+ }
464
+
465
+ await setStatus('Preparing fonts…');
220
466
  const failedFonts = await warmThemeFonts();
221
467
  if (failedFonts.length) {
222
468
  figma.notify(`⚠️ Font(s) not found in Figma: ${failedFonts.join(', ')}. Enable them via Figma's Google Fonts panel or install locally. Falling back to Inter.`, { timeout: 6000 });
223
469
  }
224
- figma.notify('✅ Loaded ' + COMPONENT_DEFS.components.length + ' components');
225
470
 
226
471
  // Pre-fetch all image srcs found in component definitions
227
- const imageSrcs = collectImageSrcs(COMPONENT_DEFS.components as any[]);
472
+ const imageSrcs = collectImageSrcs(COMPONENT_DEFS.components);
228
473
  if (imageSrcs.length > 0) {
474
+ await setStatus('Preloading images…');
229
475
  // Extract port from the source URL (e.g. 'http://localhost:3000/api/...')
230
476
  const portMatch = result.source?.match(/localhost:(\d+)/);
231
477
  const devPort = portMatch ? parseInt(portMatch[1], 10) : 3000;
@@ -237,14 +483,45 @@ async function runGenerate(): Promise<void> {
237
483
  rebuildColorIndex(TOKENS);
238
484
  createOrUpdateStyles();
239
485
 
240
- // Single page build to avoid page-limit errors on free plans
241
- buildDesignSystemSinglePage();
242
- figma.notify('✅ Built Design System page (tokens + pack)');
243
- } catch (e: any) {
244
- figma.notify('❌ Failed: ' + e.message);
486
+ // Show preflight selection UI before generation
487
+ await setStatus('Detecting changes…');
488
+ const tokenHash = hashString(stableStringify(TOKENS));
489
+ const preflightItems = computePreflightData(tokenHash);
490
+ const excluded = await loadExcludedComponents();
491
+ // Prepend the design-tokens toggle. Compute its status against the
492
+ // existing tokens row's frame-hash so the preflight UI reflects
493
+ // reality: 'new' when the row doesn't exist (e.g. after Clean
494
+ // Generated Artifacts), 'changed' when token values shifted since
495
+ // the last build, 'unchanged' when nothing changed. Previously this
496
+ // was hardcoded to 'new' which made every fresh run show "design
497
+ // tokens changed" even when token values were identical.
498
+ let tokensStatus: 'new' | 'changed' | 'unchanged' = 'new';
499
+ const dsPage = figma.root && Array.isArray(figma.root.children)
500
+ ? figma.root.children.find(function (p) { return p.name === 'Design System' && p.type === 'PAGE'; })
501
+ : null;
502
+ if (dsPage) {
503
+ const existingTokensRow = findChildByName(dsPage, 'Design Tokens');
504
+ if (existingTokensRow) {
505
+ tokensStatus = getFrameHash(existingTokensRow) === tokenHash ? 'unchanged' : 'changed';
506
+ }
507
+ }
508
+ preflightItems.unshift({
509
+ name: DESIGN_TOKENS_TOGGLE,
510
+ displayName: 'Design tokens',
511
+ status: tokensStatus,
512
+ section: '__system__',
513
+ sectionTitle: 'Design system',
514
+ });
515
+ clearStatus();
516
+ figma.showUI(__html__, { visible: true, width: 360, height: 520 });
517
+ figma.ui.postMessage({ type: 'show-preflight', items: preflightItems, excluded: excluded });
518
+ shouldClose = false;
519
+ } catch (e) {
520
+ clearStatus();
521
+ figma.notify('❌ Failed: ' + errorMessage(e));
245
522
  } finally {
246
523
  if (shouldClose) {
247
- await waitForAllFonts();
524
+ await waitForFontsBeforeClose();
248
525
  figma.closePlugin();
249
526
  }
250
527
  }
@@ -252,77 +529,6 @@ async function runGenerate(): Promise<void> {
252
529
 
253
530
  // ----------------------- Menu Command Handling -----------------------
254
531
 
255
- function debugSelection(): void {
256
- const selection = figma.currentPage.selection || [];
257
- if (selection.length === 0) {
258
- figma.notify('No selection. Select a node and run Debug Selection.');
259
- return;
260
- }
261
-
262
- const payload: any[] = [];
263
- for (const node of selection) {
264
- const entry: any = {
265
- id: node.id,
266
- name: node.name,
267
- type: node.type,
268
- width: (node as any).width,
269
- height: (node as any).height,
270
- };
271
-
272
- if ('layoutMode' in node) {
273
- entry.layoutMode = (node as any).layoutMode;
274
- entry.layoutWrap = (node as any).layoutWrap;
275
- entry.itemSpacing = (node as any).itemSpacing;
276
- entry.counterAxisSpacing = (node as any).counterAxisSpacing;
277
- entry.primaryAxisSizingMode = (node as any).primaryAxisSizingMode;
278
- entry.counterAxisSizingMode = (node as any).counterAxisSizingMode;
279
- entry.primaryAxisAlignItems = (node as any).primaryAxisAlignItems;
280
- entry.counterAxisAlignItems = (node as any).counterAxisAlignItems;
281
- entry.padding = {
282
- top: (node as any).paddingTop,
283
- right: (node as any).paddingRight,
284
- bottom: (node as any).paddingBottom,
285
- left: (node as any).paddingLeft,
286
- };
287
- }
288
-
289
- if ('layoutAlign' in node) {
290
- entry.layoutAlign = (node as any).layoutAlign;
291
- }
292
-
293
- if ('layoutGrow' in node) {
294
- entry.layoutGrow = (node as any).layoutGrow;
295
- }
296
-
297
- if ('layoutPositioning' in node) {
298
- entry.layoutPositioning = (node as any).layoutPositioning;
299
- }
300
-
301
- if ('children' in node) {
302
- const childSummary: any[] = [];
303
- const children = (node as any).children || [];
304
- for (let i = 0; i < children.length; i++) {
305
- const child = children[i];
306
- childSummary.push({
307
- name: child.name,
308
- type: child.type,
309
- width: child.width,
310
- height: child.height,
311
- layoutGrow: child.layoutGrow,
312
- layoutAlign: child.layoutAlign,
313
- layoutPositioning: child.layoutPositioning,
314
- });
315
- }
316
- entry.children = childSummary;
317
- }
318
-
319
- payload.push(entry);
320
- }
321
-
322
- console.log('[TailwindTokens][DebugSelection]', payload);
323
- figma.notify('Selection logged to console.');
324
- }
325
-
326
532
  async function handleCommand(command: string): Promise<void> {
327
533
  switch (command) {
328
534
  case 'push': {
@@ -367,8 +573,19 @@ async function handleCommand(command: string): Promise<void> {
367
573
  break;
368
574
  }
369
575
 
576
+ case 'clean-generated': {
577
+ const result = cleanGeneratedDesignSystemArtifacts();
578
+ if (result.removedNodes > 0) {
579
+ figma.notify('✅ Removed ' + result.removedNodes + ' generated node(s) across ' + result.touchedPages + ' page(s).');
580
+ } else {
581
+ figma.notify('No generated artifacts found.');
582
+ }
583
+ figma.closePlugin();
584
+ break;
585
+ }
586
+
370
587
  case 'debug-selection': {
371
- debugSelection();
588
+ runDebugSelectionCommand();
372
589
  figma.closePlugin();
373
590
  break;
374
591
  }
@@ -382,7 +599,46 @@ async function handleCommand(command: string): Promise<void> {
382
599
 
383
600
  // ----------------------- UI Message Handler -----------------------
384
601
 
602
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- UI postMessage payloads are untyped JSON
385
603
  figma.ui.onmessage = async (msg: any) => {
604
+ if (msg.type === 'confirm-preflight') {
605
+ const excluded: string[] = Array.isArray(msg.excluded) ? msg.excluded as string[] : [];
606
+ await saveExcludedComponents(excluded);
607
+ // The UI is already visible (the preflight view sent this message), so
608
+ // just switch to the empty loading view rather than re-invoking
609
+ // `showUI` — reinvocation triggers an iframe rerender that leaves the
610
+ // window blank until the UI finishes reloading. The loading view is
611
+ // intentionally empty; only the bottom bridge watermark stays visible
612
+ // while the toast handles phase feedback.
613
+ figma.ui.postMessage({ type: 'show-view', view: 'loading' });
614
+ // Let the UI process the view-switch before we block the main thread
615
+ // with the synchronous build work.
616
+ await new Promise(function (resolve) { setTimeout(resolve, 0); });
617
+ try {
618
+ await buildDesignSystemSinglePage(excluded, { onStatus: setStatus });
619
+ clearStatus();
620
+ figma.notify('✅ Design system created');
621
+ } catch (e) {
622
+ clearStatus();
623
+ const message = errorMessage(e);
624
+ const stack = e instanceof Error && e.stack ? e.stack : '';
625
+ if (stack) {
626
+ console.error('[Inkbridge][BuildFailed]', stack);
627
+ } else {
628
+ console.error('[Inkbridge][BuildFailed]', message);
629
+ }
630
+ figma.notify('❌ Build failed: ' + message, { timeout: 10000 });
631
+ }
632
+ await waitForFontsBeforeClose();
633
+ figma.closePlugin();
634
+ return;
635
+ }
636
+
637
+ if (msg.type === 'cancel-preflight') {
638
+ figma.closePlugin();
639
+ return;
640
+ }
641
+
386
642
  // Handle UI iframe ready signal
387
643
  if (msg.type === 'ui-ready') {
388
644
  handleUIReady();
@@ -392,6 +648,7 @@ figma.ui.onmessage = async (msg: any) => {
392
648
  // Handle license validation result from UI iframe
393
649
  if (msg.type === 'license-result') {
394
650
  const tier: 'free' | 'pro' = msg.tier === 'pro' ? 'pro' : 'free';
651
+ sessionTier = tier;
395
652
  await figma.clientStorage.setAsync('license_tier', tier);
396
653
  await figma.clientStorage.setAsync('license_cache_at', String(Date.now()));
397
654
  if (pendingLicenseResolve) {
@@ -444,8 +701,8 @@ figma.ui.onmessage = async (msg: any) => {
444
701
  setTimeout(() => {
445
702
  figma.ui.postMessage({ type: 'show-view', view: 'overview' });
446
703
  }, 1000);
447
- } catch (e: any) {
448
- figma.ui.postMessage({ type: 'config-status', message: 'Failed to save token: ' + e.message, success: false });
704
+ } catch (e) {
705
+ figma.ui.postMessage({ type: 'config-status', message: 'Failed to save token: ' + errorMessage(e), success: false });
449
706
  }
450
707
  }
451
708
 
@@ -456,7 +713,7 @@ figma.ui.onmessage = async (msg: any) => {
456
713
  repo: msg.repo || '',
457
714
  baseBranch: msg.baseBranch || 'main',
458
715
  tokenPath: msg.tokenPath || 'design-tokens/tokens.dtcg.json',
459
- tokenSourceMode: msg.tokenSourceMode || 'auto',
716
+ tokenSourceMode: msg.tokenSourceMode || 'css',
460
717
  cssTokenPath: msg.cssTokenPath || '',
461
718
  syncDtcgOnPush: msg.syncDtcgOnPush === true,
462
719
  allowNewTokensFromFigma: msg.allowNewTokensFromFigma === true,
@@ -486,22 +743,32 @@ figma.ui.onmessage = async (msg: any) => {
486
743
  figma.ui.postMessage({ type: 'show-view', view: 'overview' });
487
744
  }, 800);
488
745
  }
489
- } catch (e: any) {
490
- figma.ui.postMessage({ type: 'settings-status', message: 'Failed: ' + e.message, success: false });
746
+ } catch (e) {
747
+ figma.ui.postMessage({ type: 'settings-status', message: 'Failed: ' + errorMessage(e), success: false });
491
748
  }
492
749
  }
493
750
 
494
751
  if (msg.type === 'push-to-github') {
752
+ const pushTier = await checkLicense();
753
+ if (pushTier !== 'pro') {
754
+ figma.ui.postMessage({ type: 'show-view', view: 'upgrade' });
755
+ return;
756
+ }
495
757
  try {
496
758
  figma.ui.postMessage({ type: 'push-status', message: 'Creating branch...', success: null });
497
759
  const prUrl = await pushToGitHub(msg.commitMessage, msg.prDescription);
498
760
  figma.ui.postMessage({ type: 'push-status', message: 'PR created!', success: true, url: prUrl });
499
- } catch (e: any) {
500
- figma.ui.postMessage({ type: 'push-status', message: 'Failed: ' + e.message, success: false });
761
+ } catch (e) {
762
+ figma.ui.postMessage({ type: 'push-status', message: 'Failed: ' + errorMessage(e), success: false });
501
763
  }
502
764
  }
503
765
 
504
766
  if (msg.type === 'detect-changes') {
767
+ const detectTier = await checkLicense();
768
+ if (detectTier !== 'pro') {
769
+ figma.ui.postMessage({ type: 'show-view', view: 'upgrade' });
770
+ return;
771
+ }
505
772
  try {
506
773
  await loadConfig();
507
774
  const pushToken = await figma.clientStorage.getAsync('github_token') as string | null;
@@ -532,16 +799,16 @@ figma.ui.onmessage = async (msg: any) => {
532
799
 
533
800
  // Detect changes between Figma and code
534
801
  const changes = detectComponentChanges();
535
- if ((changes as any).error) {
802
+ if (changes.error) {
536
803
  figma.showUI(__html__, { width: 360, height: 520 });
537
804
  figma.ui.postMessage({ type: 'show-view', view: 'sync' });
538
805
  figma.ui.postMessage({ type: 'config-loaded', config: GITHUB_CONFIG, hasToken: !!pushToken });
539
806
  await postTokenSourceInfoToUI();
540
807
  figma.ui.postMessage({ type: 'changes-detected', changes: { tokens: false, components: [] } });
541
- figma.ui.postMessage({ type: 'sync-status', message: String((changes as any).error), success: false });
808
+ figma.ui.postMessage({ type: 'sync-status', message: String(changes.error), success: false });
542
809
  return;
543
810
  }
544
- const componentChanges = Array.isArray((changes as any).changes) ? (changes as any).changes : [];
811
+ const componentChanges = Array.isArray(changes.changes) ? changes.changes : [];
545
812
  const tokenPreview = await previewTokenChanges(pushToken);
546
813
  const hasTokenChanges = tokenPreview.hasChanges;
547
814
 
@@ -565,14 +832,19 @@ figma.ui.onmessage = async (msg: any) => {
565
832
  figma.ui.postMessage({ type: 'config-loaded', config: GITHUB_CONFIG, hasToken: !!pushToken });
566
833
  await postTokenSourceInfoToUI();
567
834
  figma.ui.postMessage({ type: 'changes-detected', changes: { tokens: hasTokenChanges, components: componentChanges } });
568
- } catch (e: any) {
835
+ } catch (e) {
569
836
  figma.showUI(__html__, { width: 360, height: 520 });
570
837
  figma.ui.postMessage({ type: 'show-view', view: 'sync' });
571
- figma.ui.postMessage({ type: 'sync-status', message: 'Failed: ' + e.message, success: false });
838
+ figma.ui.postMessage({ type: 'sync-status', message: 'Failed: ' + errorMessage(e), success: false });
572
839
  }
573
840
  }
574
841
 
575
842
  if (msg.type === 'sync-to-github') {
843
+ const syncTier = await checkLicense();
844
+ if (syncTier !== 'pro') {
845
+ figma.ui.postMessage({ type: 'show-view', view: 'upgrade' });
846
+ return;
847
+ }
576
848
  try {
577
849
  figma.notify('Sync request received', { timeout: 1200 });
578
850
  figma.ui.postMessage({ type: 'sync-status', message: 'Starting sync...', success: null });
@@ -586,13 +858,18 @@ figma.ui.onmessage = async (msg: any) => {
586
858
  }
587
859
  );
588
860
  figma.ui.postMessage({ type: 'sync-status', message: 'PR created!', success: true, url: prUrl });
589
- } catch (e: any) {
590
- figma.notify('Sync failed: ' + e.message, { timeout: 2500 });
591
- figma.ui.postMessage({ type: 'sync-status', message: 'Failed: ' + e.message, success: false });
861
+ } catch (e) {
862
+ figma.notify('Sync failed: ' + errorMessage(e), { timeout: 2500 });
863
+ figma.ui.postMessage({ type: 'sync-status', message: 'Failed: ' + errorMessage(e), success: false });
592
864
  }
593
865
  }
594
866
 
595
867
  if (msg.type === 'show-push') {
868
+ const showPushTier = await checkLicense();
869
+ if (showPushTier !== 'pro') {
870
+ figma.ui.postMessage({ type: 'show-view', view: 'upgrade' });
871
+ return;
872
+ }
596
873
  await loadConfig();
597
874
  const hasPushToken = await figma.clientStorage.getAsync('github_token');
598
875
  figma.ui.postMessage({ type: 'show-view', view: 'push' });
@@ -632,15 +909,20 @@ figma.ui.onmessage = async (msg: any) => {
632
909
  }
633
910
  rebuildColorIndex(TOKENS);
634
911
  createOrUpdateStyles();
635
- buildDesignSystemSinglePage();
636
- figma.notify('✅ Built Design System page');
637
- await waitForAllFonts();
912
+ await buildDesignSystemSinglePage();
913
+ figma.notify('✅ Design system created');
914
+ await waitForFontsBeforeClose();
638
915
  figma.closePlugin();
639
916
  }
640
917
 
641
918
  if (msg.type === 'resize') {
642
919
  const h = typeof msg.height === 'number' ? Math.max(80, msg.height) : 400;
643
- figma.ui.resize(320, h);
920
+ // Preserve the current panel width — different flows show the panel
921
+ // at different widths (320 for settings/configure, 360 for loading /
922
+ // preflight / sync). The UI passes its viewport width so we don't
923
+ // snap to a hardcoded value and cause a horizontal jump.
924
+ const w = typeof msg.width === 'number' ? Math.max(120, msg.width) : 320;
925
+ figma.ui.resize(w, h);
644
926
  }
645
927
 
646
928
  if (msg.type === 'show-settings') {
@@ -665,14 +947,14 @@ if (figma.codegen && typeof figma.codegen.on === 'function') {
665
947
  // Initialize color index for codegen
666
948
  rebuildColorIndex(TOKENS);
667
949
 
668
- figma.codegen.on('generate', (event: any) => {
950
+ figma.codegen.on('generate', (event: { node: SceneNode }) => {
669
951
  try {
670
952
  const node = event.node;
671
953
  const classes = tailwindForNode(node);
672
954
  const code = 'className="' + classes + '"';
673
955
  return [{ title: 'Tailwind CSS', code, language: 'PLAINTEXT' }];
674
- } catch (e: any) {
675
- return [{ title: 'Tailwind CSS', code: '// Unable to generate: ' + e.message, language: 'PLAINTEXT' }];
956
+ } catch (e) {
957
+ return [{ title: 'Tailwind CSS', code: '// Unable to generate: ' + errorMessage(e), language: 'PLAINTEXT' }];
676
958
  }
677
959
  });
678
960
  }