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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -0,0 +1,151 @@
1
+ import { applyTruncation, readTruncationMark } from '../text/text-truncate';
2
+
3
+ /**
4
+ * Deferred text-truncation pass.
5
+ *
6
+ * Run by any layout step that resolves a container's content width
7
+ * *after* its children have been built — e.g. the table column-
8
+ * alignment pass, or any future deferred-layout transform whose
9
+ * children include text whose width depends on a parent that wasn't
10
+ * sized at render time.
11
+ *
12
+ * At render time, the per-element handler marks each TextNode that
13
+ * came from a `truncate` / `line-clamp-N` source via
14
+ * `markForTruncation`. This pass walks `container`'s subtree, finds
15
+ * those marked nodes, and applies the actual `textTruncation =
16
+ * 'ENDING'` + `maxLines` + `resize` once the parent's width is known.
17
+ *
18
+ * The container's *content* width (`width - paddingLeft -
19
+ * paddingRight`) is the budget. As we descend through intermediate
20
+ * frames, each frame's own paddings shrink the budget further before
21
+ * reaching its children — so a deeply-nested text node sees its true
22
+ * available width.
23
+ *
24
+ * Skips text nodes that already fit. Idempotent (a second invocation
25
+ * is a no-op for already-truncated nodes that still fit).
26
+ */
27
+ export function applyDeferredTruncationPass(container: SceneNode): number {
28
+ if (!('children' in container) || !('width' in container)) return 0;
29
+ if (!Number.isFinite(container.width) || container.width <= 0) return 0;
30
+
31
+ const padLeft = ('paddingLeft' in container ? (container.paddingLeft || 0) : 0);
32
+ const padRight = ('paddingRight' in container ? (container.paddingRight || 0) : 0);
33
+ const budget = container.width - padLeft - padRight;
34
+ if (!(budget > 0)) return 0;
35
+
36
+ const counter = { applied: 0, marksSeen: 0 };
37
+ for (const child of container.children) {
38
+ walk(child, budget, 0, counter, container);
39
+ }
40
+ return counter.applied;
41
+ }
42
+
43
+ function walk(
44
+ node: SceneNode,
45
+ budget: number,
46
+ consumed: number,
47
+ counter: { applied: number; marksSeen: number },
48
+ container: SceneNode,
49
+ ): void {
50
+ if (node.type === 'TEXT') {
51
+ const maxLines = readTruncationMark(node);
52
+ if (maxLines == null) return;
53
+ counter.marksSeen += 1;
54
+ const available = budget - consumed;
55
+ if (!(available > 0)) return;
56
+ if (node.width > available + 0.5) {
57
+ const lineHeight = extractLineHeightPixels(node);
58
+ applyTruncation(node, available, maxLines, lineHeight);
59
+ counter.applied += 1;
60
+ // Walk up from the truncated text and shrink any FIXED-width
61
+ // ancestor frame that overflows the container. Without this
62
+ // step the text is resized to fit but its wrapping <span>
63
+ // frame stays at its own `max-w-[N]` width — visually the
64
+ // span (and thus the text inside) still overflows the cell.
65
+ // Equivalent to CSS shrinking the block-level child to match
66
+ // its parent's content area.
67
+ shrinkOverflowingAncestors(node, container);
68
+ }
69
+ return;
70
+ }
71
+ if (!('children' in node)) return;
72
+ // Children of this node consume its paddings before reaching their
73
+ // own content area. Gaps between siblings on a HORIZONTAL row are
74
+ // intentionally NOT subtracted — the budget is for any *one* text
75
+ // node along the path; the actual per-cell allowance is enforced by
76
+ // the table-layout pass itself when it resizes each cell first.
77
+ let nextConsumed = consumed;
78
+ if ('paddingLeft' in node) {
79
+ nextConsumed += (node.paddingLeft || 0) + (node.paddingRight || 0);
80
+ }
81
+ for (const child of node.children) {
82
+ walk(child, budget, nextConsumed, counter, container);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * After a marked TextNode is truncated to its allotted width, walk
88
+ * from it up to (but not including) `container` and shrink any
89
+ * ancestor frame currently wider than the space it has available
90
+ * inside `container`'s content box.
91
+ *
92
+ * Necessary because Figma auto-layout doesn't automatically reflow
93
+ * an explicit-width child when its parent shrinks — the parent was
94
+ * column-aligned to a smaller size *after* the child was originally
95
+ * rendered at its `max-w-[N]` natural width. Without this, the
96
+ * text resizes correctly but its wrapping `<span>` keeps its 152 px
97
+ * width inside a 100 px cell, and visually nothing changes.
98
+ *
99
+ * Mirrors the descending budget the `walk` uses, just in reverse.
100
+ */
101
+ function shrinkOverflowingAncestors(textNode: TextNode, container: SceneNode): void {
102
+ // Collect outermost-first chain `[container's child, …, textNode.parent]`.
103
+ const chain: SceneNode[] = [];
104
+ let cur: BaseNode | null = textNode.parent;
105
+ while (cur && cur !== container) {
106
+ chain.unshift(cur as SceneNode);
107
+ cur = cur.parent;
108
+ }
109
+ if (cur !== container) return; // textNode not actually a descendant
110
+
111
+ const padL = ('paddingLeft' in container ? ((container as FrameNode).paddingLeft || 0) : 0);
112
+ const padR = ('paddingRight' in container ? ((container as FrameNode).paddingRight || 0) : 0);
113
+ let budget = ('width' in container ? (container as FrameNode).width : 0) - padL - padR;
114
+ if (!(budget > 0)) return;
115
+
116
+ for (const frame of chain) {
117
+ if ('width' in frame && Number.isFinite((frame as FrameNode).width) && (frame as FrameNode).width > budget + 0.5) {
118
+ try {
119
+ if ('resize' in frame && typeof (frame as FrameNode).resize === 'function') {
120
+ (frame as FrameNode).resize(budget, (frame as FrameNode).height);
121
+ }
122
+ } catch (_err) {
123
+ // ignore — some node types refuse resize
124
+ }
125
+ }
126
+ if ('paddingLeft' in frame) {
127
+ budget -= ((frame as FrameNode).paddingLeft || 0) + ((frame as FrameNode).paddingRight || 0);
128
+ if (!(budget > 0)) return;
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Best-effort resolution of a TextNode's effective line-height in
135
+ * pixels. Used to size the post-truncation frame height so the cell
136
+ * doesn't snap to a different visual baseline. Falls back to
137
+ * `fontSize × 1.5` when the value isn't directly readable (mixed
138
+ * runs, AUTO unit), matching Tailwind's `leading-normal` default and
139
+ * what the immediate-truncation path used before this util existed.
140
+ */
141
+ function extractLineHeightPixels(textNode: TextNode): number | undefined {
142
+ const lh = textNode.lineHeight;
143
+ if (lh && typeof lh === 'object' && 'unit' in lh) {
144
+ if (lh.unit === 'PIXELS' && typeof lh.value === 'number') return lh.value;
145
+ if (lh.unit === 'PERCENT' && typeof lh.value === 'number') {
146
+ const fs = typeof textNode.fontSize === 'number' ? textNode.fontSize : 14;
147
+ return Math.ceil(fs * (lh.value / 100));
148
+ }
149
+ }
150
+ return undefined;
151
+ }
@@ -162,29 +162,75 @@ export function extractFixedWidth(classes: string[] | undefined): number | null
162
162
  return null;
163
163
  }
164
164
 
165
+ const MAX_WIDTH_PX: Record<string, number> = {
166
+ 'max-w-xs': 320,
167
+ 'max-w-sm': 384,
168
+ 'max-w-md': 448,
169
+ 'max-w-lg': 512,
170
+ 'max-w-xl': 576,
171
+ 'max-w-2xl': 672,
172
+ 'max-w-3xl': 768,
173
+ 'max-w-4xl': 896,
174
+ 'max-w-5xl': 1024,
175
+ 'max-w-6xl': 1152,
176
+ 'max-w-7xl': 1280,
177
+ };
178
+
179
+ // Tailwind breakpoint variants in ascending order. The story preview is
180
+ // rendered "desktop-first" (default Story Layout width ~900px), so a
181
+ // responsive class like `sm:max-w-lg` IS active and should constrain the
182
+ // preview. When multiple breakpoint variants of `max-w-*` are present,
183
+ // the one with the LARGEST active breakpoint wins (matches CSS cascade
184
+ // at a desktop viewport).
185
+ const RESPONSIVE_BREAKPOINT_RANK: Record<string, number> = {
186
+ 'sm': 1,
187
+ 'md': 2,
188
+ 'lg': 3,
189
+ 'xl': 4,
190
+ '2xl': 5,
191
+ };
192
+
193
+ function parseBracketMaxWidth(cls: string): number | null {
194
+ const bracket = cls.match(/^max-w-\[(\d+(?:\.\d+)?)px\]$/);
195
+ return bracket ? parseFloat(bracket[1]) : null;
196
+ }
197
+
165
198
  export function extractMaxWidth(classes: string[] | undefined): number | null {
166
199
  if (!classes) return null;
167
- const map: Record<string, number> = {
168
- 'max-w-xs': 320,
169
- 'max-w-sm': 384,
170
- 'max-w-md': 448,
171
- 'max-w-lg': 512,
172
- 'max-w-xl': 576,
173
- 'max-w-2xl': 672,
174
- 'max-w-3xl': 768,
175
- 'max-w-4xl': 896,
176
- 'max-w-5xl': 1024,
177
- 'max-w-6xl': 1152,
178
- 'max-w-7xl': 1280,
179
- };
200
+ // Pass 1: base (unprefixed) max-w-*. Wins over any variant-prefixed
201
+ // value because it applies at every breakpoint.
180
202
  for (const cls of classes) {
181
203
  const base = getBaseClass(cls);
182
204
  if (!base) continue;
183
- if (map[base]) return map[base];
184
- const bracket = base.match(/^max-w-\[(\d+(?:\.\d+)?)px\]$/);
185
- if (bracket) return parseFloat(bracket[1]);
205
+ if (MAX_WIDTH_PX[base]) return MAX_WIDTH_PX[base];
206
+ const bracket = parseBracketMaxWidth(base);
207
+ if (bracket != null) return bracket;
186
208
  }
187
- return null;
209
+ // Pass 2: variant-prefixed max-w-* (`sm:max-w-lg`, `md:max-w-xl`, …).
210
+ // Pick the LARGEST active breakpoint variant — at the desktop-default
211
+ // preview width all responsive utilities up to xl are active, so the
212
+ // latest matching breakpoint wins (CSS cascade at wide viewport).
213
+ // Real-world trigger: shadcn DialogContent uses `sm:max-w-lg` to cap
214
+ // dialog width at 512px — without this fallback the story preview
215
+ // ignores it and renders the dialog at the 900px generic Story Layout
216
+ // fallback, which makes the form fields look stretched.
217
+ let bestRank = 0;
218
+ let bestWidth: number | null = null;
219
+ for (const cls of classes) {
220
+ if (!cls || cls.indexOf(':') === -1) continue;
221
+ const idx = cls.lastIndexOf(':');
222
+ const prefix = cls.slice(0, idx);
223
+ const base = cls.slice(idx + 1);
224
+ const rank = RESPONSIVE_BREAKPOINT_RANK[prefix];
225
+ if (!rank) continue;
226
+ const px = MAX_WIDTH_PX[base] ?? parseBracketMaxWidth(base);
227
+ if (px == null) continue;
228
+ if (rank > bestRank) {
229
+ bestRank = rank;
230
+ bestWidth = px;
231
+ }
232
+ }
233
+ return bestWidth;
188
234
  }
189
235
 
190
236
  export function extractGridColumns(classes: string[] | undefined, availableWidth?: number): number | null {
package/src/main.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // Universal plugin - configure for any project via Settings.
5
5
 
6
6
  import { loadConfig, saveConfig, GITHUB_CONFIG } from './plugin';
7
- import { TOKENS, COMPONENT_DEFS, applyScannedTokens, getCoreFontFamily, getThemeFontFamily, getThemeNames } from './tokens';
7
+ import { TOKENS, COMPONENT_DEFS, applyScannedTokens, applyConsumerOwnedTokens, getCoreFontFamily, getThemeFontFamily, getThemeNames, isConsumerOwnedToken } from './tokens';
8
8
  import { rebuildColorIndex } from './tokens';
9
9
  import { handleUIReady, handleImageResult, prefetchImages } from './plugin';
10
10
  import { setImageMap, setSvgMap } from './cache';
@@ -17,7 +17,6 @@ import {
17
17
  buildDesignSystemSinglePage,
18
18
  cleanGeneratedDesignSystemArtifacts,
19
19
  computePreflightData,
20
- isGeneratedDesignSystemNode,
21
20
  } from './design-system';
22
21
  import { hashString, stableStringify, getFrameHash, findChildByName } from './cache';
23
22
  import { waitForAllFonts } from './text';
@@ -129,21 +128,6 @@ async function saveExcludedComponents(excluded: string[]): Promise<void> {
129
128
  } catch (_e) {}
130
129
  }
131
130
  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
- ];
147
131
 
148
132
  type TokenSourceInfo = {
149
133
  source: string;
@@ -153,106 +137,6 @@ type TokenSourceInfo = {
153
137
 
154
138
  let LAST_TOKEN_SOURCE_INFO: TokenSourceInfo | null = null;
155
139
 
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
-
256
140
  function getPackLoadErrorMessage(error?: string): string {
257
141
  if (error === 'incompatible-schema-version' || error === 'incompatible-pack-version') {
258
142
  return 'Incompatible scanner contract. Update plugin and scanner to matching versions.';
@@ -354,12 +238,39 @@ async function warmThemeFonts(): Promise<string[]> {
354
238
  fontsSet.add(baseFont);
355
239
  fontsSet.add('Inter');
356
240
 
241
+ // Walk every font role the consumer defined on each theme — `sans`,
242
+ // `mono`, `serif`, `heading`, plus any custom roles (`display`, `brand`,
243
+ // …). Pre-loading them all here means the render-time
244
+ // `setRangeFontName` finds the font ready and never silently falls back
245
+ // to Inter when the consumer uses a non-default role name.
246
+ const tokensRecord = TOKENS as unknown as Record<string, Record<string, unknown> | undefined>;
357
247
  for (let i = 0; i < themeNames.length; i++) {
358
248
  const theme = themeNames[i];
249
+ // Always include `sans` and `heading` (fallback to base) — these are
250
+ // the canonical body / display surfaces and must resolve even when
251
+ // the consumer left them unset.
359
252
  const sansFont = getThemeFontFamily(TOKENS, theme, 'sans') || baseFont;
360
253
  const headingFont = getThemeFontFamily(TOKENS, theme, 'heading') || sansFont || baseFont;
361
254
  if (sansFont) fontsSet.add(sansFont);
362
255
  if (headingFont) fontsSet.add(headingFont);
256
+
257
+ // Then every other role the consumer actually defined. Filter via
258
+ // `isConsumerOwnedToken` — same predicate the Custom Tokens panel
259
+ // uses — so we skip roles leaked from Tailwind defaults (most
260
+ // notoriously `serif: ui-serif, Georgia, Cambria, …` pulled in by
261
+ // `@import "tailwindcss"`). Without the filter Figma surfaces a
262
+ // "Font(s) not found: Cambria" warning even when the consumer
263
+ // doesn't use a serif anywhere.
264
+ const themeBlock = tokensRecord[theme];
265
+ const fontGroup = themeBlock && typeof themeBlock === 'object' ? themeBlock.font : null;
266
+ if (fontGroup && typeof fontGroup === 'object') {
267
+ for (const role of Object.keys(fontGroup)) {
268
+ if (role === 'sans' || role === 'heading') continue; // already preloaded above
269
+ if (!isConsumerOwnedToken(theme, 'font', role)) continue;
270
+ const family = getThemeFontFamily(TOKENS, theme, role);
271
+ if (family) fontsSet.add(family);
272
+ }
273
+ }
363
274
  }
364
275
 
365
276
  const fontsToLoad = Array.from(fontsSet);
@@ -440,6 +351,7 @@ async function runGenerate(): Promise<void> {
440
351
 
441
352
  applyPack(result.pack);
442
353
  applyScannedTokens(result.pack.tokens);
354
+ applyConsumerOwnedTokens(result.pack.consumerTokens);
443
355
  await saveAndBroadcastTokenSourceInfo(getTokenSourceInfoFromPack(result.pack));
444
356
 
445
357
  // Empty-state guard: the dev server is reachable but the scan
@@ -500,7 +412,11 @@ async function runGenerate(): Promise<void> {
500
412
  ? figma.root.children.find(function (p) { return p.name === 'Design System' && p.type === 'PAGE'; })
501
413
  : null;
502
414
  if (dsPage) {
503
- const existingTokensRow = findChildByName(dsPage, 'Design Tokens');
415
+ // Look for the renamed frame first; fall back to the legacy
416
+ // "Design Tokens" name so files created by older plugin versions
417
+ // still report tokens-row status correctly until migration.
418
+ const existingTokensRow = findChildByName(dsPage, 'Custom Tokens')
419
+ || findChildByName(dsPage, 'Design Tokens');
504
420
  if (existingTokensRow) {
505
421
  tokensStatus = getFrameHash(existingTokensRow) === tokenHash ? 'unchanged' : 'changed';
506
422
  }
@@ -584,12 +500,6 @@ async function handleCommand(command: string): Promise<void> {
584
500
  break;
585
501
  }
586
502
 
587
- case 'debug-selection': {
588
- runDebugSelectionCommand();
589
- figma.closePlugin();
590
- break;
591
- }
592
-
593
503
  case 'generate':
594
504
  default:
595
505
  await runGenerate();
@@ -719,6 +629,7 @@ figma.ui.onmessage = async (msg: any) => {
719
629
  allowNewTokensFromFigma: msg.allowNewTokensFromFigma === true,
720
630
  newTokenPrefixes: Array.isArray(msg.newTokenPrefixes) ? msg.newTokenPrefixes : [],
721
631
  projectName: msg.projectName || '',
632
+ devServerPort: typeof msg.devServerPort === 'string' ? msg.devServerPort : '',
722
633
  };
723
634
  await saveConfig(newConfig);
724
635
 
@@ -795,6 +706,7 @@ figma.ui.onmessage = async (msg: any) => {
795
706
  }
796
707
  applyPack(result.pack);
797
708
  applyScannedTokens(result.pack.tokens);
709
+ applyConsumerOwnedTokens(result.pack.consumerTokens);
798
710
  await saveAndBroadcastTokenSourceInfo(getTokenSourceInfoFromPack(result.pack));
799
711
 
800
712
  // Detect changes between Figma and code
@@ -902,6 +814,7 @@ figma.ui.onmessage = async (msg: any) => {
902
814
  }
903
815
  applyPack(retryResult.pack);
904
816
  applyScannedTokens(retryResult.pack.tokens);
817
+ applyConsumerOwnedTokens(retryResult.pack.consumerTokens);
905
818
  await saveAndBroadcastTokenSourceInfo(getTokenSourceInfoFromPack(retryResult.pack));
906
819
  const failedFonts = await warmThemeFonts();
907
820
  if (failedFonts.length) {
@@ -13,6 +13,15 @@ export interface ProjectConfig {
13
13
  allowNewTokensFromFigma: boolean;
14
14
  newTokenPrefixes: string[];
15
15
  projectName: string;
16
+ /**
17
+ * Optional dev-server port override. Empty string means auto-discover
18
+ * across the standard Next.js / Vite ports (3000 / 4000 / 5173). Set
19
+ * this when you're running multiple local projects on different ports
20
+ * and the auto-discovery picks the wrong one. Stored as a string so
21
+ * the input round-trips unchanged; validated to a 1-65535 numeric on
22
+ * normalise.
23
+ */
24
+ devServerPort: string;
16
25
  }
17
26
 
18
27
  // Default GitHub configuration (can be overridden via Settings)
@@ -27,6 +36,7 @@ export const DEFAULT_CONFIG: ProjectConfig = {
27
36
  allowNewTokensFromFigma: false,
28
37
  newTokenPrefixes: [],
29
38
  projectName: 'My Project',
39
+ devServerPort: '',
30
40
  };
31
41
 
32
42
  // Runtime config - loaded from storage
@@ -60,6 +70,15 @@ function normalizeAllowNewTokensFromFigma(value: unknown): boolean {
60
70
  return value === true;
61
71
  }
62
72
 
73
+ function normalizeDevServerPort(value: unknown): string {
74
+ if (typeof value !== 'string' && typeof value !== 'number') return '';
75
+ const raw = String(value).trim();
76
+ if (!raw) return '';
77
+ const n = parseInt(raw, 10);
78
+ if (!Number.isFinite(n) || n < 1 || n > 65535) return '';
79
+ return String(n);
80
+ }
81
+
63
82
  function normalizeNewTokenPrefixes(value: unknown): string[] {
64
83
  if (Array.isArray(value)) {
65
84
  return value
@@ -91,6 +110,7 @@ export async function loadConfig(): Promise<ProjectConfig> {
91
110
  syncDtcgOnPush: normalizeSyncDtcgOnPush(storedConfig.syncDtcgOnPush),
92
111
  allowNewTokensFromFigma: normalizeAllowNewTokensFromFigma(storedConfig.allowNewTokensFromFigma),
93
112
  newTokenPrefixes: normalizeNewTokenPrefixes(storedConfig.newTokenPrefixes),
113
+ devServerPort: normalizeDevServerPort(storedConfig.devServerPort),
94
114
  };
95
115
  // Persist one-time migrations so old installs stop carrying stale settings.
96
116
  await figma.clientStorage.setAsync('project_config', GITHUB_CONFIG);
@@ -110,6 +130,7 @@ export async function saveConfig(config: ProjectConfig): Promise<void> {
110
130
  config.syncDtcgOnPush = normalizeSyncDtcgOnPush(config.syncDtcgOnPush);
111
131
  config.allowNewTokensFromFigma = normalizeAllowNewTokensFromFigma(config.allowNewTokensFromFigma);
112
132
  config.newTokenPrefixes = normalizeNewTokenPrefixes(config.newTokenPrefixes);
133
+ config.devServerPort = normalizeDevServerPort(config.devServerPort);
113
134
  GITHUB_CONFIG = config;
114
135
  await figma.clientStorage.setAsync('project_config', config);
115
136
  }
@@ -11,10 +11,25 @@ type PackFetchResult = {
11
11
  error?: string;
12
12
  };
13
13
 
14
- type PackFetchConfig = Pick<ProjectConfig, 'tokenPath' | 'tokenSourceMode' | 'cssTokenPath'>;
14
+ type PackFetchConfig = Pick<ProjectConfig, 'tokenPath' | 'tokenSourceMode' | 'cssTokenPath' | 'devServerPort'>;
15
15
 
16
- const PACK_PORTS = [3000, 4000, 5173];
16
+ const DEFAULT_PACK_PORTS = [3000, 4000, 5173];
17
17
  const PACK_PATHS = ['api/inkbridge/scan-components'];
18
+
19
+ /**
20
+ * Returns the ports to try when fetching the component pack. A
21
+ * configured `devServerPort` (string, validated upstream by the config
22
+ * normaliser) collapses the list to that single port — useful when the
23
+ * user runs multiple local projects and wants to pin the scanner to
24
+ * the right one. Empty / missing falls back to the default sweep.
25
+ */
26
+ function getPackPorts(devServerPort?: string): number[] {
27
+ const trimmed = (devServerPort || '').trim();
28
+ if (!trimmed) return DEFAULT_PACK_PORTS;
29
+ const n = parseInt(trimmed, 10);
30
+ if (!Number.isFinite(n) || n < 1 || n > 65535) return DEFAULT_PACK_PORTS;
31
+ return [n];
32
+ }
18
33
  const SUPPORTED_SCHEMA_VERSION = 1;
19
34
  const SUPPORTED_COMPONENT_DEFS_MAJOR = 1;
20
35
 
@@ -71,8 +86,9 @@ function validateComponentDefsContract(raw: unknown): { ok: true } | { ok: false
71
86
 
72
87
  function buildPackCandidates(config?: PackFetchConfig): string[] {
73
88
  const urls: string[] = [];
74
- for (let i = 0; i < PACK_PORTS.length; i++) {
75
- const port = PACK_PORTS[i];
89
+ const ports = getPackPorts(config?.devServerPort);
90
+ for (let i = 0; i < ports.length; i++) {
91
+ const port = ports[i];
76
92
  for (let j = 0; j < PACK_PATHS.length; j++) {
77
93
  const path = PACK_PATHS[j];
78
94
  if (!path) continue;
@@ -19,7 +19,16 @@ export type Pack = {
19
19
  name: string;
20
20
  version?: string;
21
21
  components: ComponentDefs;
22
+ // Full token map (consumer + imported Tailwind defaults). Used by the
23
+ // runtime token resolver so utilities like `text-sm` keep working when
24
+ // the consumer relies on Tailwind's defaults.
22
25
  tokens?: ScannedTokenMap;
26
+ // Consumer-only subset of `tokens` — only what the consumer wrote in
27
+ // their own files. Used by the Design Tokens panel to avoid showing
28
+ // leaked Tailwind defaults (`serif: Cambria`, the lone spacing
29
+ // baseline, etc.). When absent (older scanner output / DTCG mode),
30
+ // the panel falls back to `tokens` and may show defaults.
31
+ consumerTokens?: ScannedTokenMap;
23
32
  stories?: StoryDefinition[];
24
33
  tags?: string[];
25
34
  };
@@ -102,6 +111,11 @@ export function normalizePack(raw: unknown, fallbackId: string): Pack | null {
102
111
  version: typeof raw.version === 'string' ? raw.version : undefined,
103
112
  components: normalizeComponentDefs(componentDefs),
104
113
  tokens: normalizeTokenMap(raw.tokens),
114
+ // CLI emits `consumerTokens` alongside `tokens` (see scanner/cli.ts).
115
+ // Older scanner output won't have this field — `normalizeTokenMap`
116
+ // handles undefined safely and the panel falls back to `tokens` in
117
+ // that case.
118
+ consumerTokens: raw.consumerTokens ? normalizeTokenMap(raw.consumerTokens) : undefined,
105
119
  stories: Array.isArray(raw.stories) ? raw.stories : undefined,
106
120
  tags: Array.isArray(raw.tags) ? raw.tags : undefined,
107
121
  };
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build.mjs. Do not edit manually.
2
- export const RENDER_ENGINE_VERSION = '011e89524e15bfda';
2
+ export const RENDER_ENGINE_VERSION = '7a5be23936c74399';
@@ -163,6 +163,45 @@ export function extractLeadingContainerMaxWidthFromTree(node: JsxNode | undefine
163
163
  return constrainedWidth;
164
164
  }
165
165
 
166
+ /**
167
+ * Find any `max-w-*` declaration anywhere in the tree (depth-first walk),
168
+ * regardless of whether the element also has `w-full` or how many siblings
169
+ * it has. Returns the SMALLEST max-w-* found — the most restrictive
170
+ * constraint anywhere in the tree wins as the story-width hint.
171
+ *
172
+ * Use case: the story-layout fallback needs SOMETHING smaller than the
173
+ * generic 900px default when a component declares its intended max width
174
+ * via Tailwind. `extractLeadingContainerMaxWidthFromTree` is too
175
+ * conservative — it requires `w-full` (the "fill parent up to max"
176
+ * idiom) and bails on multi-child branches. A modal like shadcn
177
+ * DialogContent has neither: it's wrapped by `<Dialog>` (multi-child
178
+ * with overlay) and doesn't carry `w-full` itself. So we lose the
179
+ * `sm:max-w-lg` signal and the story renders at 900px instead of 512px.
180
+ *
181
+ * This helper is the looser fallback for that case — any `max-w-*`
182
+ * anywhere counts. Doesn't replace the leading-container helper (which
183
+ * is correct for its own use case); just supplements it.
184
+ */
185
+ export function findAnyMaxWidthInTree(node: JsxNode | undefined): number | null {
186
+ if (!node) return null;
187
+ let best: number | null = null;
188
+ function walk(n: JsxNode): void {
189
+ if (!n || n.type !== 'element') return;
190
+ const element = n as JsxElement;
191
+ const className = element.props && element.props.className ? String(element.props.className) : '';
192
+ if (className) {
193
+ const classes = splitClassName(className);
194
+ const maxWidth = extractMaxWidth(classes);
195
+ if (maxWidth != null) {
196
+ best = best == null ? maxWidth : Math.min(best, maxWidth);
197
+ }
198
+ }
199
+ for (const child of element.children || []) walk(child);
200
+ }
201
+ walk(node);
202
+ return best;
203
+ }
204
+
166
205
  /**
167
206
  * Propagate child-wildcard utility classes (`*:X`, `sm:*:X`, etc.) from a node
168
207
  * to all of its direct element children, then remove them from the parent.
@@ -348,7 +348,14 @@ function buildRangeSliderTree(input: NodeIRElement): NodeIR {
348
348
  const rawValue = props.value ?? props.defaultValue;
349
349
  const pcts = resolveValuePercents(rawValue, props.min, props.max);
350
350
  const pct = pcts[0] ?? 0;
351
- const isDisabled = props.disabled !== undefined && props.disabled !== 'false';
351
+ // Treat ONLY explicit-truthy literals as disabled. Consumers commonly write
352
+ // `disabled={disabled}` where the React var is undefined; the scanner can't
353
+ // statically resolve the identifier and emits its text (e.g. "disabled") as
354
+ // the prop value. The previous `!== 'false'` check accepted any non-"false"
355
+ // string and dimmed every slider at opacity 50% by mistake. Only `true`,
356
+ // `'true'`, `'1'`, or JSX shorthand (scanner emits `'true'`) should count.
357
+ const disabledRaw = props.disabled;
358
+ const isDisabled = disabledRaw === 'true' || disabledRaw === '1';
352
359
 
353
360
  // Preserve sizing-related classes from the consumer (`w-full`, `w-[Npx]`,
354
361
  // `max-w-*`) onto the wrapper. Visual / state classes that applied to the