inkbridge 0.1.0-beta.20 → 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 (36) hide show
  1. package/README.md +2 -1
  2. package/bin/inkbridge.mjs +64 -9
  3. package/code.js +11 -11
  4. package/package.json +8 -2
  5. package/scanner/adapter-utils-regression.ts +159 -0
  6. package/scanner/component-scanner.ts +276 -19
  7. package/scanner/font-family-extract-regression.ts +113 -0
  8. package/scanner/framework-adapter-shadcn-regression.ts +96 -1
  9. package/scanner/grid-cols-extraction-regression.ts +110 -0
  10. package/scanner/input-range-regression.ts +217 -0
  11. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  12. package/scanner/local-const-className-regression.ts +331 -0
  13. package/scanner/ring-utility-regression.ts +25 -4
  14. package/scanner/state-classification-regression.ts +38 -0
  15. package/scanner/stretch-to-parent-width-regression.ts +35 -1
  16. package/scanner/tailwind-parser.ts +38 -2
  17. package/src/components/component-gen.ts +11 -151
  18. package/src/design-system/cva-master.ts +7 -3
  19. package/src/design-system/design-system.ts +8 -0
  20. package/src/design-system/node-helpers.ts +15 -1
  21. package/src/design-system/preview-builder.ts +14 -45
  22. package/src/design-system/state-master.ts +23 -1
  23. package/src/design-system/story-builder.ts +55 -5
  24. package/src/design-system/ui-builder.ts +116 -6
  25. package/src/framework-adapters/index.ts +15 -2
  26. package/src/framework-adapters/shadcn.ts +83 -67
  27. package/src/layout/deferred-layout.ts +187 -1
  28. package/src/layout/layout-utils.ts +2 -1
  29. package/src/layout/ring-utils.ts +31 -82
  30. package/src/render-engine-version.ts +1 -1
  31. package/src/tailwind/adapter-utils.ts +137 -0
  32. package/src/tailwind/jsx-utils.ts +9 -0
  33. package/src/tailwind/node-ir.ts +172 -0
  34. package/src/tailwind/tailwind.ts +23 -16
  35. package/src/tokens/tokens.ts +11 -3
  36. package/templates/scan-components-route.ts +11 -1
