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
@@ -0,0 +1,127 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { splitClassName } from '../src/tailwind';
4
+ import { LayoutParser } from '../src/layout/layout-parser';
5
+
6
+ /**
7
+ * Regression: `size-full` is Tailwind shorthand for `w-full h-full`. The
8
+ * plugin had separate code paths for `w-full` and `h-full` (each correctly
9
+ * marks the node for the deferred-layout stretch pipeline), but `size-full`
10
+ * was silently dropped — no sizing parser, no full-width/height mark, no
11
+ * STRETCH alignment.
12
+ *
13
+ * Symptom: `<AvatarFallback className="flex size-full ...">` rendered at
14
+ * its text-content size (24×19 — just "CN") instead of filling its
15
+ * 40×40 Avatar.Root parent. `<AvatarImage className="size-full">` rendered
16
+ * as a 200×150 placeholder. Both look broken next to a Storybook that
17
+ * happily rendered the same primitives correctly.
18
+ *
19
+ * Architectural fix: normalize `size-full` into `w-full h-full` at the
20
+ * `splitClassName` boundary — the single bottleneck every downstream
21
+ * parser passes through. Every existing w-full / h-full handler now
22
+ * applies to size-full automatically with zero parallel code paths. The
23
+ * numeric `size-N` form has its own dedicated handler in sizing.ts and is
24
+ * intentionally left as-is (it sets `widthMode`/`heightMode` to FIXED in
25
+ * one go, not via the FILL cascade).
26
+ *
27
+ * This file locks the boundary so a refactor of splitClassName can't
28
+ * regress this without breaking the test.
29
+ */
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // splitClassName — size-full expands to w-full + h-full
33
+ // ---------------------------------------------------------------------------
34
+
35
+ {
36
+ assert.deepEqual(
37
+ splitClassName('size-full'),
38
+ ['w-full', 'h-full'],
39
+ 'bare size-full expands into [w-full, h-full]',
40
+ );
41
+ }
42
+
43
+ {
44
+ // In context with siblings — order preserved, expansion happens in-place.
45
+ assert.deepEqual(
46
+ splitClassName('flex size-full items-center'),
47
+ ['flex', 'w-full', 'h-full', 'items-center'],
48
+ 'size-full expanded in-place; siblings keep order',
49
+ );
50
+ }
51
+
52
+ {
53
+ // size-N (numeric) — NOT expanded. The numeric handler in sizing.ts
54
+ // sets both modes FIXED in one go; turning it into `w-N h-N` would
55
+ // route through a different (and slower) code path.
56
+ assert.deepEqual(
57
+ splitClassName('size-10'),
58
+ ['size-10'],
59
+ 'numeric size-N is NOT expanded — its FIXED handler stays the source of truth',
60
+ );
61
+ assert.deepEqual(
62
+ splitClassName('size-4 size-8 size-[40px]'),
63
+ ['size-4', 'size-8', 'size-[40px]'],
64
+ 'numeric / arbitrary size-* forms are left intact',
65
+ );
66
+ }
67
+
68
+ {
69
+ // Important-modifier handling still strips ! around the token.
70
+ assert.deepEqual(
71
+ splitClassName('!size-full'),
72
+ ['w-full', 'h-full'],
73
+ 'leading-! is stripped before expansion (so !size-full still expands)',
74
+ );
75
+ assert.deepEqual(
76
+ splitClassName('size-full!'),
77
+ ['w-full', 'h-full'],
78
+ 'trailing-! is stripped before expansion',
79
+ );
80
+ }
81
+
82
+ {
83
+ // Empty / whitespace inputs are no-ops.
84
+ assert.deepEqual(splitClassName(''), [], 'empty string → []');
85
+ assert.deepEqual(splitClassName(' '), [], 'whitespace-only → []');
86
+ assert.deepEqual(splitClassName(undefined), [], 'undefined → []');
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // LayoutParser — size-full now drives FILL on both axes
91
+ // ---------------------------------------------------------------------------
92
+
93
+ {
94
+ // After splitClassName expansion, the parser sees w-full h-full and sets
95
+ // both axes to FILL. This is what makes AvatarFallback fill its parent
96
+ // rather than hug its text content.
97
+ const ir = LayoutParser.parseToIR(splitClassName('flex size-full'));
98
+ assert.equal(
99
+ ir.widthMode,
100
+ 'FILL',
101
+ 'size-full sets widthMode=FILL via expansion → w-full handler',
102
+ );
103
+ assert.equal(
104
+ ir.heightMode,
105
+ 'FILL',
106
+ 'size-full sets heightMode=FILL via expansion → h-full handler',
107
+ );
108
+ }
109
+
110
+ {
111
+ // Sanity: plain w-full alone only fills the width axis.
112
+ const ir = LayoutParser.parseToIR(splitClassName('w-full'));
113
+ assert.equal(ir.widthMode, 'FILL', 'w-full alone fills width only');
114
+ assert.notEqual(ir.heightMode, 'FILL', 'w-full alone does NOT fill height');
115
+ }
116
+
117
+ {
118
+ // And numeric size still produces FIXED on both axes — the parser-level
119
+ // contract we don't want to drift.
120
+ const ir = LayoutParser.parseToIR(splitClassName('size-10'));
121
+ assert.equal(ir.widthMode, 'FIXED', 'size-10 → widthMode=FIXED');
122
+ assert.equal(ir.heightMode, 'FIXED', 'size-10 → heightMode=FIXED');
123
+ assert.equal(ir.fixedWidth, 40, 'size-10 → fixedWidth=40px');
124
+ assert.equal(ir.fixedHeight, 40, 'size-10 → fixedHeight=40px');
125
+ }
126
+
127
+ console.log('size-full-normalization-regression: PASS (7 split cases + 3 IR cases)');
@@ -0,0 +1,175 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: marketing molecules whose JSX root is a wrapper component
7
+ * (e.g. `<Hero>...</Hero>`) — including ones that expand into a fragment
8
+ * `<>...</>` — must not be classified as `state` components even if a
9
+ * deeply-nested element carries hover/focus classes (typical in chips,
10
+ * links, or buttons inside the molecule).
11
+ *
12
+ * History: this same logical hole has caused HeroSection to be wrongly
13
+ * rendered as a State Matrix + symbol multiple times. Each time a fix
14
+ * landed in `hasRootStateModifier` it covered the case the regression
15
+ * was reported with, but missed the next variant — fragment-rooted
16
+ * expansion was the most recent miss. Lock it in.
17
+ */
18
+
19
+ // Test-only view of ComponentScanner that surfaces the otherwise-private
20
+ // `project` field and `analyzeState` method. The runtime members exist;
21
+ // TS hides them. Cast through `unknown` to avoid `any`.
22
+ type ComponentAnalysisLike = unknown;
23
+ interface TestScannerView {
24
+ project: import('ts-morph').Project;
25
+ analyzeState: (sourceFile: import('ts-morph').SourceFile, filePath: string) => ComponentAnalysisLike;
26
+ }
27
+
28
+ function makeScanner(): TestScannerView {
29
+ // ScannerConfig requires componentPaths/filePattern/exclude. Pass empty values
30
+ // — the regression tests stuff source files into `project` directly via
31
+ // createSourceFile, so the scanner never reads the filesystem.
32
+ const scanner = new ComponentScanner({
33
+ componentPaths: [],
34
+ filePattern: '*.tsx',
35
+ exclude: [],
36
+ });
37
+ return scanner as unknown as TestScannerView;
38
+ }
39
+
40
+ function fixturePath(relative: string): string {
41
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
42
+ }
43
+
44
+ const scanner = makeScanner();
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Case 1: <Hero> wrapper component as root — direct uppercase wrapper.
48
+ // Was already covered by the original heuristic; included so a future
49
+ // "simplification" doesn't regress this.
50
+ // ---------------------------------------------------------------------------
51
+ {
52
+ // File basename must match the exported function name — the scanner
53
+ // uses the basename to find the component definition.
54
+ const file = scanner.project.createSourceFile(
55
+ fixturePath('hero-section.tsx'),
56
+ `
57
+ import { Hero } from '@/components/hero';
58
+
59
+ export function HeroSection() {
60
+ return (
61
+ <Hero>
62
+ <a className="hover:ring-gray-900/20 ring-1">Get started</a>
63
+ </Hero>
64
+ );
65
+ }
66
+ `,
67
+ { overwrite: true }
68
+ );
69
+ const result = scanner.analyzeState(file, file.getFilePath());
70
+ assert.equal(
71
+ result,
72
+ null,
73
+ 'A component whose root JSX is an uppercase wrapper (<Hero>) must NOT be classified as `state` even when a nested element has hover classes.'
74
+ );
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Case 2: fragment-rooted component — the actual recurring break.
79
+ // HeroSection's root expands to `<><div className="relative isolate">...
80
+ // </div></>` once Hero is inlined. Pre-fix, the empty-tagName fragment
81
+ // failed the uppercase wrapper check and got treated as a state primitive.
82
+ // ---------------------------------------------------------------------------
83
+ {
84
+ // Filename matches function name so the scanner can resolve the JSX tree.
85
+ const file = scanner.project.createSourceFile(
86
+ fixturePath('fragment-rooted-section.tsx'),
87
+ `
88
+ export function FragmentRootedSection() {
89
+ return (
90
+ <>
91
+ <div className="relative isolate">
92
+ <a className="hover:ring-gray-900/20 ring-1">Get started</a>
93
+ </div>
94
+ </>
95
+ );
96
+ }
97
+ `,
98
+ { overwrite: true }
99
+ );
100
+ const result = scanner.analyzeState(file, file.getFilePath());
101
+ assert.equal(
102
+ result,
103
+ null,
104
+ 'A component whose root JSX is a fragment (<>...</>) must NOT be classified as `state` — only deeply nested elements carry the hover/focus classes.'
105
+ );
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Case 3: the positive control. A real state primitive — root is a plain
110
+ // HTML element with hover/focus classes directly on it. Must STILL be
111
+ // classified as state. Ensures we haven't accidentally killed the feature.
112
+ // ---------------------------------------------------------------------------
113
+ {
114
+ // Filename matches function name so the scanner resolves the JSX tree.
115
+ const file = scanner.project.createSourceFile(
116
+ fixturePath('styled-input.tsx'),
117
+ `
118
+ export function StyledInput(props: any) {
119
+ return (
120
+ <input
121
+ {...props}
122
+ className="border h-9 px-3 hover:border-primary focus-visible:ring-2 disabled:opacity-50"
123
+ />
124
+ );
125
+ }
126
+ `,
127
+ { overwrite: true }
128
+ );
129
+ const result = scanner.analyzeState(file, file.getFilePath());
130
+ assert.notEqual(
131
+ result,
132
+ null,
133
+ 'A genuine state primitive (root <input> with hover/focus/disabled classes directly on it) must STILL be classified as `state`.'
134
+ );
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Case 4: shadcn-style Label — only carries `peer-disabled:` and
139
+ // `group-data-[disabled=true]:` modifiers (passive reactions to a
140
+ // parent's state, NOT its own state axis). Must NOT be classified as
141
+ // `state` — otherwise the runtime renders it via the state-master
142
+ // path, which has no slot for the label's text children, and a story
143
+ // like `<Dialog>... <Label>Name</Label> <Input/> ...</Dialog>` ends up
144
+ // with the labels invisible. Lock this in so a future widening of the
145
+ // state-modifier list doesn't silently re-break it.
146
+ // ---------------------------------------------------------------------------
147
+ {
148
+ const file = scanner.project.createSourceFile(
149
+ fixturePath('passive-label.tsx'),
150
+ `
151
+ export function Label({ className, ...props }: any) {
152
+ return (
153
+ <label
154
+ className={
155
+ "flex items-center gap-2 text-sm leading-none font-medium select-none " +
156
+ "group-data-[disabled=true]:pointer-events-none " +
157
+ "group-data-[disabled=true]:opacity-50 " +
158
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
159
+ }
160
+ {...props}
161
+ />
162
+ );
163
+ }
164
+ `,
165
+ { overwrite: true }
166
+ );
167
+ const result = scanner.analyzeState(file, file.getFilePath());
168
+ assert.equal(
169
+ result,
170
+ null,
171
+ 'A component whose only "state" modifiers are passive `group-*:` / `peer-*:` reactions must NOT be classified as `state` — it has no own-element state axis, and the runtime would lose its text children when rendering via the state-master path.',
172
+ );
173
+ }
174
+
175
+ console.log('state-classification-regression: ok');
@@ -0,0 +1,216 @@
1
+ import assert from 'node:assert/strict';
2
+ import { _internal, setStoryRenderDiagnostics } from '../src/design-system/story-diagnostics';
3
+ import type { ComponentDef, ComponentStory, LayoutInfo } from '../src/components';
4
+ import type { JsxNode } from '../src/tailwind';
5
+
6
+ /**
7
+ * Regression: story-diagnostics writes the `inkbridge:story-scan` and
8
+ * `inkbridge:story-render` plugin-data values that the `Debug Selection`
9
+ * plugin command reads. The string format is contract — anyone reading the
10
+ * pluginData (the dev console output, future tooling) parses by the
11
+ * `key=value;key=value` shape. Lock the shape and the per-helper inputs.
12
+ *
13
+ * Extracted from `src/design-system/story-builder.ts` into
14
+ * `src/design-system/story-diagnostics.ts`. setStoryRenderDiagnostics is a
15
+ * side-effect on a LayoutInfo (Figma node); the regression uses a stub that
16
+ * captures setPluginData calls so the format can be asserted in Node.
17
+ */
18
+
19
+ const { getStoryTreeRootTag, getStoryTreeRootChildCount, summarizeLayoutClasses } = _internal;
20
+
21
+ function el(tag: string, children: JsxNode[] = []): JsxNode {
22
+ return { type: 'element', tagName: tag, props: {}, children } as unknown as JsxNode;
23
+ }
24
+
25
+ function story(overrides: Partial<ComponentStory> = {}): ComponentStory {
26
+ return { name: 'TestStory', jsxTree: null, instances: [], layoutClasses: [], ...overrides } as ComponentStory;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // getStoryTreeRootTag — null/non-element/missing tag tolerances
31
+ // ---------------------------------------------------------------------------
32
+
33
+ assert.equal(getStoryTreeRootTag(story({ jsxTree: null })), '', 'null jsxTree → empty string');
34
+ assert.equal(getStoryTreeRootTag(story({ jsxTree: el('div') })), 'div', 'div root → "div"');
35
+ assert.equal(
36
+ getStoryTreeRootTag(story({ jsxTree: el('Header') })),
37
+ 'Header',
38
+ 'component-cased tag preserved',
39
+ );
40
+
41
+ // Non-element root (text-only).
42
+ {
43
+ const tree = { type: 'text', value: 'hello' } as unknown as JsxNode;
44
+ assert.equal(
45
+ getStoryTreeRootTag(story({ jsxTree: tree })),
46
+ '',
47
+ 'text-root story has no tag — return empty string',
48
+ );
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // getStoryTreeRootChildCount — null / array / non-array tolerances
53
+ // ---------------------------------------------------------------------------
54
+
55
+ assert.equal(getStoryTreeRootChildCount(story({ jsxTree: null })), 0, 'null tree → 0');
56
+ assert.equal(getStoryTreeRootChildCount(story({ jsxTree: el('div') })), 0, 'empty children → 0');
57
+ assert.equal(
58
+ getStoryTreeRootChildCount(story({ jsxTree: el('div', [el('span'), el('span'), el('span')]) })),
59
+ 3,
60
+ 'three children → 3',
61
+ );
62
+
63
+ // Non-array children defensive: when jsxTree has children: undefined or null.
64
+ {
65
+ const tree = { type: 'element', tagName: 'div', props: {}, children: null } as unknown as JsxNode;
66
+ assert.equal(
67
+ getStoryTreeRootChildCount(story({ jsxTree: tree })),
68
+ 0,
69
+ 'non-array children → 0 (not NaN, not throw)',
70
+ );
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // summarizeLayoutClasses — join + truncation
75
+ // ---------------------------------------------------------------------------
76
+
77
+ assert.equal(summarizeLayoutClasses(undefined, 50), '', 'undefined input → empty string');
78
+ assert.equal(summarizeLayoutClasses(['flex', 'p-4'], 50), 'flex p-4', 'array joins by space');
79
+ assert.equal(
80
+ summarizeLayoutClasses(['a', 'b', 'c'], 3),
81
+ 'a b',
82
+ 'truncates when joined length > maxLength',
83
+ );
84
+ assert.equal(
85
+ summarizeLayoutClasses(['short'], 10),
86
+ 'short',
87
+ 'no truncation when joined length <= maxLength',
88
+ );
89
+
90
+ // Edge: empty array → empty string.
91
+ assert.equal(summarizeLayoutClasses([], 50), '', 'empty array → empty string');
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // setStoryRenderDiagnostics — full integration: format + plugin-data writes
95
+ // ---------------------------------------------------------------------------
96
+
97
+ // Stub LayoutInfo that captures setPluginData calls.
98
+ function makeLayoutStub(): { layout: LayoutInfo; writes: Record<string, string> } {
99
+ const writes: Record<string, string> = {};
100
+ const layout = {
101
+ children: [],
102
+ setPluginData: (key: string, value: string) => {
103
+ writes[key] = value;
104
+ },
105
+ } as unknown as LayoutInfo;
106
+ return { layout, writes };
107
+ }
108
+
109
+ // Happy path: writes all four plugin-data keys with the documented format.
110
+ {
111
+ const { layout, writes } = makeLayoutStub();
112
+ const def = { name: 'Button' } as ComponentDef;
113
+ const tree = el('div', [el('span'), el('p')]);
114
+ const s = story({
115
+ name: 'Default',
116
+ jsxTree: tree,
117
+ instances: [{ componentName: 'Button' } as unknown],
118
+ layoutClasses: ['flex', 'p-4'],
119
+ });
120
+
121
+ setStoryRenderDiagnostics(layout, def, s, 'tree', 5, true);
122
+
123
+ assert.equal(writes['inkbridge:story-name'], 'Default', 'story name written');
124
+ assert.equal(writes['inkbridge:story-def'], 'Button', 'def name written');
125
+
126
+ // scanSummary format: jsx=...;root=...;rootChildren=...;instances=...;layout=...
127
+ assert.equal(
128
+ writes['inkbridge:story-scan'],
129
+ 'jsx=1;root=div;rootChildren=2;instances=1;layout=flex p-4',
130
+ 'scan summary uses canonical key=value;... format',
131
+ );
132
+
133
+ // renderSummary format: mode=...;added=...;treeSelected=...;children=...;engine=...
134
+ assert.match(
135
+ writes['inkbridge:story-render'],
136
+ /^mode=tree;added=5;treeSelected=1;children=0;engine=.+$/,
137
+ 'render summary uses canonical key=value;... format, engine version present',
138
+ );
139
+ }
140
+
141
+ // jsx=0 path when no jsxTree
142
+ {
143
+ const { layout, writes } = makeLayoutStub();
144
+ setStoryRenderDiagnostics(
145
+ layout,
146
+ { name: 'Button' } as ComponentDef,
147
+ story({ jsxTree: null }),
148
+ 'instance',
149
+ 1,
150
+ false,
151
+ );
152
+ assert.match(writes['inkbridge:story-scan'], /^jsx=0;root=;rootChildren=0;/, 'no tree → jsx=0;root=');
153
+ assert.match(writes['inkbridge:story-render'], /treeSelected=0/, 'treeSelected reflects useStoryTree=false');
154
+ }
155
+
156
+ // Defensive: missing setPluginData → no throw
157
+ {
158
+ const layoutWithoutSetPluginData = { children: [] } as unknown as LayoutInfo;
159
+ // Should silently do nothing rather than throw.
160
+ setStoryRenderDiagnostics(
161
+ layoutWithoutSetPluginData,
162
+ { name: 'X' } as ComponentDef,
163
+ story(),
164
+ 'mode',
165
+ 0,
166
+ false,
167
+ );
168
+ // (no assertion needed — getting here means no throw)
169
+ }
170
+
171
+ // Defensive: setPluginData throws → caught silently
172
+ {
173
+ const writes: Record<string, string> = {};
174
+ let throwCount = 0;
175
+ const layout = {
176
+ children: [],
177
+ setPluginData: (key: string, value: string) => {
178
+ if (key === 'inkbridge:story-scan') {
179
+ throwCount++;
180
+ throw new Error('simulated figma failure');
181
+ }
182
+ writes[key] = value;
183
+ },
184
+ } as unknown as LayoutInfo;
185
+
186
+ setStoryRenderDiagnostics(
187
+ layout,
188
+ { name: 'X' } as ComponentDef,
189
+ story(),
190
+ 'mode',
191
+ 0,
192
+ false,
193
+ );
194
+ // We don't care which writes succeeded — only that the throw didn't escape.
195
+ assert.equal(throwCount, 1, 'inner throw was reached');
196
+ // (no further assertion needed — getting here means the outer catch swallowed the error)
197
+ }
198
+
199
+ // Layout-class truncation: very long class list is capped at 120 chars in
200
+ // the scan summary.
201
+ {
202
+ const { layout, writes } = makeLayoutStub();
203
+ const longClasses = Array.from({ length: 50 }, (_, i) => `cls-${i}`); // ~6*50 = 300+ chars joined
204
+ setStoryRenderDiagnostics(
205
+ layout,
206
+ { name: 'X' } as ComponentDef,
207
+ story({ layoutClasses: longClasses }),
208
+ 'mode',
209
+ 0,
210
+ false,
211
+ );
212
+ const layoutSegment = writes['inkbridge:story-scan'].split('layout=')[1] || '';
213
+ assert.ok(layoutSegment.length <= 120, `layout summary capped at 120 chars (got ${layoutSegment.length})`);
214
+ }
215
+
216
+ console.log('story-diagnostics-regression: PASS');