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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -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 [{ text: node.text, fill: parentStyle.fill, fontStyle: parentStyle.fontStyle }];
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
- const nextStyle = { fill: fill, fontStyle: fontStyle };
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
- const segments = normalizeInlineSegments(
163
- collectInlineSegments(node, { fill: baseFill, fontStyle: baseFontStyle }, colorGroup, theme)
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
- if (seg.fontStyle && seg.fontStyle !== baseStyle) {
221
- const styleCandidates = getFontStyleCandidatesForFamily(fontFamily, seg.fontStyle);
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: fontFamily, style: candidate });
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
- if (shouldConstrain && constrainWidth) {
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);
@@ -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
+ }
@@ -369,44 +369,135 @@ function cloneTokens<T>(value: T): T {
369
369
  return JSON.parse(JSON.stringify(value));
370
370
  }
371
371
 
372
- function buildTokensFromScanned(map: ScannedTokenMap, baseSnapshot: Tokens): Tokens {
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
- const primaryTheme = mergeTokens(cloneTokens(basePrimary), scannedPrimary) as ThemeTokens;
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
- // CSS theme blocks are often overrides; inherit from primary and apply overrides.
396
- out[themeName] = mergeTokens(cloneTokens(primaryTheme), scannedTheme as ThemeTokens) as ThemeTokens;
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
- const base = cloneTokens(EMBEDDED_TOKENS_SNAPSHOT) as Tokens;
409
- TOKENS = buildTokensFromScanned(map, base);
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 {