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
package/src/text/inline-text.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RGB, parseColor } from '../tokens';
|
|
2
2
|
import { resolveTextColorValue, extractTextColorToken } from '../tokens';
|
|
3
|
-
import { getLineHeightFromClasses, TAG_TYPOGRAPHY, createTextNode, type CreateTextOptions } from './text-builder';
|
|
3
|
+
import { getLineHeightFromClasses, TAG_TYPOGRAPHY, createTextNode, queueFontLoad, type CreateTextOptions } from './text-builder';
|
|
4
4
|
import { tailwindClassesToStyle, type TailwindStyle } from '../tailwind';
|
|
5
5
|
import { type NodeIR, type NodeIRElement } from '../tailwind';
|
|
6
6
|
import type { RenderContext } from '../design-system';
|
|
@@ -8,13 +8,59 @@ import { type NodeLayoutComputed } from '../layout';
|
|
|
8
8
|
import { INLINE_TEXT_TAGS } from './text-builder';
|
|
9
9
|
import { TOKENS, getThemeFontFamily } from '../tokens';
|
|
10
10
|
import { getFontStyleCandidatesForFamily, inferFontWeight } from './font-style-resolver';
|
|
11
|
+
import { applyTruncation, getTruncateFromClasses, markForTruncation } from './text-truncate';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find a `truncate` / `line-clamp-N` class anywhere in an inline
|
|
15
|
+
* collapse tree. The collapse merges multiple elements into one
|
|
16
|
+
* TextNode, so a truncate on any participating element (the wrapping
|
|
17
|
+
* `<td>` OR an inner `<span>`) should mark the resulting node.
|
|
18
|
+
*
|
|
19
|
+
* Walk order: the outer node's own classes first, then descend into
|
|
20
|
+
* each child (or `childrenOverride` when caller has pre-filtered).
|
|
21
|
+
* First match wins — mirrors `getTruncateFromClasses` precedence.
|
|
22
|
+
*/
|
|
23
|
+
export function findTruncateInInlineTree(
|
|
24
|
+
node: NodeIR,
|
|
25
|
+
childrenOverride?: NodeIR[]
|
|
26
|
+
): { maxLines: number } | null {
|
|
27
|
+
if (node.kind === 'element') {
|
|
28
|
+
const own = getTruncateFromClasses(node.classes);
|
|
29
|
+
if (own) return own;
|
|
30
|
+
}
|
|
31
|
+
const kids = childrenOverride
|
|
32
|
+
?? ((node.kind === 'element' || node.kind === 'fragment') ? node.children : []);
|
|
33
|
+
for (let i = 0; i < kids.length; i++) {
|
|
34
|
+
const child = kids[i];
|
|
35
|
+
if (child.kind === 'element') {
|
|
36
|
+
const found = getTruncateFromClasses(child.classes);
|
|
37
|
+
if (found) return found;
|
|
38
|
+
}
|
|
39
|
+
if (child.kind === 'element' || child.kind === 'fragment') {
|
|
40
|
+
const nested = findTruncateInInlineTree(child);
|
|
41
|
+
if (nested) return nested;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
11
46
|
|
|
12
47
|
export type InlineTextSegment = {
|
|
13
48
|
text: string;
|
|
14
49
|
fill?: RGB | null;
|
|
15
50
|
fontStyle?: string;
|
|
51
|
+
// Per-segment font family override. Used for inline mono-family tags
|
|
52
|
+
// (`<code>`, `<kbd>`, `<samp>`, `<var>`) so their runs render in the
|
|
53
|
+
// theme's mono font while the surrounding paragraph stays in sans —
|
|
54
|
+
// matching browser default styling without breaking paragraph flow.
|
|
55
|
+
fontFamily?: string;
|
|
16
56
|
};
|
|
17
57
|
|
|
58
|
+
// Inline tags whose runs should render with the theme's mono font family.
|
|
59
|
+
// Keep aligned with INLINE_TEXT_TAGS in text-builder.ts — adding a tag here
|
|
60
|
+
// without listing it there means the run gets isolated into its own block
|
|
61
|
+
// frame and the mono override is never applied.
|
|
62
|
+
const MONO_INLINE_TAGS = new Set(['code', 'kbd', 'samp', 'var']);
|
|
63
|
+
|
|
18
64
|
const BLOCK_TEXT_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li']);
|
|
19
65
|
const FONT_WEIGHT_TO_STYLE: Record<string, string> = {
|
|
20
66
|
'font-thin': 'Thin',
|
|
@@ -100,12 +146,17 @@ function normalizeInlineSegments(segments: InlineTextSegment[]): InlineTextSegme
|
|
|
100
146
|
|
|
101
147
|
function collectInlineSegments(
|
|
102
148
|
node: NodeIR,
|
|
103
|
-
parentStyle: { fill?: RGB | null; fontStyle?: string },
|
|
149
|
+
parentStyle: { fill?: RGB | null; fontStyle?: string; fontFamily?: string },
|
|
104
150
|
colorGroup: Record<string, string>,
|
|
105
151
|
theme: string
|
|
106
152
|
): InlineTextSegment[] {
|
|
107
153
|
if (node.kind === 'text') {
|
|
108
|
-
return [{
|
|
154
|
+
return [{
|
|
155
|
+
text: node.text,
|
|
156
|
+
fill: parentStyle.fill,
|
|
157
|
+
fontStyle: parentStyle.fontStyle,
|
|
158
|
+
fontFamily: parentStyle.fontFamily,
|
|
159
|
+
}];
|
|
109
160
|
}
|
|
110
161
|
if (node.kind === 'fragment') {
|
|
111
162
|
const parts: InlineTextSegment[] = [];
|
|
@@ -123,7 +174,19 @@ function collectInlineSegments(
|
|
|
123
174
|
if (node.tagLower === 'strong' || node.tagLower === 'b') {
|
|
124
175
|
fontStyle = ensureAtLeastBold(fontStyle);
|
|
125
176
|
}
|
|
126
|
-
|
|
177
|
+
let fontFamily = parentStyle.fontFamily;
|
|
178
|
+
if (MONO_INLINE_TAGS.has(node.tagLower)) {
|
|
179
|
+
const mono = getThemeFontFamily(TOKENS, theme, 'mono');
|
|
180
|
+
if (mono) {
|
|
181
|
+
fontFamily = mono;
|
|
182
|
+
// Pre-queue the mono variant so the setRangeFontName call later
|
|
183
|
+
// doesn't silently no-op on a not-yet-loaded font. Queue both
|
|
184
|
+
// Regular and Bold — `<strong><code>` chains the styles.
|
|
185
|
+
queueFontLoad(mono, 'Regular');
|
|
186
|
+
queueFontLoad(mono, 'Bold');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const nextStyle = { fill: fill, fontStyle: fontStyle, fontFamily: fontFamily };
|
|
127
190
|
const parts: InlineTextSegment[] = [];
|
|
128
191
|
for (const child of node.children || []) {
|
|
129
192
|
parts.push.apply(parts, collectInlineSegments(child, nextStyle, colorGroup, theme));
|
|
@@ -146,7 +209,8 @@ export function createInlineTextNode(
|
|
|
146
209
|
context: RenderContext,
|
|
147
210
|
colorGroup: Record<string, string>,
|
|
148
211
|
theme: string,
|
|
149
|
-
layoutComputed: NodeLayoutComputed
|
|
212
|
+
layoutComputed: NodeLayoutComputed,
|
|
213
|
+
childrenOverride?: NodeIR[]
|
|
150
214
|
): TextNode | null {
|
|
151
215
|
const baseFillValue = resolveTextColorValue(
|
|
152
216
|
textStyle.text || context.textColor,
|
|
@@ -159,9 +223,22 @@ export function createInlineTextNode(
|
|
|
159
223
|
node.classes,
|
|
160
224
|
context.textFontStyle || (context.textBold ? 'Bold' : undefined)
|
|
161
225
|
);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
226
|
+
// The caller can hand us a pre-filtered child list (e.g. after the
|
|
227
|
+
// responsive-hidden filter in ui-builder). If given, iterate it directly
|
|
228
|
+
// so hidden spans don't sneak back in via collectInlineSegments walking
|
|
229
|
+
// the raw node.children.
|
|
230
|
+
const inheritedStyle = { fill: baseFill, fontStyle: baseFontStyle };
|
|
231
|
+
let rawSegments: InlineTextSegment[];
|
|
232
|
+
if (childrenOverride) {
|
|
233
|
+
rawSegments = [];
|
|
234
|
+
for (let i = 0; i < childrenOverride.length; i++) {
|
|
235
|
+
const parts = collectInlineSegments(childrenOverride[i], inheritedStyle, colorGroup, theme);
|
|
236
|
+
rawSegments.push.apply(rawSegments, parts);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
rawSegments = collectInlineSegments(node, inheritedStyle, colorGroup, theme);
|
|
240
|
+
}
|
|
241
|
+
const segments = normalizeInlineSegments(rawSegments);
|
|
165
242
|
if (segments.length === 0) return null;
|
|
166
243
|
const textValue = segments.map(seg => seg.text).join('');
|
|
167
244
|
|
|
@@ -217,11 +294,20 @@ export function createInlineTextNode(
|
|
|
217
294
|
// ignore
|
|
218
295
|
}
|
|
219
296
|
}
|
|
220
|
-
|
|
221
|
-
|
|
297
|
+
// Resolve effective family + style for this segment. A per-segment
|
|
298
|
+
// `fontFamily` override (mono inline tags like `<code>`) takes precedence
|
|
299
|
+
// over the paragraph's base family. When the family changes we must
|
|
300
|
+
// setRangeFontName even if the style matches the base, otherwise the run
|
|
301
|
+
// stays in the surrounding sans family.
|
|
302
|
+
const segFamily = seg.fontFamily || fontFamily;
|
|
303
|
+
const segStyle = seg.fontStyle || baseStyle;
|
|
304
|
+
const familyChanged = seg.fontFamily && seg.fontFamily !== fontFamily;
|
|
305
|
+
const styleChanged = seg.fontStyle && seg.fontStyle !== baseStyle;
|
|
306
|
+
if (familyChanged || styleChanged) {
|
|
307
|
+
const styleCandidates = getFontStyleCandidatesForFamily(segFamily, segStyle);
|
|
222
308
|
for (const candidate of styleCandidates) {
|
|
223
309
|
try {
|
|
224
|
-
textNode.setRangeFontName(start, end, { family:
|
|
310
|
+
textNode.setRangeFontName(start, end, { family: segFamily, style: candidate });
|
|
225
311
|
break;
|
|
226
312
|
} catch (_err) {
|
|
227
313
|
// try next candidate
|
|
@@ -258,7 +344,21 @@ export function createInlineTextNode(
|
|
|
258
344
|
|| node.classes.includes('w-full')
|
|
259
345
|
|| shouldInheritContainerWidth;
|
|
260
346
|
|
|
261
|
-
|
|
347
|
+
// Text truncation: the inline-collapse merges multiple elements
|
|
348
|
+
// into one TextNode, so `truncate` / `line-clamp-N` on any
|
|
349
|
+
// participating element (the outer container OR an inner span)
|
|
350
|
+
// must mark the resulting node. Mark unconditionally so the
|
|
351
|
+
// deferred truncation pass (e.g. table-layout's per-cell apply)
|
|
352
|
+
// can re-truncate once a final width is known.
|
|
353
|
+
const inlineTruncate = findTruncateInInlineTree(node, childrenOverride);
|
|
354
|
+
if (inlineTruncate) {
|
|
355
|
+
markForTruncation(textNode, inlineTruncate.maxLines);
|
|
356
|
+
if (constrainWidth) {
|
|
357
|
+
const fs = typeof textNode.fontSize === 'number' ? textNode.fontSize : undefined;
|
|
358
|
+
const lh = getLineHeightFromClasses(node.classes, fs ?? 14) ?? undefined;
|
|
359
|
+
applyTruncation(textNode, constrainWidth, inlineTruncate.maxLines, lh);
|
|
360
|
+
}
|
|
361
|
+
} else if (shouldConstrain && constrainWidth) {
|
|
262
362
|
try {
|
|
263
363
|
textNode.textAutoResize = 'HEIGHT';
|
|
264
364
|
textNode.resize(constrainWidth, textNode.height);
|
package/src/text/text-builder.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function waitForAllFonts(): Promise<void> {
|
|
|
26
26
|
return Promise.all(FONT_LOAD_PROMISES).then(() => undefined);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export const INLINE_TEXT_TAGS = new Set(['span', 'a', 'label', 'small', 'strong', 'em', 'b', 'i']);
|
|
29
|
+
export const INLINE_TEXT_TAGS = new Set(['span', 'a', 'label', 'small', 'strong', 'em', 'b', 'i', 'code', 'kbd', 'samp', 'var', 'mark', 'sub', 'sup', 'u', 'q', 'cite']);
|
|
30
30
|
|
|
31
31
|
export const TAG_TYPOGRAPHY: Record<string, { fontSize: number; bold?: boolean; lineHeight?: number }> = {
|
|
32
32
|
h1: { fontSize: 32, bold: true, lineHeight: 40 },
|
|
@@ -43,7 +43,7 @@ export const TAG_TYPOGRAPHY: Record<string, { fontSize: number; bold?: boolean;
|
|
|
43
43
|
small: { fontSize: 12 },
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
function queueFontLoad(family: string, style: string): void {
|
|
46
|
+
export function queueFontLoad(family: string, style: string): void {
|
|
47
47
|
const key = family + ':' + style;
|
|
48
48
|
if (FONT_LOAD_STATE[key]) return;
|
|
49
49
|
FONT_LOAD_STATE[key] = 'loading';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-truncation primitives shared by the immediate (render-time) and
|
|
3
|
+
* deferred (post-layout) truncation paths.
|
|
4
|
+
*
|
|
5
|
+
* The renderer marks every TextNode that came from a `truncate` /
|
|
6
|
+
* `line-clamp-N` source — even when the parent's width isn't yet
|
|
7
|
+
* known. Later passes (the table column-alignment pass, any other
|
|
8
|
+
* deferred-layout step) can then find marked nodes and apply the
|
|
9
|
+
* truncation once a final width is available.
|
|
10
|
+
*
|
|
11
|
+
* Keep this file self-contained: no React/Tailwind imports, no
|
|
12
|
+
* `RenderContext` coupling. Anything calling `applyTruncation` already
|
|
13
|
+
* has the width and max-lines it needs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MARK_KEY = 'inkbridge:truncate-max-lines';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse `truncate` / `line-clamp-N` from a Tailwind class list. Returns
|
|
20
|
+
* `maxLines` for the matched utility, or `null` when neither is
|
|
21
|
+
* present. Centralised here so every site that creates a TextNode can
|
|
22
|
+
* use the same source of truth (avoid the recurring drift seen across
|
|
23
|
+
* the three text-rendering paths in `ui-builder.ts`).
|
|
24
|
+
*/
|
|
25
|
+
export function getTruncateFromClasses(classes: string[]): { maxLines: number } | null {
|
|
26
|
+
if (classes.includes('truncate')) return { maxLines: 1 };
|
|
27
|
+
for (const cls of classes) {
|
|
28
|
+
const m = cls.match(/^line-clamp-(\d+)$/);
|
|
29
|
+
if (m) {
|
|
30
|
+
const n = parseInt(m[1], 10);
|
|
31
|
+
if (Number.isFinite(n) && n > 0) return { maxLines: n };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tag a TextNode as a truncation target. Idempotent. Stores `maxLines`
|
|
39
|
+
* so the deferred pass picks the same value the renderer would have
|
|
40
|
+
* applied immediately.
|
|
41
|
+
*/
|
|
42
|
+
export function markForTruncation(textNode: TextNode, maxLines: number): void {
|
|
43
|
+
if (!(maxLines > 0)) return;
|
|
44
|
+
try {
|
|
45
|
+
textNode.setPluginData(MARK_KEY, String(maxLines));
|
|
46
|
+
} catch (_err) {
|
|
47
|
+
// pluginData write can fail on certain node types in older Figma
|
|
48
|
+
// plugin sandbox versions — best effort only.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns the marked maxLines value, or `null` when the node was
|
|
54
|
+
* never marked (or the mark was cleared). Used by the deferred pass
|
|
55
|
+
* to decide whether to act on a TextNode it finds while walking.
|
|
56
|
+
*/
|
|
57
|
+
export function readTruncationMark(textNode: TextNode): number | null {
|
|
58
|
+
try {
|
|
59
|
+
const raw = textNode.getPluginData(MARK_KEY);
|
|
60
|
+
if (!raw) return null;
|
|
61
|
+
const n = parseInt(raw, 10);
|
|
62
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
63
|
+
} catch (_err) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Apply CSS `text-overflow: ellipsis` semantics to a Figma TextNode.
|
|
70
|
+
* `maxWidth` is the cell/container content width the text must fit
|
|
71
|
+
* inside; `maxLines` controls how many lines are kept before the
|
|
72
|
+
* ellipsis (1 for `truncate`, N for `line-clamp-N`).
|
|
73
|
+
*
|
|
74
|
+
* `lineHeight` is optional — when omitted we derive a sensible value
|
|
75
|
+
* from the node's `fontSize` (×1.5, mirroring Tailwind's default
|
|
76
|
+
* `leading-normal`). Callers that have the resolved line-height from
|
|
77
|
+
* the original class list (e.g. when the source had explicit
|
|
78
|
+
* `leading-*`) should pass it explicitly so the height matches.
|
|
79
|
+
*
|
|
80
|
+
* Errors are swallowed: `textTruncation` is unavailable in older
|
|
81
|
+
* Figma plugin runtimes, and we'd rather render an untruncated cell
|
|
82
|
+
* than abort the whole table.
|
|
83
|
+
*/
|
|
84
|
+
export function applyTruncation(
|
|
85
|
+
textNode: TextNode,
|
|
86
|
+
maxWidth: number,
|
|
87
|
+
maxLines: number,
|
|
88
|
+
lineHeight?: number,
|
|
89
|
+
): void {
|
|
90
|
+
if (!(maxWidth > 0) || !(maxLines > 0)) return;
|
|
91
|
+
try {
|
|
92
|
+
const fs = typeof textNode.fontSize === 'number' ? textNode.fontSize : 14;
|
|
93
|
+
const lh = lineHeight && lineHeight > 0 ? lineHeight : Math.ceil(fs * 1.5);
|
|
94
|
+
textNode.textTruncation = 'ENDING';
|
|
95
|
+
textNode.maxLines = maxLines;
|
|
96
|
+
textNode.resize(maxWidth, Math.max(lh, lh * maxLines));
|
|
97
|
+
} catch (_err) {
|
|
98
|
+
// truncation not supported / resize rejected; the cell will keep
|
|
99
|
+
// its untruncated text but rendering continues.
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/tokens/tokens.ts
CHANGED
|
@@ -369,44 +369,135 @@ function cloneTokens<T>(value: T): T {
|
|
|
369
369
|
return JSON.parse(JSON.stringify(value));
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Build TOKENS from a successful CSS / DTCG scan. The result reflects
|
|
374
|
+
* the consumer's source files *exactly* — no merge with the embedded
|
|
375
|
+
* snapshot. The embedded snapshot is now used ONLY as a pre-scan
|
|
376
|
+
* placeholder (see `applyScannedTokens` below) so the design-tokens
|
|
377
|
+
* panel isn't blank during the brief window before the first scan
|
|
378
|
+
* completes.
|
|
379
|
+
*
|
|
380
|
+
* Previously this function deep-merged scanned tokens on top of the
|
|
381
|
+
* embedded snapshot, which leaked inkbridge-marketing-specific values
|
|
382
|
+
* (e.g. a `heading: Playfair Display` font role) into every other
|
|
383
|
+
* consumer's panel — confusing because consumers never wrote those
|
|
384
|
+
* tokens and the values were arbitrary defaults from this plugin's
|
|
385
|
+
* own marketing site.
|
|
386
|
+
*
|
|
387
|
+
* # Concern #2 — planned follow-up
|
|
388
|
+
*
|
|
389
|
+
* Designers also need access to the FULL Tailwind palette in Figma
|
|
390
|
+
* (blue-500, slate-200, etc.) as Figma variables, separate from the
|
|
391
|
+
* consumer's custom tokens. That should be a separate pipeline that
|
|
392
|
+
* reads Tailwind's actual built-in `@theme` (from the `tailwindcss`
|
|
393
|
+
* package, not from a frozen snapshot), exposes those values as Figma
|
|
394
|
+
* variables for designer convenience, and keeps the Design Tokens
|
|
395
|
+
* panel focused on the consumer's overrides only. Tracked in
|
|
396
|
+
* `tools/figma-plugin/.ai/architecture.md` § "Future work".
|
|
397
|
+
*/
|
|
398
|
+
function buildTokensFromScanned(map: ScannedTokenMap): Tokens {
|
|
373
399
|
const scannedPatch = buildTokenPatchFromScanned(map);
|
|
374
400
|
const out: Tokens = {};
|
|
375
401
|
|
|
376
|
-
// Keep core as-is (scanner currently maps fonts into theme blocks).
|
|
377
|
-
if (baseSnapshot.core) {
|
|
378
|
-
out.core = cloneTokens(baseSnapshot.core);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const basePrimary = (baseSnapshot.primary && typeof baseSnapshot.primary === 'object')
|
|
382
|
-
? (baseSnapshot.primary as ThemeTokens)
|
|
383
|
-
: {};
|
|
384
402
|
const scannedPrimary = (scannedPatch.primary && typeof scannedPatch.primary === 'object')
|
|
385
|
-
? (scannedPatch.primary as ThemeTokens)
|
|
403
|
+
? cloneTokens(scannedPatch.primary as ThemeTokens) as ThemeTokens
|
|
386
404
|
: {};
|
|
387
|
-
|
|
388
|
-
out.primary = primaryTheme;
|
|
405
|
+
out.primary = scannedPrimary;
|
|
389
406
|
|
|
390
407
|
const scannedThemeNames = Object.keys(map.themes || {}).filter((name) => name && name !== 'primary');
|
|
391
408
|
for (let i = 0; i < scannedThemeNames.length; i++) {
|
|
392
409
|
const themeName = scannedThemeNames[i];
|
|
393
410
|
const scannedTheme = scannedPatch[themeName];
|
|
394
411
|
if (!scannedTheme || typeof scannedTheme !== 'object') continue;
|
|
395
|
-
//
|
|
396
|
-
|
|
412
|
+
// Secondary themes are normally CSS overrides on top of primary —
|
|
413
|
+
// inherit primary's resolved values then apply per-key overrides.
|
|
414
|
+
// This is a CONSUMER-to-CONSUMER merge (no embedded values touched).
|
|
415
|
+
out[themeName] = mergeTokens(cloneTokens(scannedPrimary), scannedTheme as ThemeTokens) as ThemeTokens;
|
|
397
416
|
}
|
|
398
417
|
|
|
399
418
|
return out;
|
|
400
419
|
}
|
|
401
420
|
|
|
402
421
|
export function applyScannedTokens(map: ScannedTokenMap | null | undefined): void {
|
|
422
|
+
// Pre-scan placeholder OR consumer has no `globals.css` / DTCG file
|
|
423
|
+
// at all — fall back to the embedded snapshot so the panel renders
|
|
424
|
+
// SOMETHING. This is the only path that surfaces the embedded values
|
|
425
|
+
// post-refactor; once a real scan completes (`map.mode !== 'embedded'`)
|
|
426
|
+
// TOKENS reflects the consumer's CSS exclusively.
|
|
403
427
|
if (!map || map.mode === 'embedded') {
|
|
404
428
|
TOKENS = cloneTokens(EMBEDDED_TOKENS_SNAPSHOT);
|
|
405
429
|
return;
|
|
406
430
|
}
|
|
431
|
+
TOKENS = buildTokensFromScanned(map);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Per-theme / per-group set of token keys the consumer actually wrote in
|
|
436
|
+
* their own files. Distinct from TOKENS — which holds the FULL merged
|
|
437
|
+
* map (consumer overrides on top of imported Tailwind defaults) for
|
|
438
|
+
* runtime resolution. `CONSUMER_TOKEN_KEYS` exists so the Design Tokens
|
|
439
|
+
* panel can filter the display down to only what the consumer owns,
|
|
440
|
+
* avoiding noise like a leaked `serif: Cambria` or the lone Tailwind
|
|
441
|
+
* `spacing: 0.25rem` baseline that the consumer never wrote.
|
|
442
|
+
*
|
|
443
|
+
* Structure: `CONSUMER_TOKEN_KEYS[theme][group] = Set<key>`.
|
|
444
|
+
* Empty when no consumer-only scan is available (pre-scan, embedded
|
|
445
|
+
* fallback, DTCG mode where the file IS the consumer's source).
|
|
446
|
+
*/
|
|
447
|
+
export const CONSUMER_TOKEN_KEYS: Record<string, Record<string, Set<string>>> = {};
|
|
407
448
|
|
|
408
|
-
|
|
409
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Mirror of `applyScannedTokens` but for the consumer-only scan result
|
|
451
|
+
* coming from `readConsumerOwnedTokenMap`. Populates `CONSUMER_TOKEN_KEYS`
|
|
452
|
+
* so demoFrame* can filter displayed entries to consumer overrides only.
|
|
453
|
+
*/
|
|
454
|
+
export function applyConsumerOwnedTokens(map: ScannedTokenMap | null | undefined): void {
|
|
455
|
+
// Wipe — a re-scan replaces the previous run's set wholesale.
|
|
456
|
+
for (const k of Object.keys(CONSUMER_TOKEN_KEYS)) delete CONSUMER_TOKEN_KEYS[k];
|
|
457
|
+
if (!map || map.mode === 'embedded') return;
|
|
458
|
+
const patch = buildTokenPatchFromScanned(map);
|
|
459
|
+
for (const themeName of Object.keys(patch)) {
|
|
460
|
+
const themeBlock = patch[themeName];
|
|
461
|
+
if (!themeBlock || typeof themeBlock !== 'object') continue;
|
|
462
|
+
const perGroup: Record<string, Set<string>> = {};
|
|
463
|
+
for (const groupName of Object.keys(themeBlock)) {
|
|
464
|
+
const group = (themeBlock as Record<string, unknown>)[groupName];
|
|
465
|
+
if (!group || typeof group !== 'object') continue;
|
|
466
|
+
perGroup[groupName] = new Set(Object.keys(group as Record<string, unknown>));
|
|
467
|
+
}
|
|
468
|
+
CONSUMER_TOKEN_KEYS[themeName] = perGroup;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Returns true when the consumer-only scan has the given theme/group/key
|
|
474
|
+
* combination. Behaviour:
|
|
475
|
+
*
|
|
476
|
+
* 1. `CONSUMER_TOKEN_KEYS` is COMPLETELY empty (no themes at all) →
|
|
477
|
+
* no consumer-only scan ran (pre-scan, embedded fallback, older
|
|
478
|
+
* CLI output without `consumerTokens`). Graceful fallback: return
|
|
479
|
+
* `true` for everything so the panel keeps its pre-Concern-#2
|
|
480
|
+
* behaviour and shows whatever's in TOKENS.
|
|
481
|
+
*
|
|
482
|
+
* 2. A scan ran (at least one theme has entries) but THIS theme is
|
|
483
|
+
* absent → consumer didn't write any tokens for this theme. Hide
|
|
484
|
+
* everything for this theme.
|
|
485
|
+
*
|
|
486
|
+
* 3. A scan ran, the theme has entries, but THIS group is absent →
|
|
487
|
+
* consumer didn't write anything in this group (e.g. greenhouse
|
|
488
|
+
* defines fonts + colors but no `--spacing-*` / `--breakpoint-*`).
|
|
489
|
+
* Hide everything for this group — that's the whole point of
|
|
490
|
+
* Concern #2.
|
|
491
|
+
*
|
|
492
|
+
* 4. Group is present → check membership.
|
|
493
|
+
*/
|
|
494
|
+
export function isConsumerOwnedToken(theme: string, group: string, key: string): boolean {
|
|
495
|
+
if (Object.keys(CONSUMER_TOKEN_KEYS).length === 0) return true;
|
|
496
|
+
const themeKeys = CONSUMER_TOKEN_KEYS[theme];
|
|
497
|
+
if (!themeKeys) return false;
|
|
498
|
+
const groupKeys = themeKeys[group];
|
|
499
|
+
if (!groupKeys) return false;
|
|
500
|
+
return groupKeys.has(key);
|
|
410
501
|
}
|
|
411
502
|
|
|
412
503
|
export function maybeApplyVariableTokens(): boolean {
|