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.
- package/README.md +2 -1
- package/bin/inkbridge.mjs +64 -9
- package/code.js +11 -11
- package/package.json +8 -2
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/component-scanner.ts +276 -19
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/framework-adapter-shadcn-regression.ts +96 -1
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/ring-utility-regression.ts +25 -4
- package/scanner/state-classification-regression.ts +38 -0
- package/scanner/stretch-to-parent-width-regression.ts +35 -1
- package/scanner/tailwind-parser.ts +38 -2
- package/src/components/component-gen.ts +11 -151
- package/src/design-system/cva-master.ts +7 -3
- package/src/design-system/design-system.ts +8 -0
- package/src/design-system/node-helpers.ts +15 -1
- package/src/design-system/preview-builder.ts +14 -45
- package/src/design-system/state-master.ts +23 -1
- package/src/design-system/story-builder.ts +55 -5
- package/src/design-system/ui-builder.ts +116 -6
- package/src/framework-adapters/index.ts +15 -2
- package/src/framework-adapters/shadcn.ts +83 -67
- package/src/layout/deferred-layout.ts +187 -1
- package/src/layout/layout-utils.ts +2 -1
- package/src/layout/ring-utils.ts +31 -82
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/tailwind/jsx-utils.ts +9 -0
- package/src/tailwind/node-ir.ts +172 -0
- package/src/tailwind/tailwind.ts +23 -16
- package/src/tokens/tokens.ts +11 -3
- package/templates/scan-components-route.ts +11 -1
package/src/layout/ring-utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 = '
|
|
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);
|
package/src/tailwind/node-ir.ts
CHANGED
|
@@ -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;
|
package/src/tailwind/tailwind.ts
CHANGED
|
@@ -37,7 +37,8 @@ import {
|
|
|
37
37
|
shouldApplyAtom,
|
|
38
38
|
BORDER_WIDTH_CLASSES,
|
|
39
39
|
DEFERRED_TOP_RELATIVE_NODES,
|
|
40
|
-
|
|
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
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
|
|
1746
|
-
|
|
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
|
}
|
package/src/tokens/tokens.ts
CHANGED
|
@@ -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
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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 = {
|