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
@@ -166,20 +166,51 @@ export function resolveSpacing(value: string): number | undefined {
166
166
  // State Modifiers
167
167
  // ============================================================================
168
168
 
169
- export const STATE_MODIFIERS = [
169
+ /**
170
+ * Modifiers that imply the component has its OWN interactive/aria state
171
+ * (it receives focus, can be disabled directly, etc.). These are the
172
+ * trigger set for state-component classification — if a root className
173
+ * uses any of these, the component participates in a state-variant matrix
174
+ * and the plugin renders it via the state-master path.
175
+ *
176
+ * Deliberately excludes `group-*:` and `peer-*:` modifiers — those are
177
+ * *passive reactions* to a parent's or sibling's state (e.g. Label
178
+ * dimming when its `<input>` peer is `:disabled`). They don't justify
179
+ * promoting the component to a state primitive. A Label that only
180
+ * carries `peer-disabled:opacity-50` should stay `simple`, render its
181
+ * text children normally, and not get hoisted into a state-master that
182
+ * wipes its content.
183
+ */
184
+ export const OWN_STATE_MODIFIERS = [
170
185
  'hover:',
171
186
  'focus:',
172
187
  'focus-visible:',
173
188
  'focus-within:',
174
189
  'active:',
175
190
  'disabled:',
191
+ 'checked:',
176
192
  'aria-invalid:',
177
193
  'aria-selected:',
178
194
  'aria-expanded:',
179
195
  'data-[state=open]:',
180
196
  'data-[state=checked]:',
181
197
  'data-[state=active]:',
198
+ 'data-[checked]:',
182
199
  'data-[disabled]:',
200
+ ];
201
+
202
+ /**
203
+ * All recognized state modifiers — used during state-grouping after a
204
+ * component has been classified, so passive `group-*` / `peer-*`
205
+ * variants still get their classes captured even though they don't
206
+ * trigger classification on their own.
207
+ */
208
+ export const STATE_MODIFIERS = [
209
+ ...OWN_STATE_MODIFIERS,
210
+ 'group-data-[checked]:',
211
+ 'group-data-[disabled]:',
212
+ 'peer-data-[checked]:',
213
+ 'peer-data-[disabled]:',
183
214
  'group-hover:',
184
215
  'group-focus:',
185
216
  'peer-focus:',
@@ -377,13 +408,13 @@ export function extractPaletteTokens(classes: string[]): Record<string, string>
377
408
  const resolvePaletteColor = (token: string): string | null => {
378
409
  if (!token) return null;
379
410
  if (token === 'transparent' || token === 'current' || token === 'inherit') return null;
380
- const direct = (colors as any)[token];
411
+ const direct = (colors as Record<string, string | Record<string, string>>)[token];
381
412
  if (typeof direct === 'string') return direct;
382
413
  const parts = token.split('-');
383
414
  if (parts.length < 2) return null;
384
415
  const shade = parts[parts.length - 1];
385
416
  const name = parts.slice(0, -1).join('-');
386
- const group = (colors as any)[name];
417
+ const group = (colors as Record<string, string | Record<string, string>>)[name];
387
418
  if (group && typeof group === 'object' && group[shade]) return group[shade];
388
419
  return null;
389
420
  };
@@ -506,7 +537,18 @@ export function groupClassesByState(classes: string[]): Record<string, string[]>
506
537
  if (!groups[stateName]) {
507
538
  groups[stateName] = [];
508
539
  }
509
- groups[stateName].push(parsed.utility);
540
+ // Reconstitute the utility with its opacity suffix when one was
541
+ // parsed off — without this, `focus-visible:ring-ring/50` and
542
+ // `aria-invalid:ring-destructive/20` get stripped to plain
543
+ // `ring-ring` / `ring-destructive` (full opacity), losing the
544
+ // translucent look shadcn uses for soft focus / invalid rings.
545
+ // Symptom: State Matrix focus/error variants render solid bright
546
+ // rings instead of the brand-tinted, semi-transparent look the
547
+ // browser shows.
548
+ const utilityWithOpacity = parsed.opacity != null
549
+ ? parsed.utility + '/' + parsed.opacity
550
+ : parsed.utility;
551
+ groups[stateName].push(utilityWithOpacity);
510
552
  } else if (!parsed.modifier) {
511
553
  groups.default.push(cls);
512
554
  } else {
@@ -0,0 +1,173 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { resolveTextResizeWidth, type TextResizeFrameInput } from '../src/layout/text-resize-decision';
4
+
5
+ // MATRIX REGRESSION — text-resize-to-parent-content-width decision.
6
+ //
7
+ // This is fix-site (c) of the recurring inline-flex pill bug (see
8
+ // `tools/figma-plugin/.ai/troubleshooting.md`). The decision lives in the
9
+ // ui-builder's frame-with-text branch and gates whether the inserted text
10
+ // node is resized to `(parent.maxWidth - parent.padding)` or left HUG-sized.
11
+ //
12
+ // Before this matrix the playbook explicitly named this site as the
13
+ // "youngest fix and most likely to drift through refactors" — no automated
14
+ // coverage. Extracting the decision into a pure helper made it testable
15
+ // without stubbing the buildFigmaNode pipeline.
16
+ //
17
+ // Skip rules (text stays HUG-sized):
18
+ // - HORIZONTAL + primaryAxisAlignItems=CENTER → numbered-circle case
19
+ // - HORIZONTAL + primaryAxisSizingMode=AUTO (HUG) → inline-flex pill case
20
+ // - contextMaxWidth missing / 0 / negative → no resize to compute
21
+ // - padding consumes the entire maxWidth → no positive width to resize to
22
+
23
+ function makeFrame(overrides: Partial<TextResizeFrameInput> = {}): TextResizeFrameInput {
24
+ return {
25
+ layoutMode: 'NONE',
26
+ primaryAxisAlignItems: 'MIN',
27
+ primaryAxisSizingMode: 'AUTO',
28
+ paddingLeft: 0,
29
+ paddingRight: 0,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ interface Cell {
35
+ frame: Partial<TextResizeFrameInput>;
36
+ contextMaxWidth: number | undefined | null;
37
+ expect: number | undefined;
38
+ note: string;
39
+ }
40
+
41
+ const CELLS: Cell[] = [
42
+ // ---- resize-allowed cells ----------------------------------------------
43
+ {
44
+ frame: { layoutMode: 'NONE' },
45
+ contextMaxWidth: 600,
46
+ expect: 600,
47
+ note: 'NONE parent: resize text to context max width',
48
+ },
49
+ {
50
+ frame: { layoutMode: 'NONE', paddingLeft: 16, paddingRight: 16 },
51
+ contextMaxWidth: 600,
52
+ expect: 568,
53
+ note: 'padding subtracted from target width',
54
+ },
55
+ {
56
+ frame: { layoutMode: 'VERTICAL' },
57
+ contextMaxWidth: 600,
58
+ expect: 600,
59
+ note: 'VERTICAL parent: resize text — the common block-flow case',
60
+ },
61
+ {
62
+ frame: { layoutMode: 'VERTICAL', primaryAxisSizingMode: 'FIXED' },
63
+ contextMaxWidth: 600,
64
+ expect: 600,
65
+ note: 'VERTICAL + FIXED-primary still resizes (sizing mode only affects HORIZONTAL skip)',
66
+ },
67
+ {
68
+ frame: { layoutMode: 'HORIZONTAL', primaryAxisSizingMode: 'FIXED' },
69
+ contextMaxWidth: 600,
70
+ expect: 600,
71
+ note: 'HORIZONTAL + FIXED-primary (not HUG): resize allowed — only HORIZONTAL+HUG is the pill case',
72
+ },
73
+ {
74
+ frame: {
75
+ layoutMode: 'HORIZONTAL',
76
+ primaryAxisAlignItems: 'SPACE_BETWEEN',
77
+ primaryAxisSizingMode: 'FIXED',
78
+ },
79
+ contextMaxWidth: 600,
80
+ expect: 600,
81
+ note: 'SPACE_BETWEEN is not CENTER; resize allowed',
82
+ },
83
+ {
84
+ frame: { layoutMode: 'GRID' },
85
+ contextMaxWidth: 600,
86
+ expect: 600,
87
+ note: 'GRID parent: not HORIZONTAL, so no HUG/CENTER skip applies — resize allowed',
88
+ },
89
+
90
+ // ---- skip cells (the bugs that motivated the helper) -------------------
91
+ {
92
+ frame: { layoutMode: 'HORIZONTAL', primaryAxisSizingMode: 'AUTO' },
93
+ contextMaxWidth: 600,
94
+ expect: undefined,
95
+ note: 'HORIZONTAL + AUTO = HUG: inline-flex pill case (fix-site c) — must NOT resize',
96
+ },
97
+ {
98
+ frame: {
99
+ layoutMode: 'HORIZONTAL',
100
+ primaryAxisAlignItems: 'CENTER',
101
+ primaryAxisSizingMode: 'AUTO',
102
+ },
103
+ contextMaxWidth: 600,
104
+ expect: undefined,
105
+ note: 'HORIZONTAL + CENTER: numbered-circle case — must NOT resize',
106
+ },
107
+ {
108
+ frame: {
109
+ layoutMode: 'HORIZONTAL',
110
+ primaryAxisAlignItems: 'CENTER',
111
+ primaryAxisSizingMode: 'FIXED',
112
+ },
113
+ contextMaxWidth: 600,
114
+ expect: undefined,
115
+ note: 'HORIZONTAL + CENTER + FIXED: still CENTER skip applies — must NOT resize',
116
+ },
117
+
118
+ // ---- edge cases --------------------------------------------------------
119
+ {
120
+ frame: { layoutMode: 'NONE' },
121
+ contextMaxWidth: undefined,
122
+ expect: undefined,
123
+ note: 'missing context maxWidth: no resize',
124
+ },
125
+ {
126
+ frame: { layoutMode: 'NONE' },
127
+ contextMaxWidth: null,
128
+ expect: undefined,
129
+ note: 'null context maxWidth: no resize',
130
+ },
131
+ {
132
+ frame: { layoutMode: 'NONE' },
133
+ contextMaxWidth: 0,
134
+ expect: undefined,
135
+ note: 'zero context maxWidth: no resize',
136
+ },
137
+ {
138
+ frame: { layoutMode: 'NONE', paddingLeft: 400, paddingRight: 400 },
139
+ contextMaxWidth: 600,
140
+ expect: undefined,
141
+ note: 'padding > maxWidth: target would be negative — no resize (no degenerate textNode.resize(0))',
142
+ },
143
+ ];
144
+
145
+ function runRegression(): void {
146
+ const failures: string[] = [];
147
+
148
+ for (let i = 0; i < CELLS.length; i++) {
149
+ const cell = CELLS[i]!;
150
+ const frame = makeFrame(cell.frame);
151
+ const result = resolveTextResizeWidth(frame, cell.contextMaxWidth);
152
+ if (result !== cell.expect) {
153
+ failures.push(
154
+ `cell ${i}: expected ${cell.expect}, got ${result}\n → ${cell.note}\n → frame=${JSON.stringify(cell.frame)} ctxMaxWidth=${cell.contextMaxWidth}`,
155
+ );
156
+ }
157
+ }
158
+
159
+ if (failures.length > 0) {
160
+ for (const msg of failures) console.error(' ✗ ' + msg);
161
+ assert.fail(`${failures.length}/${CELLS.length} text-resize matrix cells failed`);
162
+ }
163
+
164
+ console.log(`text-resize-matrix-regression: PASS (${CELLS.length} cells)`);
165
+ }
166
+
167
+ try {
168
+ runRegression();
169
+ } catch (err) {
170
+ console.error('text-resize-matrix-regression: FAIL');
171
+ console.error(err);
172
+ process.exit(1);
173
+ }
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import {
3
3
  centerPlacedRotationTransform,
4
4
  rotationTransformAroundPointRadians,
5
- } from '../src/transform-math';
5
+ } from '../src/tailwind/transform-math';
6
6
 
7
7
  function approxEqual(actual: number, expected: number, epsilon = 0.0001): void {
8
8
  assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${expected}, got ${actual}`);
package/scanner/types.ts CHANGED
@@ -41,13 +41,23 @@ export interface StoryInfo {
41
41
  }
42
42
 
43
43
  /**
44
- * A single component usage found in a story (flat extraction)
44
+ * A single component usage found in a story (flat extraction).
45
+ *
46
+ * `props` is a string map for ordinary attributes (`variant="default"`,
47
+ * `size="sm"`, `className="..."`) but the scanner also stuffs a special
48
+ * `__jsxChildren` key with a `JsxNode[]` payload when the component has
49
+ * structural children that need to be re-rendered (e.g. a `<DialogTrigger>`
50
+ * inside `<Dialog>`). Consumers either treat props as the string map or
51
+ * read `__jsxChildren` after a runtime `Array.isArray` check.
45
52
  */
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- props mixes string attrs with __jsxChildren: JsxNode[]
54
+ export type ComponentInstanceProps = Record<string, any>;
55
+
46
56
  export interface ComponentInstance {
47
57
  /** Component tag name (e.g., "Button", "Card") */
48
58
  componentName: string;
49
59
  /** Props on the component (e.g., { variant: "default", size: "sm" }) */
50
- props: Record<string, any>;
60
+ props: ComponentInstanceProps;
51
61
  /** Text content children */
52
62
  children?: string;
53
63
  }
@@ -295,4 +305,18 @@ export interface EnrichedComponentAnalysis {
295
305
  responsive: ResponsiveInfo;
296
306
  /** Light/dark mode class split */
297
307
  colorScheme: ColorSchemeInfo;
308
+ /** Atomic design classification inferred from component graph + structure */
309
+ kind?: AtomicKind;
310
+ /** Number of unique components this component references in stories */
311
+ usesCount?: number;
312
+ /** Number of unique components that reference this component in stories */
313
+ usedByCount?: number;
314
+ /** True when this component references no other component */
315
+ isLeaf?: boolean;
316
+ /** Whether this component should be emitted as a reusable Figma symbol candidate */
317
+ symbolCandidate?: boolean;
318
+ /** Precomputed safe text override prop keys for non-CVA/non-compound symbol inference */
319
+ safeTextOverrideProps?: string[];
298
320
  }
321
+
322
+ export type AtomicKind = 'atom' | 'molecule' | 'organism' | 'utility' | 'other';
@@ -0,0 +1,150 @@
1
+ /**
2
+ * frame-cache.ts — Incremental re-render helpers
3
+ *
4
+ * ## Why this exists
5
+ * The Design System page used to be rebuilt from scratch on every plugin run:
6
+ * every node was deleted and recreated. This destroyed Figma node IDs, which
7
+ * meant any designer annotations, comments, or manual repositioning attached
8
+ * to component frames were lost.
9
+ *
10
+ * ## How it works
11
+ * Each component block frame stores a content hash in Figma plugin data
12
+ * (key: "inkbridge:hash"). On the next run we compare the stored hash against
13
+ * a freshly computed one. If they match, the frame is left completely untouched
14
+ * — its node ID, position, and any attached annotations survive. Only frames
15
+ * whose hash changed (or that are newly added) are removed and rebuilt.
16
+ *
17
+ * ## Hash strategy
18
+ * Two inputs are combined per component block:
19
+ * defHash — hash of the component definition (name, type, stories, classes)
20
+ * tokenHash — hash of the global TOKENS object (covers all themes)
21
+ *
22
+ * blockHash = defHash + ":" + tokenHash
23
+ *
24
+ * If only tokens changed, all component blocks are rebuilt (they carry colour
25
+ * and radius information). If only one component's code changed, only that
26
+ * component's block is rebuilt. Everything else is preserved.
27
+ *
28
+ * ## Hash algorithm
29
+ * djb2 — simple, deterministic, no crypto API needed in the Figma sandbox.
30
+ * Output is a base-36 string (~7 chars), compact and alphanumeric.
31
+ *
32
+ * ## Plugin data key
33
+ * "inkbridge:hash" is written to every component block frame and to the
34
+ * Design Tokens row. No other Figma data is modified.
35
+ */
36
+
37
+ const PLUGIN_HASH_KEY = 'inkbridge:hash';
38
+
39
+ /**
40
+ * djb2 string hash. Works in Figma plugin sandbox (no crypto API required).
41
+ * Returns a base-36 string.
42
+ */
43
+ export function hashString(s: string): string {
44
+ let h = 5381;
45
+ for (let i = 0; i < s.length; i++) {
46
+ h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
47
+ }
48
+ return h.toString(36);
49
+ }
50
+
51
+ /**
52
+ * Stable JSON stringify with recursive key sorting.
53
+ * Prevents hash churn when equivalent objects are created with different key order.
54
+ */
55
+ export function stableStringify(value: unknown): string {
56
+ if (value === null || value === undefined) return 'null';
57
+ const t = typeof value;
58
+ if (t === 'string' || t === 'number' || t === 'boolean') return JSON.stringify(value);
59
+ if (Array.isArray(value)) {
60
+ return '[' + value.map(stableStringify).join(',') + ']';
61
+ }
62
+ if (t === 'object') {
63
+ const obj = value as Record<string, unknown>;
64
+ const keys = Object.keys(obj).sort();
65
+ const parts: string[] = [];
66
+ for (let i = 0; i < keys.length; i++) {
67
+ const k = keys[i];
68
+ const v = obj[k];
69
+ if (typeof v === 'undefined') continue;
70
+ parts.push(JSON.stringify(k) + ':' + stableStringify(v));
71
+ }
72
+ return '{' + parts.join(',') + '}';
73
+ }
74
+ return JSON.stringify(String(value));
75
+ }
76
+
77
+ /**
78
+ * Compute a stable hash for a component definition.
79
+ *
80
+ * Only fields that affect rendering are included. Transient scanner metadata
81
+ * (generatedAt, schemaVersion, file path) is excluded so a re-scan that
82
+ * produces identical components does not trigger unnecessary rebuilds.
83
+ */
84
+ export function hashDef(def: Record<string, unknown>): string {
85
+ const stable = {
86
+ name: def.name,
87
+ type: def.type,
88
+ stories: def.stories,
89
+ classes: def.classes,
90
+ baseClasses: def.baseClasses,
91
+ variants: def.variants,
92
+ states: def.states,
93
+ subComponents: def.subComponents,
94
+ };
95
+ // Use stableStringify so nested object key-order differences from scanner
96
+ // output do not trigger false-positive "changed" component hashes.
97
+ return hashString(stableStringify(stable));
98
+ }
99
+
100
+ /**
101
+ * Read the stored content hash from a Figma frame's plugin data.
102
+ * Returns null if no hash has been stored (e.g. frame was built before
103
+ * incremental re-render was introduced, or plugin data is unavailable).
104
+ */
105
+ export function getFrameHash(frame: BaseNode): string | null {
106
+ try {
107
+ const v = frame.getPluginData(PLUGIN_HASH_KEY);
108
+ return v || null;
109
+ } catch (_e) {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Store a content hash on a Figma frame via plugin data.
116
+ * Silently ignored if the node type does not support plugin data.
117
+ */
118
+ export function setFrameHash(frame: BaseNode, hash: string): void {
119
+ try {
120
+ frame.setPluginData(PLUGIN_HASH_KEY, hash);
121
+ } catch (_e) {
122
+ // Some node types (e.g. text nodes) do not support plugin data — ignore.
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Find the first direct child of `parent` whose `.name` matches `name`.
128
+ * Returns null if not found or if parent has no children.
129
+ */
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ export function findChildByName(parent: any, name: string): any | null {
132
+ if (!parent || !parent.children) return null;
133
+ for (let i = 0; i < parent.children.length; i++) {
134
+ if (parent.children[i].name === name) return parent.children[i];
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Find the index of the first direct child of `parent` whose `.name` matches
141
+ * `name`. Returns -1 if not found.
142
+ */
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
+ export function findChildIndexByName(parent: any, name: string): number {
145
+ if (!parent || !parent.children) return -1;
146
+ for (let i = 0; i < parent.children.length; i++) {
147
+ if (parent.children[i].name === name) return i;
148
+ }
149
+ return -1;
150
+ }
@@ -0,0 +1,2 @@
1
+ export * from './frame-cache';
2
+ export * from './image-cache';
@@ -1,12 +1,27 @@
1
- import { COMPONENT_DEFS } from './tokens';
1
+ import { COMPONENT_DEFS } from '../tokens';
2
+ import type {
3
+ ComponentAnalysis,
4
+ CVAComponentAnalysis,
5
+ CompoundComponentAnalysis,
6
+ EnrichedComponentAnalysis,
7
+ StateComponentAnalysis,
8
+ SubComponentInfo,
9
+ } from '../../scanner/types';
2
10
 
3
11
  // --- Component Definition Helpers ---
4
12
  // These helpers let the plugin dynamically read from scanned component definitions
5
13
 
6
- export function getComponentDef(name: string): any | null {
14
+ type RawEntry = EnrichedComponentAnalysis | ComponentAnalysis;
15
+
16
+ function unwrap(raw: RawEntry): ComponentAnalysis {
17
+ return 'analysis' in raw ? raw.analysis : raw;
18
+ }
19
+
20
+ export function getComponentDef(name: string): ComponentAnalysis | null {
7
21
  if (!COMPONENT_DEFS || !COMPONENT_DEFS.components) return null;
8
- for (const raw of COMPONENT_DEFS.components) {
9
- const def = raw && raw.analysis ? raw.analysis : raw;
22
+ for (const raw of COMPONENT_DEFS.components as RawEntry[]) {
23
+ if (!raw) continue;
24
+ const def = unwrap(raw);
10
25
  if (def && def.name && def.name.toLowerCase() === name.toLowerCase()) {
11
26
  return def;
12
27
  }
@@ -14,7 +29,7 @@ export function getComponentDef(name: string): any | null {
14
29
  return null;
15
30
  }
16
31
 
17
- export function getCVAComponentVariants(name: string): any | null {
32
+ export function getCVAComponentVariants(name: string): CVAComponentAnalysis['variants'] | null {
18
33
  const def = getComponentDef(name);
19
34
  if (!def || def.type !== 'cva') return null;
20
35
  return def.variants;
@@ -36,19 +51,19 @@ export function getCVAComponentClasses(name: string, variantName?: string, varia
36
51
  export function getStateComponentStates(name: string): string[] | null {
37
52
  const def = getComponentDef(name);
38
53
  if (!def || def.type !== 'state') return null;
39
- return Object.keys(def.states);
54
+ return Object.keys((def as StateComponentAnalysis).states);
40
55
  }
41
56
 
42
- export function getCompoundSubComponents(name: string): any[] | null {
57
+ export function getCompoundSubComponents(name: string): SubComponentInfo[] | null {
43
58
  const def = getComponentDef(name);
44
59
  if (!def || def.type !== 'compound') return null;
45
- return def.subComponents;
60
+ return (def as CompoundComponentAnalysis).subComponents;
46
61
  }
47
62
 
48
63
  export function listAllComponents(): { name: string; type: string }[] {
49
64
  if (!COMPONENT_DEFS || !COMPONENT_DEFS.components) return [];
50
- return COMPONENT_DEFS.components.map((raw: any) => {
51
- const def = raw && raw.analysis ? raw.analysis : raw;
65
+ return (COMPONENT_DEFS.components as RawEntry[]).map((raw) => {
66
+ const def = unwrap(raw);
52
67
  return { name: def?.name, type: def?.type };
53
68
  });
54
69
  }