inkbridge 0.1.0-beta.2 → 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 (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -1,4 +1,4 @@
1
- import { pxFromSizeToken } from './variables';
1
+ import { pxFromSizeToken } from '../tokens';
2
2
 
3
3
  export type UtilityAtom = {
4
4
  raw: string;
@@ -23,7 +23,17 @@ const MAX_WIDTH_MAP: Record<string, number> = {
23
23
  'max-w-7xl': 1280,
24
24
  };
25
25
 
26
+ // Memo for `parseUtilityClass` — the function is pure and the same class
27
+ // strings repeat heavily across a render tree (`flex` / `items-center` /
28
+ // `text-sm` appear on dozens of elements per story). The atom is treated
29
+ // as read-only by callers, so sharing the same instance across call sites
30
+ // is safe.
31
+ const PARSE_UTILITY_CACHE = new Map<string, UtilityAtom>();
32
+
26
33
  export function parseUtilityClass(input: string): UtilityAtom {
34
+ const cached = PARSE_UTILITY_CACHE.get(input);
35
+ if (cached !== undefined) return cached;
36
+
27
37
  let cls = input.trim();
28
38
  let important = false;
29
39
  if (cls.startsWith('!')) {
@@ -51,10 +61,11 @@ export function parseUtilityClass(input: string): UtilityAtom {
51
61
  }
52
62
  if (buf) parts.push(buf);
53
63
 
54
- if (parts.length <= 1) {
55
- return { raw: input, variants: [], important, utility: parts[0] || '' };
56
- }
57
- return { raw: input, variants: parts.slice(0, -1), important, utility: parts[parts.length - 1] };
64
+ const atom: UtilityAtom = parts.length <= 1
65
+ ? { raw: input, variants: [], important, utility: parts[0] || '' }
66
+ : { raw: input, variants: parts.slice(0, -1), important, utility: parts[parts.length - 1] };
67
+ PARSE_UTILITY_CACHE.set(input, atom);
68
+ return atom;
58
69
  }
59
70
 
60
71
  export function hasResponsiveVariant(variants: string[]): boolean {
@@ -120,6 +131,16 @@ export function resolveRadius(utility: string, radiusGroup: Record<string, strin
120
131
  if (radiusGroup && radiusGroup[key]) {
121
132
  return pxFromSizeToken(radiusGroup[key]);
122
133
  }
123
- const fallback: Record<string, number> = { none: 0, sm: 2, md: 6, lg: 8, xl: 12, '2xl': 16, '3xl': 24, full: 9999 };
134
+ const fallback: Record<string, number> = {
135
+ none: 0,
136
+ sm: 2,
137
+ md: 6,
138
+ lg: 8,
139
+ xl: 12,
140
+ '2xl': 16,
141
+ '3xl': 24,
142
+ '4xl': 32,
143
+ full: 9999,
144
+ };
124
145
  return fallback[key] ?? null;
125
146
  }
@@ -1,5 +1,3 @@
1
- declare const figma: any;
2
-
3
1
  type FontStyleIndex = Record<string, string[]>;
4
2
 
5
3
  let AVAILABLE_STYLES_BY_FAMILY: FontStyleIndex | null = null;
@@ -0,0 +1,4 @@
1
+ export * from './text-builder';
2
+ export * from './text-line';
3
+ export * from './inline-text';
4
+ export * from './font-style-resolver';
@@ -1,12 +1,12 @@
1
- import { type RGB, parseColor } from './colors';
2
- import { resolveTextColorValue, extractTextColorToken } from './color-resolver';
3
- import { getLineHeightFromClasses, TAG_TYPOGRAPHY, createTextNode } from './text-builder';
4
- import { tailwindClassesToStyle, type TailwindStyle } from './tailwind';
5
- import { type NodeIR, type NodeIRElement } from './node-ir';
6
- import type { RenderContext } from './render-context';
7
- import { type NodeLayoutComputed } from './width-solver';
1
+ import { type RGB, parseColor } from '../tokens';
2
+ import { resolveTextColorValue, extractTextColorToken } from '../tokens';
3
+ import { getLineHeightFromClasses, TAG_TYPOGRAPHY, createTextNode, type CreateTextOptions } from './text-builder';
4
+ import { tailwindClassesToStyle, type TailwindStyle } from '../tailwind';
5
+ import { type NodeIR, type NodeIRElement } from '../tailwind';
6
+ import type { RenderContext } from '../design-system';
7
+ import { type NodeLayoutComputed } from '../layout';
8
8
  import { INLINE_TEXT_TAGS } from './text-builder';
9
- import { TOKENS, getThemeFontFamily } from './tokens';
9
+ import { TOKENS, getThemeFontFamily } from '../tokens';
10
10
  import { getFontStyleCandidatesForFamily, inferFontWeight } from './font-style-resolver';
11
11
 
12
12
  export type InlineTextSegment = {
@@ -166,7 +166,7 @@ export function createInlineTextNode(
166
166
  const textValue = segments.map(seg => seg.text).join('');
167
167
 
168
168
  const typo = TAG_TYPOGRAPHY[node.tagLower] || { fontSize: 14 };
169
- const textOpts: any = {
169
+ const textOpts: CreateTextOptions & { theme?: string } = {
170
170
  fontSize: typo.fontSize,
171
171
  lineHeight: typo.lineHeight,
172
172
  };
@@ -188,7 +188,7 @@ export function createInlineTextNode(
188
188
  };
189
189
  if (sizeMap[sizeClass]) textOpts.fontSize = sizeMap[sizeClass];
190
190
  }
191
- const lineHeight = getLineHeightFromClasses(node.classes, textOpts.fontSize);
191
+ const lineHeight = getLineHeightFromClasses(node.classes, textOpts.fontSize ?? 14);
192
192
  if (lineHeight) {
193
193
  textOpts.lineHeight = lineHeight;
194
194
  }
@@ -196,11 +196,11 @@ export function createInlineTextNode(
196
196
  textOpts.theme = theme;
197
197
  const textNode = createTextNode(textValue, textOpts);
198
198
 
199
- const baseFontName = textNode.fontName && typeof textNode.fontName === 'object' ? textNode.fontName : null;
200
- const fontFamily = (baseFontName && 'family' in baseFontName ? (baseFontName as any).family : null)
199
+ const baseFontName: FontName | null = textNode.fontName !== figma.mixed ? textNode.fontName : null;
200
+ const fontFamily = (baseFontName ? baseFontName.family : null)
201
201
  || getThemeFontFamily(TOKENS, theme)
202
202
  || 'Inter';
203
- const baseStyle = baseFontName && 'style' in baseFontName ? (baseFontName as any).style : (baseFontStyle || 'Regular');
203
+ const baseStyle = baseFontName ? baseFontName.style : (baseFontStyle || 'Regular');
204
204
  let cursor = 0;
205
205
  for (const seg of segments) {
206
206
  const start = cursor;
@@ -1,10 +1,23 @@
1
- import { parseColor } from './colors';
2
- import { TOKENS, getCoreFontFamily, getThemeFontFamily } from './tokens';
3
- import { tailwindClassesToStyle, type TailwindStyle } from './tailwind';
4
- import { type NodeIR, isElementLikeNode } from './node-ir';
1
+ import { parseColor } from '../tokens';
2
+ import { TOKENS, getCoreFontFamily, getThemeFontFamily } from '../tokens';
3
+ import type { RGB } from '../tokens/colors';
4
+ import { tailwindClassesToStyle, type TailwindStyle } from '../tailwind';
5
+ import { type NodeIR, isElementLikeNode } from '../tailwind';
5
6
  import { getFontStyleCandidatesForFamily } from './font-style-resolver';
6
7
 
7
- declare const figma: any;
8
+ export interface CreateTextOptions {
9
+ fontStyle?: string;
10
+ bold?: boolean;
11
+ fontRole?: string;
12
+ fontFamily?: string;
13
+ theme?: string;
14
+ fontSize?: number;
15
+ lineHeight?: number;
16
+ textAlignHorizontal?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED';
17
+ textAlignVertical?: 'TOP' | 'CENTER' | 'BOTTOM';
18
+ fill?: string | RGB;
19
+ opacity?: number;
20
+ }
8
21
 
9
22
  const FONT_LOAD_STATE: Record<string, 'loading' | 'loaded'> = {};
10
23
  const FONT_LOAD_PROMISES: Promise<void>[] = [];
@@ -48,10 +61,10 @@ function queueFontLoad(family: string, style: string): void {
48
61
  }
49
62
  }
50
63
 
51
- export function createTextNode(text: string, options?: any): any {
64
+ export function createTextNode(text: string, options?: CreateTextOptions): TextNode {
52
65
  const node = figma.createText();
53
66
  node.characters = text;
54
- const opts = options || {};
67
+ const opts: CreateTextOptions = options || {};
55
68
  const style = opts.fontStyle || (opts.bold ? 'Bold' : 'Regular');
56
69
  const fontRole = opts.fontRole || 'sans';
57
70
  const fontFamily = opts.fontFamily || (opts.theme ? getThemeFontFamily(TOKENS, opts.theme, fontRole) : null) || getCoreFontFamily(TOKENS) || 'Inter';
@@ -80,6 +93,10 @@ export function createTextNode(text: string, options?: any): any {
80
93
  if (opts.textAlignHorizontal) {
81
94
  node.textAlignHorizontal = opts.textAlignHorizontal;
82
95
  }
96
+ // Default to CENTER: identical to TOP for hug-sized single-line text, but
97
+ // correct when the text box is taller (row-synced in flex/grid with
98
+ // items-center, or layoutAlign=STRETCH). Callers can override with 'TOP'.
99
+ node.textAlignVertical = opts.textAlignVertical || 'CENTER';
83
100
  if (opts.fill) {
84
101
  const fill = typeof opts.fill === 'string' ? parseColor(opts.fill) : opts.fill;
85
102
  node.fills = [{
@@ -1,5 +1,5 @@
1
- import { type NodeIR, type NodeIRComponent, splitClassName } from './node-ir';
2
- import { parseTextLineCount, parseWidthTokenList, TEXT_LINE_WIDTH_CLASSES } from './render-context';
1
+ import { type NodeIR, type NodeIRComponent, splitClassName } from '../tailwind';
2
+ import { parseTextLineCount, parseWidthTokenList, TEXT_LINE_WIDTH_CLASSES } from '../design-system';
3
3
 
4
4
  export function buildTextLinePlaceholderNode(node: NodeIRComponent): NodeIR {
5
5
  const props = node.props || {};
@@ -1,12 +1,12 @@
1
1
  import { COMPONENT_DEFS } from './tokens';
2
- import { extractFrameProperties, propsToTailwindClasses } from './tailwind';
2
+ import { extractFrameProperties, propsToTailwindClasses, type FrameProperties } from '../tailwind';
3
3
 
4
4
  interface ComponentFrame {
5
- frame: any;
5
+ frame: SceneNode;
6
6
  componentName: string;
7
7
  variant: string;
8
8
  state: string;
9
- props: any;
9
+ props: FrameProperties;
10
10
  }
11
11
 
12
12
  interface ChangeDetail {
@@ -21,7 +21,7 @@ interface ComponentChange {
21
21
  type: string;
22
22
  file: string;
23
23
  changes: ChangeDetail[];
24
- figmaProps: any;
24
+ figmaProps: FrameProperties;
25
25
  suggestedClasses: string[];
26
26
  }
27
27
 
@@ -38,10 +38,10 @@ interface ComponentPatch {
38
38
  }
39
39
 
40
40
  export function findComponentFrames(): ComponentFrame[] {
41
- let dsPage: any = null;
42
- for (let i = 0; i < (figma as any).root.children.length; i++) {
43
- if ((figma as any).root.children[i].name === 'Design System') {
44
- dsPage = (figma as any).root.children[i];
41
+ let dsPage: PageNode | null = null;
42
+ for (let i = 0; i < figma.root.children.length; i++) {
43
+ if (figma.root.children[i].name === 'Design System') {
44
+ dsPage = figma.root.children[i];
45
45
  break;
46
46
  }
47
47
  }
@@ -53,7 +53,7 @@ export function findComponentFrames(): ComponentFrame[] {
53
53
  const components: ComponentFrame[] = [];
54
54
 
55
55
  // Find component frames by name pattern: "ComponentName/variant/state" or "ComponentName States"
56
- function searchFrames(node: any, depth: number): void {
56
+ function searchFrames(node: SceneNode | PageNode, depth: number): void {
57
57
  if (depth > 5) return; // Limit depth
58
58
 
59
59
  if (node.type === 'FRAME' || node.type === 'COMPONENT') {
@@ -101,12 +101,12 @@ export function detectComponentChanges(): { changes?: ComponentChange[]; error?:
101
101
  }
102
102
 
103
103
  // Compare with code definitions
104
- if (!COMPONENT_DEFS || !(COMPONENT_DEFS as any).components) {
104
+ if (!COMPONENT_DEFS || !COMPONENT_DEFS.components) {
105
105
  return { error: 'No component definitions loaded. Start dev server and refresh.' };
106
106
  }
107
107
 
108
- for (let j = 0; j < (COMPONENT_DEFS as any).components.length; j++) {
109
- const codeDef = (COMPONENT_DEFS as any).components[j];
108
+ for (let j = 0; j < COMPONENT_DEFS.components.length; j++) {
109
+ const codeDef = COMPONENT_DEFS.components[j];
110
110
  const figmaFrames = figmaByComponent[codeDef.name] || [];
111
111
 
112
112
  if (figmaFrames.length === 0) {
@@ -1,5 +1,6 @@
1
1
  import { TOKENS, getThemeColors } from './tokens';
2
2
  import type { RGB } from './colors';
3
+ import { getBaseClass } from '../tailwind';
3
4
 
4
5
  const NON_COLOR_TEXT_PREFIXES = new Set([
5
6
  'xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', '8xl', '9xl',
@@ -11,12 +12,6 @@ const NON_COLOR_TEXT_TOKENS = new Set([
11
12
  'inherit',
12
13
  ]);
13
14
 
14
- function getBaseClass(value: string): string | null {
15
- if (!value) return null;
16
- if (value.indexOf(':') !== -1) return null;
17
- return value;
18
- }
19
-
20
15
  function isRgbObject(value: unknown): value is RGB {
21
16
  if (!value || typeof value !== 'object') return false;
22
17
  const obj = value as Record<string, unknown>;
@@ -1,3 +1,5 @@
1
+ import type { Tokens } from './tokens';
2
+
1
3
  // --- Color parsing: OKLCH -> sRGB ---
2
4
 
3
5
  export interface RGB {
@@ -20,8 +22,12 @@ export const DEBUG = false;
20
22
  export function debug(...args: unknown[]): void {
21
23
  if (!DEBUG) return;
22
24
  try {
23
- console.log.apply(console, ["[TailwindTokens]"].concat([].slice.call(args)));
24
- } catch (err) {}
25
+ // Rest params compile to value-side arg-collection at es2017; the
26
+ // Figma sandbox supports this fine. What it can't handle is
27
+ // value-side call/array spread (`fn(...args)`, `[...arr]`) — see
28
+ // .ai/behaviour.md.
29
+ console.log.apply(console, (['[TailwindTokens]'] as unknown[]).concat(args));
30
+ } catch (_err) { /* ignore */ }
25
31
  }
26
32
 
27
33
  export function clamp01(x: number): number {
@@ -162,14 +168,15 @@ export function normalizeSizeValue(value: unknown): string {
162
168
 
163
169
  // --- Color index for nearest-color lookups ---
164
170
 
165
- export function rebuildColorIndex(tokens: any): void {
171
+ export function rebuildColorIndex(tokens: Tokens): void {
166
172
  COLOR_INDEX.length = 0;
167
173
  for (const theme of Object.keys(tokens)) {
168
- const col: Record<string, unknown> = (tokens[theme] && tokens[theme].color) ? tokens[theme].color : {};
169
- for (const [token, value] of Object.entries(col)) {
174
+ const themeTokens = tokens[theme];
175
+ if (!themeTokens || !('color' in themeTokens) || !themeTokens.color) continue;
176
+ for (const [token, value] of Object.entries(themeTokens.color)) {
170
177
  try {
171
178
  COLOR_INDEX.push({ token, rgb: parseColor(value) });
172
- } catch (err) {
179
+ } catch (_err) {
173
180
  // ignore bad color
174
181
  }
175
182
  }
@@ -0,0 +1,6 @@
1
+ export * from './tokens';
2
+ export * from './token-source';
3
+ export * from './variables';
4
+ export * from './colors';
5
+ export * from './color-resolver';
6
+ export * from './change-detection';
@@ -1,4 +1,4 @@
1
- export type TokenSourceMode = 'auto' | 'css' | 'dtcg';
1
+ export type TokenSourceMode = 'css' | 'dtcg';
2
2
  export type ResolvedTokenSourceMode = 'css' | 'dtcg' | 'embedded';
3
3
 
4
4
  export interface ScannedThemeTokens {
@@ -8,6 +8,7 @@ export interface ScannedThemeTokens {
8
8
  spacing?: Record<string, number>;
9
9
  fontSize?: Record<string, number>;
10
10
  shadows?: Record<string, string>;
11
+ breakpoints?: Record<string, number>;
11
12
  }
12
13
 
13
14
  export interface ScannedTokenMap {
@@ -20,6 +21,7 @@ export interface ScannedTokenMap {
20
21
  spacing: Record<string, number>;
21
22
  fontSize: Record<string, number>;
22
23
  shadows: Record<string, string>;
24
+ breakpoints: Record<string, number>;
23
25
  themes: Record<string, ScannedThemeTokens>;
24
26
  }
25
27
 
@@ -38,6 +40,7 @@ export function createEmptyScannedTokenMap(
38
40
  spacing: {},
39
41
  fontSize: {},
40
42
  shadows: {},
43
+ breakpoints: {},
41
44
  themes: {},
42
45
  };
43
46
  }
@@ -1,6 +1,7 @@
1
1
  import { parseColor, mergeTokens as deepMergeTokens } from './colors';
2
2
  import { readVariableTokens } from './variables';
3
3
  import type { ScannedTokenMap } from './token-source';
4
+ import type { ComponentDef } from '../components/scanner-types';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Types
@@ -9,7 +10,7 @@ import type { ScannedTokenMap } from './token-source';
9
10
  /** A flat record of token name -> value (e.g. color name -> oklch string). */
10
11
  export type TokenGroup = Record<string, string>;
11
12
 
12
- /** A theme block contains optional color, radius, spacing, fontSize, and shadow groups. */
13
+ /** A theme block contains optional color, radius, spacing, fontSize, shadow, and breakpoint groups. */
13
14
  export interface ThemeTokens {
14
15
  color?: TokenGroup;
15
16
  font?: TokenGroup;
@@ -17,6 +18,7 @@ export interface ThemeTokens {
17
18
  spacing?: TokenGroup;
18
19
  fontSize?: TokenGroup;
19
20
  shadow?: TokenGroup;
21
+ breakpoint?: TokenGroup;
20
22
  }
21
23
 
22
24
  /** Core (non-theme) tokens. */
@@ -46,6 +48,7 @@ export interface DTCGTokens {
46
48
  description: string;
47
49
  note: string;
48
50
  };
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DTCG groups are deeply nested and dynamically built
49
52
  [key: string]: any;
50
53
  }
51
54
 
@@ -59,9 +62,9 @@ export interface ComponentDefs {
59
62
  schemaVersion?: number;
60
63
  version?: string;
61
64
  generatedAt?: string;
62
- components: any[];
63
- spacingScale: Record<string, any>;
64
- colorTokens: any[];
65
+ components: ComponentDef[];
66
+ spacingScale: Record<string, number>;
67
+ colorTokens: unknown[];
65
68
  paletteTokens?: Record<string, string>;
66
69
  iconRegistry?: Record<string, { module: string; exportName: string; svg: string }>;
67
70
  styleMap?: Record<string, { declarations: Record<string, string>; media?: string }[]>;
@@ -71,7 +74,7 @@ export interface ComponentDefs {
71
74
  // Mutable state
72
75
  // ---------------------------------------------------------------------------
73
76
 
74
- export let TOKENS: Tokens = {"primary":{"color":{"background":"oklch(1 0 0)","foreground":"oklch(0.141 0.005 285.823)","card":"oklch(1 0 0)","card-foreground":"oklch(0.141 0.005 285.823)","popover":"oklch(1 0 0)","popover-foreground":"oklch(0.141 0.005 285.823)","primary":"oklch(62.71% 0.17 149.21)","primary-foreground":"oklch(0.982 0.018 155.826)","secondary":"oklch(0.967 0.001 286.375)","secondary-foreground":"oklch(0.21 0.006 285.885)","muted":"oklch(0.967 0.001 286.375)","muted-foreground":"oklch(0.552 0.016 285.938)","accent":"oklch(0.967 0.001 286.375)","accent-foreground":"oklch(0.21 0.006 285.885)","destructive":"oklch(0.577 0.245 27.325)","border":"oklch(0.92 0.004 286.32)","input":"oklch(0.92 0.004 286.32)","ring":"oklch(0.723 0.219 149.579)","sidebar":"oklch(0.985 0 0)","sidebar-foreground":"oklch(0.141 0.005 285.823)","sidebar-primary":"oklch(0.723 0.219 149.579)","sidebar-primary-foreground":"oklch(0.982 0.018 155.826)","sidebar-accent":"oklch(0.967 0.001 286.375)","sidebar-accent-foreground":"oklch(0.21 0.006 285.885)","sidebar-border":"oklch(0.92 0.004 286.32)","sidebar-ring":"oklch(0.723 0.219 149.579)"},"font":{"sans":"\"Open Sans\", ui-sans-serif, system-ui, sans-serif","heading":"\"Open Sans\", ui-sans-serif, system-ui, sans-serif"},"radius":{"base":"0.5rem","sm":"0.25rem","md":"0.5rem","lg":"0.75rem","xl":"1rem","2xl":"1.25rem","full":"624rem"},"spacing":{"xs":"0.25rem","sm":"0.5rem","md":"1rem","lg":"1.5rem","xl":"2rem","2xl":"3rem","3xl":"4rem","4xl":"6rem"},"fontSize":{"xs":"0.75rem","sm":"0.875rem","base":"1rem","lg":"1.125rem","xl":"1.25rem","2xl":"1.5rem","3xl":"1.875rem","4xl":"2.25rem","5xl":"3rem","6xl":"3.75rem"},"shadow":{"sm":"0 1px 2px 0 rgb(0 0 0 / 0.05)","DEFAULT":"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)","md":"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)","lg":"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.05)","xl":"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.04)","2xl":"0 25px 50px -12px rgb(0 0 0 / 0.25)","inner":"inset 0 2px 4px 0 rgb(0 0 0 / 0.05)"}},"secondary":{"color":{"background":"oklch(1 0 0)","foreground":"oklch(0.141 0.005 285.823)","card":"oklch(1 0 0)","card-foreground":"oklch(0.141 0.005 285.823)","popover":"oklch(1 0 0)","popover-foreground":"oklch(0.141 0.005 285.823)","primary":"oklch(62.71% 0.17 250)","primary-foreground":"oklch(0.982 0.018 250)","secondary":"oklch(0.967 0.001 286.375)","secondary-foreground":"oklch(0.21 0.006 285.885)","muted":"oklch(0.967 0.001 286.375)","muted-foreground":"oklch(0.552 0.016 285.938)","accent":"oklch(0.967 0.001 286.375)","accent-foreground":"oklch(0.21 0.006 285.885)","destructive":"oklch(0.577 0.245 250)","border":"oklch(0.92 0.004 286.32)","input":"oklch(0.92 0.004 286.32)","ring":"oklch(0.723 0.219 250)","sidebar":"oklch(0.985 0 0)","sidebar-foreground":"oklch(0.141 0.005 285.823)","sidebar-primary":"oklch(0.723 0.219 250)","sidebar-primary-foreground":"oklch(0.982 0.018 250)","sidebar-accent":"oklch(0.967 0.001 286.375)","sidebar-accent-foreground":"oklch(0.21 0.006 285.885)","sidebar-border":"oklch(0.92 0.004 286.32)","sidebar-ring":"oklch(0.723 0.219 250)"},"font":{"sans":"\"Inter\", ui-sans-serif, system-ui, sans-serif","heading":"\"Playfair Display\", Georgia, serif"},"radius":{"base":"1rem","sm":"0.75rem","md":"1rem","lg":"1.25rem","xl":"1.5rem","2xl":"1.75rem","full":"624rem"}}};
77
+ export let TOKENS: Tokens = {"primary":{"color":{"background":"oklch(1 0 0)","foreground":"oklch(0.141 0.005 285.823)","card":"oklch(1 0 0)","card-foreground":"oklch(0.141 0.005 285.823)","popover":"oklch(1 0 0)","popover-foreground":"oklch(0.141 0.005 285.823)","primary":"oklch(50% 0.14 145)","primary-foreground":"oklch(0.982 0.018 155.826)","primary-decorative":"oklch(62.71% 0.17 149.21)","primary-soft":"oklch(0.84 0.06 145)","secondary":"oklch(0.967 0.001 286.375)","secondary-foreground":"oklch(0.21 0.006 285.885)","muted":"oklch(0.967 0.001 286.375)","muted-foreground":"oklch(0.552 0.016 285.938)","accent":"oklch(0.967 0.001 286.375)","accent-foreground":"oklch(0.21 0.006 285.885)","destructive":"oklch(0.577 0.245 27.325)","border":"oklch(0.92 0.004 286.32)","input":"oklch(0.92 0.004 286.32)","ring":"oklch(0.723 0.219 149.579)","sidebar":"oklch(0.985 0 0)","sidebar-foreground":"oklch(0.141 0.005 285.823)","sidebar-primary":"oklch(0.723 0.219 149.579)","sidebar-primary-foreground":"oklch(0.982 0.018 155.826)","sidebar-accent":"oklch(0.967 0.001 286.375)","sidebar-accent-foreground":"oklch(0.21 0.006 285.885)","sidebar-border":"oklch(0.92 0.004 286.32)","sidebar-ring":"oklch(0.723 0.219 149.579)"},"font":{"sans":"\"Open Sans\", ui-sans-serif, system-ui, sans-serif","heading":"\"Open Sans\", ui-sans-serif, system-ui, sans-serif"},"radius":{"base":"0.5rem","sm":"0.25rem","md":"0.5rem","lg":"0.75rem","xl":"1rem","2xl":"1.25rem","full":"624rem"},"spacing":{"xs":"0.25rem","sm":"0.5rem","md":"1rem","lg":"1.5rem","xl":"2rem","2xl":"3rem","3xl":"4rem","4xl":"6rem"},"fontSize":{"xs":"0.75rem","sm":"0.875rem","base":"1rem","lg":"1.125rem","xl":"1.25rem","2xl":"1.5rem","3xl":"1.875rem","4xl":"2.25rem","5xl":"3rem","6xl":"3.75rem"},"shadow":{"sm":"0 1px 2px 0 rgb(0 0 0 / 0.05)","DEFAULT":"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)","md":"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)","lg":"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.05)","xl":"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.04)","2xl":"0 25px 50px -12px rgb(0 0 0 / 0.25)","inner":"inset 0 2px 4px 0 rgb(0 0 0 / 0.05)"}},"secondary":{"color":{"background":"oklch(1 0 0)","foreground":"oklch(0.141 0.005 285.823)","card":"oklch(1 0 0)","card-foreground":"oklch(0.141 0.005 285.823)","popover":"oklch(1 0 0)","popover-foreground":"oklch(0.141 0.005 285.823)","primary":"oklch(50% 0.16 250)","primary-foreground":"oklch(0.982 0.018 250)","primary-decorative":"oklch(62.71% 0.17 250)","primary-soft":"oklch(0.84 0.06 250)","secondary":"oklch(0.967 0.001 286.375)","secondary-foreground":"oklch(0.21 0.006 285.885)","muted":"oklch(0.967 0.001 286.375)","muted-foreground":"oklch(0.552 0.016 285.938)","accent":"oklch(0.967 0.001 286.375)","accent-foreground":"oklch(0.21 0.006 285.885)","destructive":"oklch(0.577 0.245 250)","border":"oklch(0.92 0.004 286.32)","input":"oklch(0.92 0.004 286.32)","ring":"oklch(0.723 0.219 250)","sidebar":"oklch(0.985 0 0)","sidebar-foreground":"oklch(0.141 0.005 285.823)","sidebar-primary":"oklch(0.723 0.219 250)","sidebar-primary-foreground":"oklch(0.982 0.018 250)","sidebar-accent":"oklch(0.967 0.001 286.375)","sidebar-accent-foreground":"oklch(0.21 0.006 285.885)","sidebar-border":"oklch(0.92 0.004 286.32)","sidebar-ring":"oklch(0.723 0.219 250)"},"font":{"sans":"\"Open Sans\", ui-sans-serif, system-ui, sans-serif","heading":"\"Playfair Display\", Georgia, serif"},"radius":{"base":"1rem","sm":"0.75rem","md":"1rem","lg":"1.25rem","xl":"1.5rem","2xl":"1.75rem","full":"624rem"}}};
75
78
  const EMBEDDED_TOKENS_SNAPSHOT: Tokens = JSON.parse(JSON.stringify(TOKENS));
76
79
 
77
80
  // Component definitions - fetched from dev server at runtime
@@ -86,18 +89,21 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
86
89
  return v !== null && typeof v === 'object' && !Array.isArray(v);
87
90
  }
88
91
 
89
- function mergeTokens(base: any, override: any): any {
92
+ function mergeTokens<T>(base: T, override: T): T {
90
93
  if (!isPlainObject(base)) return override;
91
- const out: Record<string, any> = {};
92
- for (const key in base) out[key] = base[key];
94
+ if (!isPlainObject(override)) return base;
95
+ const out: Record<string, unknown> = {};
96
+ for (const key in base) out[key] = (base as Record<string, unknown>)[key];
93
97
  for (const key in override) {
94
- if (isPlainObject(base[key]) && isPlainObject(override[key])) {
95
- out[key] = mergeTokens(base[key], override[key]);
98
+ const baseVal = (base as Record<string, unknown>)[key];
99
+ const overrideVal = (override as Record<string, unknown>)[key];
100
+ if (isPlainObject(baseVal) && isPlainObject(overrideVal)) {
101
+ out[key] = mergeTokens(baseVal, overrideVal);
96
102
  } else {
97
- out[key] = override[key];
103
+ out[key] = overrideVal;
98
104
  }
99
105
  }
100
- return out;
106
+ return out as T;
101
107
  }
102
108
 
103
109
  function parseDimensionToPx(value: unknown): number | null {
@@ -293,16 +299,53 @@ function hasKeys(group: Record<string, unknown> | undefined): boolean {
293
299
  return !!group && Object.keys(group).length > 0;
294
300
  }
295
301
 
302
+ /**
303
+ * Ensures a font-family CSS value always contains a quoted literal name so that
304
+ * round-trips through patchCssVariables are self-healing.
305
+ *
306
+ * e.g. `var(--font-playfair), Georgia, serif`
307
+ * → `var(--font-playfair), "Playfair", Georgia, serif`
308
+ *
309
+ * If a proper literal is already present the value is returned unchanged.
310
+ */
311
+ function enrichFontValue(value: string): string {
312
+ const parts = value.split(',');
313
+ let hasLiteral = false;
314
+ let insertAfterIdx = -1;
315
+
316
+ for (let i = 0; i < parts.length; i++) {
317
+ const trimmed = parts[i].trim();
318
+ if (/^var\(/.test(trimmed)) {
319
+ if (insertAfterIdx === -1) insertAfterIdx = i;
320
+ continue;
321
+ }
322
+ const lower = trimmed.toLowerCase().replace(/^["']|["']$/g, '');
323
+ if (SYSTEM_FONT_KEYWORDS.has(lower)) continue;
324
+ if (trimmed.replace(/^["']|["']$/g, '').trim()) { hasLiteral = true; break; }
325
+ }
326
+
327
+ if (hasLiteral || insertAfterIdx === -1) return value;
328
+
329
+ // Derive best-effort literal from the first var(--font-*) name
330
+ const varMatch = parts[insertAfterIdx].trim().match(/^var\(--font-([a-z0-9-]+)\)/i);
331
+ if (!varMatch) return value;
332
+ const literal = varMatch[1].split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
333
+ const result = parts.slice();
334
+ result.splice(insertAfterIdx + 1, 0, ` "${literal}"`);
335
+ return result.join(',');
336
+ }
337
+
296
338
  function buildTokenPatchFromScanned(map: ScannedTokenMap): Tokens {
297
339
  const patch: Tokens = {};
298
340
 
299
341
  const primary: ThemeTokens = {};
300
342
  if (hasKeys(map.colors)) primary.color = { ...map.colors };
301
- if (hasKeys(map.fonts)) primary.font = { ...map.fonts };
343
+ if (hasKeys(map.fonts)) primary.font = Object.fromEntries(Object.entries(map.fonts).map(([k, v]) => [k, enrichFontValue(String(v))]));
302
344
  if (hasKeys(map.radius)) primary.radius = mapDimensionGroup(map.radius);
303
345
  if (hasKeys(map.spacing)) primary.spacing = mapDimensionGroup(map.spacing);
304
346
  if (hasKeys(map.fontSize)) primary.fontSize = mapDimensionGroup(map.fontSize);
305
347
  if (hasKeys(map.shadows)) primary.shadow = { ...map.shadows };
348
+ if (hasKeys(map.breakpoints)) primary.breakpoint = mapDimensionGroup(map.breakpoints);
306
349
  if (Object.keys(primary).length > 0) patch.primary = primary;
307
350
 
308
351
  for (const themeName in map.themes) {
@@ -310,11 +353,12 @@ function buildTokenPatchFromScanned(map: ScannedTokenMap): Tokens {
310
353
  if (!scanned) continue;
311
354
  const themedPatch: ThemeTokens = {};
312
355
  if (hasKeys(scanned.colors)) themedPatch.color = { ...scanned.colors };
313
- if (hasKeys(scanned.fonts)) themedPatch.font = { ...scanned.fonts };
356
+ if (hasKeys(scanned.fonts)) themedPatch.font = Object.fromEntries(Object.entries(scanned.fonts as Record<string, string>).map(([k, v]) => [k, enrichFontValue(v)]));
314
357
  if (hasKeys(scanned.radius)) themedPatch.radius = mapDimensionGroup(scanned.radius as Record<string, number>);
315
358
  if (hasKeys(scanned.spacing)) themedPatch.spacing = mapDimensionGroup(scanned.spacing as Record<string, number>);
316
359
  if (hasKeys(scanned.fontSize)) themedPatch.fontSize = mapDimensionGroup(scanned.fontSize as Record<string, number>);
317
360
  if (hasKeys(scanned.shadows)) themedPatch.shadow = { ...(scanned.shadows as Record<string, string>) };
361
+ if (hasKeys(scanned.breakpoints)) themedPatch.breakpoint = mapDimensionGroup(scanned.breakpoints as Record<string, number>);
318
362
  if (Object.keys(themedPatch).length > 0) patch[themeName] = themedPatch;
319
363
  }
320
364
 
@@ -455,6 +499,17 @@ export function getThemeFontSize(tokens: Tokens, theme: string): TokenGroup {
455
499
  return {};
456
500
  }
457
501
 
502
+ /** Get breakpoint tokens for a named theme, falling back to primary. */
503
+ export function getThemeBreakpoints(tokens: Tokens, theme: string): TokenGroup {
504
+ const block = tokens[theme];
505
+ if (block && 'breakpoint' in block) {
506
+ const themeBlock = block as ThemeTokens;
507
+ if (themeBlock.breakpoint && Object.keys(themeBlock.breakpoint).length) return themeBlock.breakpoint;
508
+ }
509
+ if (theme !== 'primary') return getThemeBreakpoints(tokens, 'primary');
510
+ return {};
511
+ }
512
+
458
513
  /** Get named theme keys in declaration order, excluding the core block. */
459
514
  export function getThemeNames(tokens: Tokens): string[] {
460
515
  return Object.keys(tokens).filter((key) => {
@@ -471,6 +526,45 @@ export function getThemeNames(tokens: Tokens): string[] {
471
526
 
472
527
  /** Get a font family for a specific theme and role ('sans' | 'heading' | 'mono').
473
528
  * Falls back: theme+role → theme+sans → primary+role → primary+sans → null. */
529
+ const SYSTEM_FONT_KEYWORDS = new Set([
530
+ // Generic families & system keywords
531
+ 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'system-ui',
532
+ 'sans-serif', 'serif', 'monospace', '-apple-system', 'inherit', 'initial',
533
+ // Common web-safe fonts that appear as fallbacks, not as the intended design font
534
+ 'arial', 'helvetica', 'georgia', 'verdana', 'tahoma', 'trebuchet ms',
535
+ 'times new roman', 'courier new', 'courier', 'palatino', 'garamond',
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',
543
+ ]);
544
+
545
+ export function extractFontName(raw: unknown): string | null {
546
+ const parts = String(raw || '').split(',');
547
+ let varFallback: string | null = null;
548
+ for (const part of parts) {
549
+ const trimmed = part.trim();
550
+ // Try to extract from CSS variable references like var(--font-open-sans)
551
+ const varMatch = trimmed.match(/^var\(--font-([a-z0-9-]+)\)/i);
552
+ if (varMatch) {
553
+ if (!varFallback) {
554
+ varFallback = varMatch[1].split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
555
+ }
556
+ continue;
557
+ }
558
+ // Skip system font keywords
559
+ const lower = trimmed.toLowerCase().replace(/^["']|["']$/g, '');
560
+ if (SYSTEM_FONT_KEYWORDS.has(lower)) continue;
561
+ const name = trimmed.replace(/^["']|["']$/g, '');
562
+ if (name) return name;
563
+ }
564
+ // Fall back to name derived from the CSS variable (e.g. var(--font-open-sans) → "Open Sans")
565
+ return varFallback;
566
+ }
567
+
474
568
  export function getThemeFontFamily(tokens: Tokens, theme: string, role: string = 'sans'): string | null {
475
569
  const block = tokens[theme];
476
570
  if (block && 'font' in block) {
@@ -478,8 +572,8 @@ export function getThemeFontFamily(tokens: Tokens, theme: string, role: string =
478
572
  if (font) {
479
573
  const raw = (font as TokenGroup)[role] || (role !== 'sans' ? (font as TokenGroup).sans : null) || Object.values(font as TokenGroup)[0];
480
574
  if (raw) {
481
- const first = String(raw).split(',')[0].trim().replace(/^["']|["']$/g, '');
482
- if (first) return first;
575
+ const name = extractFontName(raw);
576
+ if (name) return name;
483
577
  }
484
578
  }
485
579
  }
@@ -493,8 +587,10 @@ export function getCoreFontFamily(tokens: Tokens): string | null {
493
587
  if (core && core.font) {
494
588
  const raw = (core.font as TokenGroup).sans || Object.values(core.font as TokenGroup)[0];
495
589
  if (raw) {
496
- const first = String(raw).split(',')[0].trim().replace(/^["']|["']$/g, '');
497
- 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;
498
594
  }
499
595
  }
500
596
  return getThemeFontFamily(tokens, 'primary');
@@ -534,7 +630,7 @@ export function tokensToDTCG(tokens: Tokens): DTCGTokens {
534
630
 
535
631
  // Core (font)
536
632
  if (tokens.core) {
537
- dtcg.core = {} as Record<string, any>;
633
+ dtcg.core = {} as Record<string, Record<string, DTCGEntry>>;
538
634
  if (tokens.core.font) {
539
635
  dtcg.core.font = {} as Record<string, DTCGEntry>;
540
636
  for (const fk in tokens.core.font) {
@@ -554,7 +650,7 @@ export function tokensToDTCG(tokens: Tokens): DTCGTokens {
554
650
  for (let ti = 0; ti < themes.length; ti++) {
555
651
  const themeName = themes[ti];
556
652
  if (!tokens[themeName]) continue;
557
- dtcg[themeName] = {} as Record<string, any>;
653
+ dtcg[themeName] = {} as Record<string, Record<string, DTCGEntry>>;
558
654
 
559
655
  const themeBlock = tokens[themeName] as ThemeTokens;
560
656