@@ -62,7 +62,23 @@ export function parseRingColor(
62
62
 
63
63
  /**
64
64
  * Derive a RingInfo from a list of Tailwind classes.
65
- * Returns null when no ring is declared.
65
+ * Returns null when no ring is declared OR when only a ring colour is
66
+ * present without an accompanying width utility.
67
+ *
68
+ * CSS subtlety: in Tailwind, `ring-COLOR` (e.g. `ring-destructive`)
69
+ * sets the ring's color via `--tw-ring-color` but does NOT make the
70
+ * ring visible — that requires a width utility (`ring`, `ring-2`,
71
+ * `ring-[3px]`, …). shadcn's invalid-input pattern relies on this:
72
+ *
73
+ * `aria-invalid:ring-destructive/20 focus-visible:ring-[3px]`
74
+ *
75
+ * "When invalid, the ring is destructive-tinted; when focused, the
76
+ * ring becomes 3px visible." Without focus, ring width = 0, so the
77
+ * invalid-but-unfocused input renders with just the red border, no
78
+ * ring. Previously this function defaulted width to 3 whenever a
79
+ * color was present — so the State Matrix `error` variant rendered
80
+ * with a doubled red ring outside the destructive border, instead of
81
+ * just the border. Now: no width → no ring.
66
82
  */
67
83
  export function getRingInfoFromClasses(
68
84
  classes: string[],
@@ -79,8 +95,7 @@ export function getRingInfoFromClasses(
79
95
  if (nextColor) color = nextColor;
80
96
  }
81
97
 
82
- if (width == null && color == null) return null;
83
- if (width == null) width = 3;
98
+ if (width == null) return null;
84
99
  if (!color) {
85
100
  const fallback = colorGroup.ring || colorGroup.primary;
86
101
  if (!fallback) return null;
@@ -90,82 +105,16 @@ export function getRingInfoFromClasses(
90
105
  return { width: width, color: color };
91
106
  }
92
107
 
93
- /**
94
- * Render a ring as an absolutely-positioned overlay child frame so it sits
95
- * outside the node boundary (matching CSS `box-shadow` ring behavior).
96
- * Returns true on success, false when the node has no dimensions yet.
97
- */
98
- export function applyRingOverlay(node: FrameNode, ring: RingInfo): boolean {
99
- const width = typeof node.width === 'number' ? node.width : 0;
100
- const height = typeof node.height === 'number' ? node.height : 0;
101
- if (!(width > 0) || !(height > 0)) return false;
102
-
103
- // Remove any stale ring overlay from a previous run.
104
- const children = node.children;
105
- if (Array.isArray(children) && children.length > 0) {
106
- for (let i = children.length - 1; i >= 0; i--) {
107
- const child = children[i];
108
- if (!child || child.name !== '__inkbridge-ring__') continue;
109
- try { child.remove(); } catch (_e) {}
110
- }
111
- }
112
-
113
- const overlay = figma.createFrame();
114
- overlay.name = '__inkbridge-ring__';
115
- overlay.resize(width + ring.width * 2, height + ring.width * 2);
116
- overlay.fills = [];
117
- overlay.strokes = [{
118
- type: 'SOLID',
119
- color: { r: ring.color.r, g: ring.color.g, b: ring.color.b },
120
- opacity: ring.color.a == null ? 1 : ring.color.a,
121
- }];
122
- overlay.strokeWeight = ring.width;
123
- try { overlay.strokeAlign = 'INSIDE'; } catch (_e) {}
124
-
125
- const nodeRadius = node.cornerRadius;
126
- if (typeof nodeRadius === 'number') {
127
- overlay.cornerRadius = Math.max(0, nodeRadius + ring.width);
128
- } else {
129
- const tl = typeof node.topLeftRadius === 'number' ? node.topLeftRadius : null;
130
- const tr = typeof node.topRightRadius === 'number' ? node.topRightRadius : null;
131
- const br = typeof node.bottomRightRadius === 'number' ? node.bottomRightRadius : null;
132
- const bl = typeof node.bottomLeftRadius === 'number' ? node.bottomLeftRadius : null;
133
- if (tl != null && tr != null && br != null && bl != null) {
134
- overlay.topLeftRadius = Math.max(0, tl + ring.width);
135
- overlay.topRightRadius = Math.max(0, tr + ring.width);
136
- overlay.bottomRightRadius = Math.max(0, br + ring.width);
137
- overlay.bottomLeftRadius = Math.max(0, bl + ring.width);
138
- }
139
- }
140
-
141
- node.appendChild(overlay);
142
- try { overlay.layoutPositioning = 'ABSOLUTE'; } catch (_e) {}
143
- overlay.x = -ring.width;
144
- overlay.y = -ring.width;
145
- try { node.clipsContent = false; } catch (_e) {}
146
- try { node.insertChild(0, overlay); } catch (_e) {}
147
- return true;
148
- }
149
-
150
- /**
151
- * Apply a ring derived from Tailwind classes to a frame node.
152
- * Prefers the overlay approach; falls back to a plain stroke when the node has
153
- * no dimensions yet (e.g. hug-content frames before children are added).
154
- */
155
- export function applyRingEffect(
156
- node: FrameNode,
157
- classes: string[],
158
- colorGroup: Record<string, string>
159
- ): void {
160
- const ring = getRingInfoFromClasses(classes, colorGroup);
161
- if (!ring) return;
162
- if (applyRingOverlay(node, ring)) return;
163
- const strokeWeight = typeof node.strokeWeight === 'number' ? node.strokeWeight : 0;
164
- node.strokes = [{
165
- type: 'SOLID',
166
- color: { r: ring.color.r, g: ring.color.g, b: ring.color.b },
167
- opacity: ring.color.a == null ? 1 : ring.color.a,
168
- }];
169
- node.strokeWeight = Math.max(strokeWeight, ring.width);
170
- try { node.strokeAlign = 'INSIDE'; } catch (_e) {}
171
- }
108
+ // Ring rendering — the OVERLAY-FRAME implementation, the FIXED-toggle
109
+ // invariant, and the post-pass scheduling all live in
110
+ // `src/layout/deferred-layout.ts` (`markRingNode` / `applyRingIfPossible`).
111
+ // This file owns the *parsing* contract only:
112
+ //
113
+ // parseRingWidth / parseRingColor / getRingInfoFromClasses
114
+ //
115
+ // Callers in the parser path (`tailwind.ts`) and the imperative frame
116
+ // builders (`component-gen.ts`, `cva-master.ts`, `preview-builder.ts`,
117
+ // `state-master.ts`) all funnel through that single deferred entry point.
118
+ // See `.ai/troubleshooting.md` "DO NOT use Figma DROP_SHADOW spread" and
119
+ // "State Matrix focus/error variants render TALLER/WIDER" for the
120
+ // architectural rationale.
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build.mjs. Do not edit manually.
2
- export const RENDER_ENGINE_VERSION = 'a110f024b1fc6aca';
2
+ export const RENDER_ENGINE_VERSION = '011e89524e15bfda';
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared utilities for tree-transform "adapters" — both framework-specific
3
+ * (shadcn / Radix data-slot patches in `framework-adapters/shadcn.ts`) and
4
+ * native-HTML rewrites (e.g. `<input type="range">` in `node-ir.ts`'s
5
+ * transform chain).
6
+ *
7
+ * Why a separate file
8
+ * -------------------
9
+ * `framework-adapters/shadcn.ts` and `tailwind/node-ir.ts` already have a
10
+ * one-way `node-ir → framework-adapters` dependency for the dispatcher.
11
+ * Putting these helpers in either would either deepen that cycle (shadcn →
12
+ * node-ir for `isClassedElement`) or hide them in a "framework adapter"
13
+ * module when they aren't framework-specific. A sibling utility module
14
+ * keeps the import graph clean: both sides import down into this file.
15
+ *
16
+ * All helpers must be sandbox-safe — no value-side spread, no DOM, no
17
+ * external I/O — these run inside the Figma plugin's code.js.
18
+ */
19
+
20
+ import type { NodeIR } from './node-ir';
21
+
22
+ /**
23
+ * Type guard for nodes that carry `props` + `classes`. `text`, `fragment`,
24
+ * `divider`, and `ring` nodes do not; `ring` wraps a single child via a
25
+ * separate field. Tree-transform code typically branches first on this.
26
+ */
27
+ export function isClassedElement(
28
+ node: NodeIR,
29
+ ): node is Extract<NodeIR, { kind: 'element' | 'component' }> {
30
+ return node.kind === 'element' || node.kind === 'component';
31
+ }
32
+
33
+ /**
34
+ * Additive class merge: append `extras` to `existing` only when not
35
+ * already present. Preserves order. Returns `existing` by reference when
36
+ * nothing was added so callers can detect "no change" and keep object
37
+ * identity stable (downstream caches like `NODE_LAYOUT_CACHE` rely on
38
+ * this).
39
+ *
40
+ * Note: this is the "injection" merge, distinct from `class-utils.ts`'s
41
+ * `mergeClasses` which implements Tailwind override semantics
42
+ * (last-wins). Use this when an adapter wants to ADD classes without
43
+ * disturbing the consumer's own class order.
44
+ */
45
+ export function mergeMissing(existing: string[], extras: readonly string[]): string[] {
46
+ if (extras.length === 0) return existing;
47
+ const set = new Set(existing);
48
+ let out: string[] | null = null;
49
+ for (const cls of extras) {
50
+ if (set.has(cls)) continue;
51
+ if (!out) out = existing.slice();
52
+ out.push(cls);
53
+ set.add(cls);
54
+ }
55
+ return out ?? existing;
56
+ }
57
+
58
+ /**
59
+ * Resolve a value/min/max trio into an array of percent positions
60
+ * (0-100). The scanner stringifies every JSX prop, so `<input value={5}>`
61
+ * arrives as `props.value === "5"`. We accept numbers, strings,
62
+ * JSON-array strings ("[25, 75]" for shadcn range sliders), and an actual
63
+ * array of numbers/strings.
64
+ *
65
+ * Defaults — when `min` or `max` is missing / unparseable:
66
+ * - `min` defaults to 0
67
+ * - `max` defaults to 100
68
+ * - If `max <= min` after parsing (degenerate range), we fall back to
69
+ * `min + 100` so the percent math doesn't divide by zero.
70
+ *
71
+ * If `value` can't be parsed at all, returns `[0]` — a recognisable
72
+ * "left-edge" position. Callers that want a different default (e.g. HTML
73
+ * `<input type=range>`'s `(min+max)/2`) can post-process.
74
+ *
75
+ * Used by:
76
+ * - shadcn Slider adapter (`applySliderPositioning`) — passes min=0
77
+ * unconditionally because base-ui's Slider is min=0 max=`max` only.
78
+ * - `<input type="range">` IR transform — passes the actual min from
79
+ * the element's prop (HTML default min is 0 but consumers commonly
80
+ * override).
81
+ */
82
+ export function resolveValuePercents(
83
+ rawValue: unknown,
84
+ rawMin: unknown,
85
+ rawMax: unknown,
86
+ ): number[] {
87
+ const min = parseNumeric(rawMin, 0);
88
+ let max = parseNumeric(rawMax, 100);
89
+ if (!(max > min)) max = min + 100;
90
+ const range = max - min;
91
+ const toPct = (v: number): number =>
92
+ Math.max(0, Math.min(100, ((v - min) / range) * 100));
93
+
94
+ if (Array.isArray(rawValue)) {
95
+ const nums: number[] = [];
96
+ for (const v of rawValue) {
97
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? parseFloat(v) : NaN;
98
+ if (Number.isFinite(n)) nums.push(toPct(n));
99
+ }
100
+ if (nums.length > 0) return nums;
101
+ }
102
+ if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
103
+ return [toPct(rawValue)];
104
+ }
105
+ if (typeof rawValue === 'string') {
106
+ const trimmed = rawValue.trim();
107
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
108
+ try {
109
+ const parsed = JSON.parse(trimmed);
110
+ if (Array.isArray(parsed)) {
111
+ const nums: number[] = [];
112
+ for (const v of parsed) {
113
+ const n = typeof v === 'number' ? v : parseFloat(String(v));
114
+ if (Number.isFinite(n)) nums.push(toPct(n));
115
+ }
116
+ if (nums.length > 0) return nums;
117
+ }
118
+ } catch (_e) {
119
+ // Not valid JSON — fall through to numeric parse.
120
+ }
121
+ }
122
+ const num = parseFloat(trimmed);
123
+ if (Number.isFinite(num)) return [toPct(num)];
124
+ }
125
+ return [0];
126
+ }
127
+
128
+ function parseNumeric(raw: unknown, fallback: number): number {
129
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
130
+ if (typeof raw === 'string') {
131
+ const trimmed = raw.trim();
132
+ if (trimmed.length === 0) return fallback;
133
+ const n = parseFloat(trimmed);
134
+ if (Number.isFinite(n)) return n;
135
+ }
136
+ return fallback;
137
+ }
@@ -2,14 +2,23 @@ import { type JsxNode, type JsxElement, splitClassName } from './node-ir';
2
2
  import { type ComponentDef } from './class-utils';
3
3
  import { extractMaxWidth } from '../layout';
4
4
  import { getClassesForBreakpoint, hasSignificantResponsiveChanges } from './responsive-analyzer';
5
+ import { isFrameworkAdapterDroppedTag } from '../framework-adapters';
5
6
 
6
7
  /**
7
8
  * Recursively collect every Tailwind class string from a JSX tree.
9
+ *
10
+ * Elements that the framework adapter will unconditionally drop at render
11
+ * time (e.g. `ScrollAreaPrimitive.Scrollbar` and friends) are skipped —
12
+ * including their subtree. Otherwise classes that never reach Figma
13
+ * (Scrollbar's `sm:bg-black/60`) would falsely trip pre-render scans like
14
+ * `treeHasResponsiveClasses`, producing duplicate-content Responsive
15
+ * previews on stories that are static at every breakpoint post-adapter.
8
16
  */
9
17
  export function collectTreeClasses(node: JsxNode | undefined, output: string[]): void {
10
18
  if (!node) return;
11
19
  if (node.type === 'element') {
12
20
  const el = node as JsxElement;
21
+ if (isFrameworkAdapterDroppedTag(el.tagName)) return;
13
22
  const className = el.props && el.props.className ? String(el.props.className) : '';
14
23
  if (className) {
15
24
  const list = splitClassName(className);
@@ -1,6 +1,7 @@
1
1
  import { parseColor, type RGB } from '../tokens';
2
2
  import type { ComponentDef } from '../components/scanner-types';
3
3
  import { applyFrameworkAdapters } from '../framework-adapters';
4
+ import { mergeMissing, resolveValuePercents } from './adapter-utils';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // JSX tree types
@@ -160,6 +161,10 @@ export function applyNodeTransforms(
160
161
  // downstream transform without parallel handling.
161
162
  let next = applyFrameworkAdapters(node);
162
163
  next = flattenComponentNodes(next, null, helpers);
164
+ // Native HTML input rewrites run BEFORE the visual transforms (space,
165
+ // divide, ring) so the synthetic tree they emit flows through the rest
166
+ // of the pipeline as ordinary div + Tailwind classes.
167
+ next = transformInputNodes(next);
163
168
  next = transformSpaceNodes(next);
164
169
  next = transformDivideNodes(next, colorGroup);
165
170
  next = transformRingNodes(next, colorGroup);
@@ -248,6 +253,173 @@ function flattenComponentNodes(
248
253
  });
249
254
  }
250
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // Native HTML input rewrites
258
+ //
259
+ // Some `<input>` types have no built-in className-driven appearance that
260
+ // Figma can render — most notably `type="range"`, which is a custom
261
+ // browser widget (track + thumb). The default input branch in
262
+ // `ui-builder.ts` reads `value` / `defaultValue` / `placeholder` and
263
+ // emits a text node — fine for text/number/email, useless (or worse,
264
+ // stalling) for range. This transform rewrites those special cases at
265
+ // the IR-transform stage into a synthetic Tailwind tree the existing
266
+ // pipeline already knows how to render.
267
+ //
268
+ // Currently handles: `type="range"`. Extend the dispatcher in
269
+ // `transformInputNodes` when other types need similar treatment (e.g.
270
+ // checkbox / radio with `checked` driving a visual indicator).
271
+ // ---------------------------------------------------------------------------
272
+
273
+ export function transformInputNodes(node: NodeIR): NodeIR {
274
+ if (node.kind === 'text' || node.kind === 'divider') {
275
+ return node;
276
+ }
277
+
278
+ if (node.kind === 'fragment') {
279
+ return recurseFragment(node, transformInputNodes);
280
+ }
281
+
282
+ if (node.kind === 'ring') {
283
+ const nextChild = transformInputNodes(node.child);
284
+ return nextChild === node.child ? node : Object.assign({}, node, { child: nextChild });
285
+ }
286
+
287
+ // Recurse first so any nested `<input>` is rewritten before its parent
288
+ // decides what to do with it. Preserve object identity when no
289
+ // descendant changed (keeps `NODE_LAYOUT_CACHE` warm).
290
+ let childrenChanged = false;
291
+ const nextChildren: NodeIR[] = [];
292
+ for (const child of node.children) {
293
+ const next = transformInputNodes(child);
294
+ if (next !== child) childrenChanged = true;
295
+ nextChildren.push(next);
296
+ }
297
+ const recursed: NodeIR = childrenChanged
298
+ ? (Object.assign({}, node, { children: nextChildren }) as NodeIR)
299
+ : node;
300
+
301
+ if (recursed.kind !== 'element') return recursed;
302
+ if (recursed.tagLower !== 'input') return recursed;
303
+ const type = (recursed.props && recursed.props.type) || '';
304
+ if (type !== 'range') return recursed;
305
+
306
+ return buildRangeSliderTree(recursed as NodeIRElement);
307
+ }
308
+
309
+ function recurseFragment(
310
+ frag: NodeIRFragment,
311
+ fn: (n: NodeIR) => NodeIR,
312
+ ): NodeIR {
313
+ let changed = false;
314
+ const next: NodeIR[] = [];
315
+ for (const c of frag.children) {
316
+ const r = fn(c);
317
+ if (r !== c) changed = true;
318
+ next.push(r);
319
+ }
320
+ return changed ? Object.assign({}, frag, { children: next }) : frag;
321
+ }
322
+
323
+ /**
324
+ * Rewrite `<input type="range" min=N max=M value=V disabled? className=...>`
325
+ * into a styled `<div>` tree mirroring the shape that shadcn Slider's
326
+ * adapter already produces (and which the renderer is known to handle):
327
+ *
328
+ * wrapper (relative flex h-4 items-center, consumer classes preserved)
329
+ * └── track (flow child: h-1.5 w-full rounded-full overflow-hidden bg-secondary)
330
+ * └── filled (flow child of track: h-full rounded-full bg-primary w-[N%])
331
+ * └── thumb (absolute sibling of track: size-4 rounded-full -translate-x-1/2 left-[N%])
332
+ *
333
+ * Why mirror shadcn's structure (track as flow child, thumb as absolute
334
+ * sibling)? An all-absolute-children wrapper has no flow content to size
335
+ * against — deferred-layout passes can stall trying to resolve a 0-content
336
+ * frame with `flex items-center`. The shadcn Slider already navigates this
337
+ * by making Track a flow child of Control; we copy that pattern.
338
+ *
339
+ * Vertical centering of the thumb comes from `flex items-center` on the
340
+ * wrapper combined with the thumb's static position (size-4 = wrapper
341
+ * height) — no `top-1/2 -translate-y-1/2` needed.
342
+ *
343
+ * Geometry: `pct = ((value - min) / (max - min)) * 100`. See
344
+ * `resolveValuePercents` in `./adapter-utils.ts`.
345
+ */
346
+ function buildRangeSliderTree(input: NodeIRElement): NodeIR {
347
+ const props = input.props || {};
348
+ const rawValue = props.value ?? props.defaultValue;
349
+ const pcts = resolveValuePercents(rawValue, props.min, props.max);
350
+ const pct = pcts[0] ?? 0;
351
+ const isDisabled = props.disabled !== undefined && props.disabled !== 'false';
352
+
353
+ // Preserve sizing-related classes from the consumer (`w-full`, `w-[Npx]`,
354
+ // `max-w-*`) onto the wrapper. Visual / state classes that applied to the
355
+ // native widget shape (`accent-primary`, focus rings) are kept but
356
+ // typically no-ops on the synthetic div.
357
+ const wrapperExtras = ['relative', 'flex', 'h-4', 'items-center'];
358
+ if (isDisabled) wrapperExtras.push('opacity-50');
359
+ const wrapperClasses = mergeMissing(input.classes, wrapperExtras);
360
+
361
+ const filled: NodeIR = makeRangeChild('div', [
362
+ 'h-full',
363
+ 'rounded-full',
364
+ 'bg-primary',
365
+ `w-[${formatPercent(pct)}%]`,
366
+ ]);
367
+
368
+ const track: NodeIRElement = {
369
+ kind: 'element',
370
+ tagName: 'div',
371
+ tagLower: 'div',
372
+ props: {},
373
+ classes: [
374
+ 'h-1.5',
375
+ 'w-full',
376
+ 'rounded-full',
377
+ 'overflow-hidden',
378
+ 'bg-secondary',
379
+ ],
380
+ children: [filled],
381
+ };
382
+
383
+ const thumb: NodeIR = makeRangeChild('div', [
384
+ 'absolute',
385
+ 'size-4',
386
+ 'rounded-full',
387
+ 'border-2',
388
+ 'border-primary',
389
+ 'bg-background',
390
+ '-translate-x-1/2',
391
+ `left-[${formatPercent(pct)}%]`,
392
+ ]);
393
+
394
+ return {
395
+ kind: 'element',
396
+ tagName: 'div',
397
+ tagLower: 'div',
398
+ props: {},
399
+ classes: wrapperClasses,
400
+ children: [track, thumb],
401
+ };
402
+ }
403
+
404
+ function makeRangeChild(tag: string, classes: string[]): NodeIRElement {
405
+ return {
406
+ kind: 'element',
407
+ tagName: tag,
408
+ tagLower: tag,
409
+ props: {},
410
+ classes,
411
+ children: [],
412
+ };
413
+ }
414
+
415
+ function formatPercent(pct: number): string {
416
+ // Two-decimal precision keeps thumb placement visually correct for
417
+ // common (min, max, value) triples without bloating the class name.
418
+ // `Number.toFixed` always renders fixed decimals; trim trailing zeros.
419
+ const fixed = pct.toFixed(2);
420
+ return fixed.replace(/\.?0+$/, '') || '0';
421
+ }
422
+
251
423
  function transformSpaceNodes(node: NodeIR): NodeIR {
252
424
  if (node.kind === 'text' || node.kind === 'divider') {
253
425
  return node;
@@ -37,7 +37,8 @@ import {
37
37
  shouldApplyAtom,
38
38
  BORDER_WIDTH_CLASSES,
39
39
  DEFERRED_TOP_RELATIVE_NODES,
40
- applyRingEffect,
40
+ getRingInfoFromClasses,
41
+ markRingNode,
41
42
  } from '../layout';
42
43
 
43
44
  // ---------------------------------------------------------------------------
@@ -1154,12 +1155,21 @@ function applySemanticUtilitiesToFrame(
1154
1155
  };
1155
1156
  const defs = SHADOW_MAP[shadowKey];
1156
1157
  if (defs !== undefined) {
1158
+ // Figma renders multi-layer DROP_SHADOW effects as independently
1159
+ // stacked filters — they composite "harder" than CSS box-shadow,
1160
+ // where the browser's filter pipeline smooths overlapping
1161
+ // shadow layers. The numeric values in SHADOW_MAP match
1162
+ // Tailwind v3's docs exactly, but the visual result reads as
1163
+ // ~30 % stronger in Figma at parity. Damp the alpha channel so
1164
+ // the rendered page matches Storybook / browser intensity. Tune
1165
+ // this single constant to dial all tiers up or down together.
1166
+ const SHADOW_ALPHA_DAMPER = 0.7;
1157
1167
  const shadowEffects: Effect[] = [];
1158
1168
  for (let si = 0; si < defs.length; si++) {
1159
1169
  const d = defs[si];
1160
1170
  shadowEffects.push({
1161
1171
  type: d.type,
1162
- color: { r: d.r, g: d.g, b: d.b, a: d.a },
1172
+ color: { r: d.r, g: d.g, b: d.b, a: d.a * SHADOW_ALPHA_DAMPER },
1163
1173
  offset: { x: d.x, y: d.y },
1164
1174
  radius: d.radius,
1165
1175
  spread: d.spread,
@@ -1733,20 +1743,17 @@ export function applyTailwindStylesToFrame(
1733
1743
  frame.opacity = style.opacity;
1734
1744
  }
1735
1745
 
1736
- // Ring utilities (`ring-1`, `ring-2`, `ring-ring`, `ring-primary/50`, …)
1737
- // are rendered as an absolutely-positioned overlay child frame with a
1738
- // stroke (CSS `box-shadow: 0 0 0 N <color>` semantics). We used to
1739
- // express this as a Figma DROP_SHADOW with spread=ringWidth, but Figma
1740
- // surfaces `The 'spread' parameter is not supported when frames or
1741
- // components have no visible fills` for every fills:[] frame —
1742
- // structural design-system layout frames are intentionally transparent.
1743
- // The overlay path also avoids forcing `clipsContent=true` on the host
1744
- // frame, which used to be a workaround for a *different* Figma spread
1745
- // constraint. State-frame renderings (Textarea focus row, future Input
1746
- // focus, ) come through this path; the NodeIR-based renderer wraps
1747
- // the element in a `ring` node which is the more accurate path but
1748
- // only fires when the node carries `kind: 'ring'`.
1749
- applyRingEffect(frame, classes, colorGroup);
1746
+ // Ring utilities (`ring-1`, `ring-2`, `ring-ring`, `ring-primary/50`,
1747
+ // `ring-offset-N`, …) are MARKED here at parse time and resolved later
1748
+ // by `applyRingIfPossible` in the post-append pipeline, when the host's
1749
+ // content children are all in place and its natural W × H is final.
1750
+ // Doing this eagerly here used to inflate Hug-sized parents during the
1751
+ // brief flex-child moment between `appendChild` and
1752
+ // `layoutPositioning = 'ABSOLUTE'`. See `src/layout/deferred-layout.ts`
1753
+ // (`applyRingIfPossible`) for why the deferred phase is the right
1754
+ // place for this concern.
1755
+ const ringInfo = getRingInfoFromClasses(classes, colorGroup);
1756
+ if (ringInfo) markRingNode(frame, ringInfo);
1750
1757
 
1751
1758
  return style;
1752
1759
  }
@@ -534,9 +534,15 @@ const SYSTEM_FONT_KEYWORDS = new Set([
534
534
  'arial', 'helvetica', 'georgia', 'verdana', 'tahoma', 'trebuchet ms',
535
535
  'times new roman', 'courier new', 'courier', 'palatino', 'garamond',
536
536
  'bookman', 'comic sans ms', 'impact', 'lucida sans unicode',
537
+ // Emoji / symbol fallback fonts — present in Tailwind's default
538
+ // sans-serif stack but not in Figma's font registry. Without skipping
539
+ // these, the resolver picks "Apple Color Emoji" as the body font on
540
+ // any consumer that exposes Tailwind's default `font.sans` value,
541
+ // then `figma.loadFontAsync` throws "couldn't load font".
542
+ 'apple color emoji', 'segoe ui emoji', 'segoe ui symbol', 'noto color emoji',
537
543
  ]);
538
544
 
539
- function extractFontName(raw: unknown): string | null {
545
+ export function extractFontName(raw: unknown): string | null {
540
546
  const parts = String(raw || '').split(',');
541
547
  let varFallback: string | null = null;
542
548
  for (const part of parts) {
@@ -581,8 +587,10 @@ export function getCoreFontFamily(tokens: Tokens): string | null {
581
587
  if (core && core.font) {
582
588
  const raw = (core.font as TokenGroup).sans || Object.values(core.font as TokenGroup)[0];
583
589
  if (raw) {
584
- const first = String(raw).split(',')[0].trim().replace(/^["']|["']$/g, '');
585
- if (first) return first;
590
+ // Filter system / emoji / generic-keyword entries — they aren't
591
+ // valid Figma font families. Same logic as getThemeFontFamily.
592
+ const name = extractFontName(raw);
593
+ if (name) return name;
586
594
  }
587
595
  }
588
596
  return getThemeFontFamily(tokens, 'primary');
@@ -20,7 +20,17 @@ import { spawnSync } from 'child_process';
20
20
 
21
21
  const CWD = process.cwd();
22
22
  const TSX = path.resolve(CWD, 'node_modules/.bin/tsx');
23
- const CLI = path.resolve(CWD, 'node_modules/inkbridge/scanner/cli.ts');
23
+ // `INKBRIDGE_LOCAL=1` (set by `pnpm inkbridge:dev:local` in projects that
24
+ // sit next to a sibling inkbridge checkout) points the scanner at the
25
+ // source tree directly instead of going through `node_modules/inkbridge`.
26
+ // pnpm hard-links package files at install time, but most editors break
27
+ // those links on save (save-as-rename semantics), so without this flag
28
+ // every scanner edit requires a `pnpm add -D inkbridge@file:... --force`.
29
+ // The flag turns scanner-side iteration into a true hot loop.
30
+ const LOCAL_CLI = path.resolve(CWD, '..', 'inkbridge', 'tools', 'figma-plugin', 'scanner', 'cli.ts');
31
+ const CLI = process.env.INKBRIDGE_LOCAL === '1' && fs.existsSync(LOCAL_CLI)
32
+ ? LOCAL_CLI
33
+ : path.resolve(CWD, 'node_modules/inkbridge/scanner/cli.ts');
24
34
  const OUTPUT = path.resolve(CWD, '.inkbridge/component-definitions.json');
25
35
 
26
36
  const CORS = {