inkbridge 0.1.0-beta.21 → 0.1.0-beta.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- package/ui.html +144 -43
|
@@ -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
|
-
|
|
168
|
-
|
|
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 (
|
|
184
|
-
const bracket = base
|
|
185
|
-
if (bracket) return
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/plugin/config.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
75
|
-
|
|
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 = '
|
|
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.
|
package/src/tailwind/node-ir.ts
CHANGED
|
@@ -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
|
-
|
|
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
|