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,195 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ blockHashMatchesCurrent,
4
+ normalizeForPreflight,
5
+ parseStoredBlockHash,
6
+ } from '../src/design-system/block-cache';
7
+ import type { ComponentDef } from '../src/components';
8
+
9
+ /**
10
+ * Regression: the block-cache helpers decide which component blocks survive
11
+ * across re-renders. They run on every preflight and every Generate. If the
12
+ * matching logic drifts, the plugin either rebuilds blocks unnecessarily
13
+ * (destroying user annotations) or keeps stale blocks the user expects to
14
+ * have been regenerated.
15
+ *
16
+ * Three pure-data helpers are covered here. `findComponentBlocksInPage` is
17
+ * Figma-API-bound (reads plugin data + walks the scene graph) and is exercised
18
+ * by the actual plugin under real Figma — not in this fixture.
19
+ *
20
+ * Extracted from `src/design-system/story-builder.ts` into
21
+ * `src/design-system/block-cache.ts`.
22
+ */
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // parseStoredBlockHash — string parsing of `def:token` and `def:token:engine`
26
+ // ---------------------------------------------------------------------------
27
+
28
+ // Null / empty / non-string inputs return null (defensive: plugin data reads
29
+ // can return null for absent keys).
30
+ assert.equal(parseStoredBlockHash(null), null, 'null input must parse as null');
31
+ assert.equal(parseStoredBlockHash(''), null, 'empty string must parse as null');
32
+ assert.equal(
33
+ parseStoredBlockHash('only-one-part'),
34
+ null,
35
+ 'single-segment string (no colon) must parse as null',
36
+ );
37
+
38
+ // Two-part legacy format (pre-engine-aware blocks).
39
+ assert.deepEqual(
40
+ parseStoredBlockHash('abc:def'),
41
+ { defHash: 'abc', tokenHash: 'def', engineHash: null },
42
+ 'two-part hash parses without engine — must support legacy blocks',
43
+ );
44
+
45
+ // Three-part current format.
46
+ assert.deepEqual(
47
+ parseStoredBlockHash('abc:def:1.2.3'),
48
+ { defHash: 'abc', tokenHash: 'def', engineHash: '1.2.3' },
49
+ 'three-part hash parses with engine segment',
50
+ );
51
+
52
+ // Four+ parts (engine segment carries colons) — anything after the second
53
+ // colon joins back into engineHash so SemVer prereleases (1.0.0-rc.1) or
54
+ // fingerprints with colons don't truncate.
55
+ assert.deepEqual(
56
+ parseStoredBlockHash('a:b:c:d:e'),
57
+ { defHash: 'a', tokenHash: 'b', engineHash: 'c:d:e' },
58
+ 'multi-colon engine segment must rejoin into engineHash, not truncate',
59
+ );
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // blockHashMatchesCurrent — exact-or-tolerant comparison
63
+ // ---------------------------------------------------------------------------
64
+
65
+ // No stored hash → never matches (treated as "this block was never tagged").
66
+ assert.equal(
67
+ blockHashMatchesCurrent(null, 'def', 'tok', 'eng'),
68
+ false,
69
+ 'null stored hash must not match — forces a rebuild for untagged blocks',
70
+ );
71
+
72
+ // Exact match of all three parts → true.
73
+ assert.equal(
74
+ blockHashMatchesCurrent('def:tok:eng', 'def', 'tok', 'eng'),
75
+ true,
76
+ 'exact 3-part match is the canonical hit',
77
+ );
78
+
79
+ // Backward compat: legacy 2-part hash with matching def+token → true (engine
80
+ // is tolerated as a free variable so engine upgrades don't force rebuilds).
81
+ assert.equal(
82
+ blockHashMatchesCurrent('def:tok', 'def', 'tok', 'eng'),
83
+ true,
84
+ 'legacy 2-part stored hash matches when def+token equal — engine upgrade does not rebuild',
85
+ );
86
+
87
+ // Engine drift on a 3-part stored hash → still matches (def+token wins).
88
+ assert.equal(
89
+ blockHashMatchesCurrent('def:tok:old-engine', 'def', 'tok', 'new-engine'),
90
+ true,
91
+ 'def+token match with different engine segment must still match — supports render-engine version migration',
92
+ );
93
+
94
+ // Different defHash → must not match.
95
+ assert.equal(
96
+ blockHashMatchesCurrent('OTHER:tok:eng', 'def', 'tok', 'eng'),
97
+ false,
98
+ 'different defHash must force rebuild',
99
+ );
100
+
101
+ // Different tokenHash → must not match (token change is a real rebuild trigger).
102
+ assert.equal(
103
+ blockHashMatchesCurrent('def:OTHER:eng', 'def', 'tok', 'eng'),
104
+ false,
105
+ 'different tokenHash must force rebuild',
106
+ );
107
+
108
+ // Garbage stored hash → unparseable, no match.
109
+ assert.equal(
110
+ blockHashMatchesCurrent('garbage', 'def', 'tok', 'eng'),
111
+ false,
112
+ 'unparseable stored hash must not match',
113
+ );
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // normalizeForPreflight — hoist analysis fields onto top-level def shape
117
+ // ---------------------------------------------------------------------------
118
+
119
+ // def without analysis is returned as-is (defensive: not all defs come from
120
+ // the scanner — some are constructed in tests / preflight only).
121
+ {
122
+ const def = { name: 'X', stories: [] } as unknown as ComponentDef;
123
+ assert.equal(
124
+ normalizeForPreflight(def),
125
+ def,
126
+ 'def without analysis must return identity (no normalization needed)',
127
+ );
128
+ }
129
+
130
+ // def with analysis: analysis fields hoist, top-level classification fields preserved.
131
+ {
132
+ const raw = {
133
+ filePath: 'top-file',
134
+ stories: [{ name: 'TopStory' }],
135
+ kind: 'atom',
136
+ usesCount: 3,
137
+ usedByCount: 5,
138
+ isLeaf: false,
139
+ hasStory: true,
140
+ analysis: {
141
+ filePath: 'analysis-file',
142
+ stories: [{ name: 'AnalysisStory' }],
143
+ hasStory: true,
144
+ otherAnalysisField: 'kept',
145
+ },
146
+ } as unknown as ComponentDef;
147
+
148
+ const out = normalizeForPreflight(raw);
149
+ assert.equal(out.filePath, 'analysis-file', 'analysis.filePath wins when present');
150
+ assert.deepEqual(
151
+ out.stories,
152
+ [{ name: 'AnalysisStory' }],
153
+ 'analysis.stories wins when present (array, not top-level)',
154
+ );
155
+ assert.equal(out.hasStory, true, 'analysis.hasStory wins when boolean');
156
+ assert.equal(out.kind, 'atom', 'top-level kind is preserved (classification stays on the raw def)');
157
+ assert.equal(out.usesCount, 3, 'top-level usesCount is preserved');
158
+ assert.equal(out.usedByCount, 5, 'top-level usedByCount is preserved');
159
+ assert.equal(out.isLeaf, false, 'top-level isLeaf is preserved');
160
+ assert.equal((out as { otherAnalysisField?: string }).otherAnalysisField, 'kept', 'unknown analysis fields are kept');
161
+ }
162
+
163
+ // Fallback: analysis lacks fields → top-level values fill in.
164
+ {
165
+ const raw = {
166
+ filePath: 'top-file',
167
+ stories: [{ name: 'TopStory' }],
168
+ hasStory: true,
169
+ analysis: {
170
+ // No filePath, no stories, no hasStory — exercise every fallback path.
171
+ otherField: 'x',
172
+ },
173
+ } as unknown as ComponentDef;
174
+
175
+ const out = normalizeForPreflight(raw);
176
+ assert.equal(out.filePath, 'top-file', 'top-level filePath fills in when analysis.filePath absent');
177
+ assert.deepEqual(
178
+ out.stories,
179
+ [{ name: 'TopStory' }],
180
+ 'top-level stories fill in when analysis.stories absent',
181
+ );
182
+ assert.equal(out.hasStory, true, 'top-level hasStory fills in when analysis.hasStory missing');
183
+ }
184
+
185
+ // hasStory falsy-coercion: analysis without boolean hasStory + falsy raw.hasStory → false.
186
+ {
187
+ const raw = {
188
+ stories: [],
189
+ analysis: {},
190
+ } as unknown as ComponentDef;
191
+ const out = normalizeForPreflight(raw);
192
+ assert.equal(out.hasStory, false, 'falsy fallbacks coerce to false (not undefined)');
193
+ }
194
+
195
+ console.log('block-cache-regression: PASS');
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Regression: lock in the minified `code.js` bundle size so accidental
7
+ * imports (a heavy third-party lib, an unintentional non-tree-shakable
8
+ * re-export, dropping `minify: true` from build.mjs) don't silently
9
+ * double the plugin payload.
10
+ *
11
+ * Threshold has ~10% headroom over the current minified size. Bump it
12
+ * deliberately if real new functionality justifies the growth — the
13
+ * commit message should explain why.
14
+ *
15
+ * Must run AFTER `pnpm build` (the verify chain does that). If
16
+ * `code.js` is missing or stale, the assertion fails informatively.
17
+ */
18
+
19
+ const CODE_JS_PATH = path.resolve(process.cwd(), 'tools/figma-plugin/code.js');
20
+ const MAX_BYTES = 360 * 1024; // 360 KB ceiling (current ~325 KB)
21
+
22
+ if (!fs.existsSync(CODE_JS_PATH)) {
23
+ console.error('bundle-size-regression: FAIL — code.js missing at ' + CODE_JS_PATH);
24
+ console.error(' Run `pnpm build` first.');
25
+ process.exit(1);
26
+ }
27
+
28
+ const stat = fs.statSync(CODE_JS_PATH);
29
+ const sizeKB = (stat.size / 1024).toFixed(1);
30
+
31
+ assert.ok(
32
+ stat.size <= MAX_BYTES,
33
+ `code.js is ${sizeKB} KB, exceeds ceiling of ${(MAX_BYTES / 1024).toFixed(0)} KB.\n` +
34
+ `If the growth is intentional (real new functionality, not accidental bloat),\n` +
35
+ `bump MAX_BYTES in scanner/bundle-size-regression.ts and explain in the commit.`
36
+ );
37
+
38
+ // Also sanity-check that minification actually ran — minified IIFE
39
+ // should be on a single long line, not many short lines. If somebody
40
+ // drops `minify: !isWatch` from build.mjs, this catches it before the
41
+ // raw size assertion would.
42
+ const content = fs.readFileSync(CODE_JS_PATH, 'utf8');
43
+ const linesOverThreshold = content.split('\n').filter(l => l.length > 1000).length;
44
+ assert.ok(
45
+ linesOverThreshold > 0,
46
+ 'code.js does not appear to be minified — no lines over 1000 characters found.\n' +
47
+ 'Check that build.mjs still has `minify: !isWatch`.'
48
+ );
49
+
50
+ console.log(`bundle-size-regression: PASS (code.js = ${sizeKB} KB, ceiling ${(MAX_BYTES / 1024).toFixed(0)} KB)`);
@@ -0,0 +1,303 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ createFrame: () => makeStubFrame(),
7
+ };
8
+
9
+ import {
10
+ LayoutParser,
11
+ setFrameCrossAlign,
12
+ setFrameFromBlockFlow,
13
+ setFrameInlineAlign,
14
+ } from '../src/layout/layout-parser';
15
+
16
+ // MATRIX REGRESSION — child sizing under varying parent context.
17
+ //
18
+ // The recurring "context-dependent sizing" class of bug:
19
+ // The per-class parser correctly records a child's sizing intent (HUG /
20
+ // inline-display / w-full / etc.), then a LATER pass overrides it based on
21
+ // parent-side state alone, never reading the child's recorded intent.
22
+ //
23
+ // Historical fix sites in this class (see troubleshooting.md):
24
+ // 1. `inline-flex` pill in BLOCK-FLOW VERTICAL parent → re-stretched.
25
+ // Fix-site recurred 3× across refactors (tailwind.ts, layout-parser
26
+ // implicit-stretch pass, ui-builder.ts text-resize branch).
27
+ // 2. `inline-flex` button in REAL FLEX-col STRETCH parent → did NOT
28
+ // stretch after over-broad inline-display exclusion. Dialog footer
29
+ // base-breakpoint regression.
30
+ // 3. `text-center` on a block-flow parent → inline children not centered.
31
+ // 4. `w-full` block child → correct STRETCH in vertical parent, `grow=1`
32
+ // in horizontal parent. Easy to break when refactoring either branch.
33
+ //
34
+ // This fixture asserts the truth table — every cell is one (child intent,
35
+ // parent context) cross product. A future refactor that drifts ANY cell
36
+ // triggers here, regardless of which pass introduced the drift.
37
+ //
38
+ // Cells overlap with `inline-flex-regression.ts` by design — that file
39
+ // documents the incident narrative; this file is the systematic net.
40
+
41
+ type StubFrame = {
42
+ type: 'FRAME';
43
+ layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
44
+ primaryAxisSizingMode: 'AUTO' | 'FIXED';
45
+ counterAxisSizingMode: 'AUTO' | 'FIXED';
46
+ primaryAxisAlignItems: string;
47
+ counterAxisAlignItems: string;
48
+ layoutAlign: string;
49
+ layoutGrow: number;
50
+ layoutSizingHorizontal: 'HUG' | 'FIXED' | 'FILL';
51
+ layoutSizingVertical: 'HUG' | 'FIXED' | 'FILL';
52
+ counterAxisAlignSelf: string;
53
+ width: number;
54
+ height: number;
55
+ paddingLeft: number;
56
+ paddingRight: number;
57
+ paddingTop: number;
58
+ paddingBottom: number;
59
+ fills: unknown[];
60
+ strokes: unknown[];
61
+ effects: unknown[];
62
+ children: never[];
63
+ appendChild(): void;
64
+ resize(w: number, h: number): void;
65
+ };
66
+
67
+ function makeStubFrame(): StubFrame {
68
+ return {
69
+ type: 'FRAME',
70
+ layoutMode: 'NONE',
71
+ primaryAxisSizingMode: 'AUTO',
72
+ counterAxisSizingMode: 'AUTO',
73
+ primaryAxisAlignItems: 'MIN',
74
+ counterAxisAlignItems: 'MIN',
75
+ layoutAlign: 'INHERIT',
76
+ layoutGrow: 0,
77
+ layoutSizingHorizontal: 'HUG',
78
+ layoutSizingVertical: 'HUG',
79
+ counterAxisAlignSelf: 'AUTO',
80
+ width: 0,
81
+ height: 0,
82
+ paddingLeft: 0,
83
+ paddingRight: 0,
84
+ paddingTop: 0,
85
+ paddingBottom: 0,
86
+ fills: [],
87
+ strokes: [],
88
+ effects: [],
89
+ children: [],
90
+ appendChild() { /* no-op */ },
91
+ resize(w, h) { this.width = w; this.height = h; },
92
+ };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Parent + child factories
97
+ //
98
+ // Each factory mimics the state the per-class parser would have already
99
+ // written to the frame BEFORE `applyChildProperties` runs. Keeping these
100
+ // here (rather than running tailwind.ts in-process) means the matrix tests
101
+ // the implicit-stretch / w-full / text-align passes in isolation, which is
102
+ // where the historical recurrences happened.
103
+ // ---------------------------------------------------------------------------
104
+
105
+ type ParentKind =
106
+ | 'flex-col-stretch' // real flex container, CSS items-stretch default
107
+ | 'block-flow' // <div> promoted to VERTICAL but inline-flow
108
+ | 'block-flow-text-center' // block-flow with text-align: center
109
+ | 'horizontal-row'; // <div class="flex flex-row">
110
+
111
+ function makeParent(kind: ParentKind): FrameNode {
112
+ const parent = makeStubFrame();
113
+ if (kind === 'horizontal-row') {
114
+ parent.layoutMode = 'HORIZONTAL';
115
+ parent.width = 600;
116
+ parent.primaryAxisSizingMode = 'FIXED';
117
+ setFrameCrossAlign(parent as unknown as FrameNode, 'MIN');
118
+ setFrameFromBlockFlow(parent as unknown as FrameNode, false);
119
+ } else {
120
+ parent.layoutMode = 'VERTICAL';
121
+ parent.width = 600;
122
+ parent.primaryAxisSizingMode = 'FIXED';
123
+ if (kind === 'flex-col-stretch') {
124
+ setFrameCrossAlign(parent as unknown as FrameNode, 'STRETCH');
125
+ setFrameFromBlockFlow(parent as unknown as FrameNode, false);
126
+ } else {
127
+ setFrameCrossAlign(parent as unknown as FrameNode, 'MIN');
128
+ setFrameFromBlockFlow(parent as unknown as FrameNode, true);
129
+ if (kind === 'block-flow-text-center') {
130
+ setFrameInlineAlign(parent as unknown as FrameNode, 'CENTER');
131
+ }
132
+ }
133
+ }
134
+ return parent as unknown as FrameNode;
135
+ }
136
+
137
+ type ChildKind =
138
+ | 'inline-flex-pill' // <span class="inline-flex items-center rounded-full px-3 py-1">
139
+ | 'block-w-full' // <div class="w-full p-4">
140
+ | 'block-plain' // <div class="p-4">
141
+ | 'absolute-overlay'; // <div class="absolute inset-0">
142
+
143
+ // NOTE on `absolute-overlay`: classes deliberately omit `w-full` / `h-full`.
144
+ // The implicit-stretch exclusion for out-of-flow children is what this matrix
145
+ // tests. The `w-full` handler in applyChildProperties writes `STRETCH` even
146
+ // for absolute children (dead — Figma ignores layoutAlign on absolutely-
147
+ // positioned children); the actual full-resize happens downstream in
148
+ // `applyFullWidthIfPossible` (commit d817a58). That sizing surface has its
149
+ // own regression in `aspect-percent-position-regression.ts`.
150
+
151
+ function makeChild(kind: ChildKind): { child: FrameNode; classes: string[] } {
152
+ const child = makeStubFrame();
153
+ if (kind === 'inline-flex-pill') {
154
+ // Per-class parser already wrote HORIZONTAL + HUG-mode for inline-flex.
155
+ child.layoutMode = 'HORIZONTAL';
156
+ return {
157
+ child: child as unknown as FrameNode,
158
+ classes: 'inline-flex items-center rounded-full px-3 py-1 text-sm font-medium'.split(' '),
159
+ };
160
+ }
161
+ if (kind === 'block-w-full') {
162
+ return {
163
+ child: child as unknown as FrameNode,
164
+ classes: 'w-full p-4'.split(' '),
165
+ };
166
+ }
167
+ if (kind === 'block-plain') {
168
+ return {
169
+ child: child as unknown as FrameNode,
170
+ classes: 'p-4'.split(' '),
171
+ };
172
+ }
173
+ // absolute-overlay (deliberately NO w-full / h-full — see note above)
174
+ return {
175
+ child: child as unknown as FrameNode,
176
+ classes: 'absolute inset-0'.split(' '),
177
+ };
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Truth table — every cell catches a class of historical drift.
182
+ // `note` documents which incident the row guards against.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ interface Cell {
186
+ child: ChildKind;
187
+ parent: ParentKind;
188
+ expect: {
189
+ layoutAlign?: 'INHERIT' | 'STRETCH';
190
+ layoutGrow?: number;
191
+ counterAxisAlignSelf?: 'AUTO' | 'CENTER' | 'MIN' | 'MAX';
192
+ };
193
+ note: string;
194
+ }
195
+
196
+ const CELLS: Cell[] = [
197
+ // ---- inline-flex pill ---------------------------------------------------
198
+ { child: 'inline-flex-pill', parent: 'flex-col-stretch',
199
+ expect: { layoutAlign: 'STRETCH' },
200
+ note: 'dialog footer base-bp: inline-flex button SHOULD stretch in real flex parent' },
201
+ { child: 'inline-flex-pill', parent: 'block-flow',
202
+ expect: { layoutAlign: 'INHERIT' },
203
+ note: 'recurring 3-site pill bug: must NOT stretch in block-flow vertical card' },
204
+ { child: 'inline-flex-pill', parent: 'block-flow-text-center',
205
+ expect: { layoutAlign: 'INHERIT', counterAxisAlignSelf: 'CENTER' },
206
+ note: 'round-trip "in motion" pill: NOT stretched, BUT centered via text-align inheritance' },
207
+ { child: 'inline-flex-pill', parent: 'horizontal-row',
208
+ expect: { layoutAlign: 'INHERIT' },
209
+ note: 'horizontal parent has no implicit-stretch pass; inline-flex passes through' },
210
+
211
+ // ---- block w-full -------------------------------------------------------
212
+ { child: 'block-w-full', parent: 'flex-col-stretch',
213
+ expect: { layoutAlign: 'STRETCH' },
214
+ note: 'w-full in vertical flex parent → STRETCH cross-axis' },
215
+ { child: 'block-w-full', parent: 'block-flow',
216
+ expect: { layoutAlign: 'STRETCH' },
217
+ note: 'w-full in block-flow parent → STRETCH (CSS items-stretch emulation)' },
218
+ { child: 'block-w-full', parent: 'block-flow-text-center',
219
+ expect: { layoutAlign: 'STRETCH' },
220
+ note: 'block child not affected by text-align — full-width regardless' },
221
+ { child: 'block-w-full', parent: 'horizontal-row',
222
+ expect: { layoutGrow: 1 },
223
+ note: 'w-full in horizontal parent → grow primary axis (NOT layoutAlign)' },
224
+
225
+ // ---- plain block (no width directive) -----------------------------------
226
+ { child: 'block-plain', parent: 'flex-col-stretch',
227
+ expect: { layoutAlign: 'STRETCH' },
228
+ note: 'CSS items-stretch default applies to block child' },
229
+ { child: 'block-plain', parent: 'block-flow',
230
+ expect: { layoutAlign: 'STRETCH' },
231
+ note: 'plugin emulates items-stretch for block children in block-flow' },
232
+ { child: 'block-plain', parent: 'block-flow-text-center',
233
+ expect: { layoutAlign: 'STRETCH' },
234
+ note: 'text-center is for inline children only; block child still stretches' },
235
+ { child: 'block-plain', parent: 'horizontal-row',
236
+ expect: { layoutAlign: 'INHERIT', layoutGrow: 0 },
237
+ note: 'no width directive in horizontal parent: neither grow nor stretch' },
238
+
239
+ // ---- absolute overlay (out-of-flow) -------------------------------------
240
+ { child: 'absolute-overlay', parent: 'flex-col-stretch',
241
+ expect: { layoutAlign: 'INHERIT' },
242
+ note: 'out-of-flow children opt out of implicit-stretch regardless of parent' },
243
+ { child: 'absolute-overlay', parent: 'block-flow',
244
+ expect: { layoutAlign: 'INHERIT' },
245
+ note: 'absolute child in block-flow: position handled by deferred-layout, not stretch' },
246
+ { child: 'absolute-overlay', parent: 'block-flow-text-center',
247
+ expect: { layoutAlign: 'INHERIT' },
248
+ note: 'absolute child not affected by text-center' },
249
+ { child: 'absolute-overlay', parent: 'horizontal-row',
250
+ expect: { layoutAlign: 'INHERIT' },
251
+ note: 'absolute child in horizontal parent: position handled separately' },
252
+ ];
253
+
254
+ function runRegression(): void {
255
+ const failures: string[] = [];
256
+
257
+ for (const cell of CELLS) {
258
+ const parent = makeParent(cell.parent);
259
+ const { child, classes } = makeChild(cell.child);
260
+
261
+ LayoutParser.applyChildProperties(child, classes, parent);
262
+
263
+ const cellId = `${cell.child} × ${cell.parent}`;
264
+ const stub = child as unknown as StubFrame;
265
+
266
+ if (cell.expect.layoutAlign !== undefined) {
267
+ if (stub.layoutAlign !== cell.expect.layoutAlign) {
268
+ failures.push(
269
+ `${cellId}: expected layoutAlign=${cell.expect.layoutAlign}, got ${stub.layoutAlign}\n → ${cell.note}`,
270
+ );
271
+ }
272
+ }
273
+ if (cell.expect.layoutGrow !== undefined) {
274
+ if (stub.layoutGrow !== cell.expect.layoutGrow) {
275
+ failures.push(
276
+ `${cellId}: expected layoutGrow=${cell.expect.layoutGrow}, got ${stub.layoutGrow}\n → ${cell.note}`,
277
+ );
278
+ }
279
+ }
280
+ if (cell.expect.counterAxisAlignSelf !== undefined) {
281
+ if (stub.counterAxisAlignSelf !== cell.expect.counterAxisAlignSelf) {
282
+ failures.push(
283
+ `${cellId}: expected counterAxisAlignSelf=${cell.expect.counterAxisAlignSelf}, got ${stub.counterAxisAlignSelf}\n → ${cell.note}`,
284
+ );
285
+ }
286
+ }
287
+ }
288
+
289
+ if (failures.length > 0) {
290
+ for (const msg of failures) console.error(' ✗ ' + msg);
291
+ assert.fail(`${failures.length}/${CELLS.length} matrix cells failed`);
292
+ }
293
+
294
+ console.log(`child-sizing-matrix-regression: PASS (${CELLS.length} cells)`);
295
+ }
296
+
297
+ try {
298
+ runRegression();
299
+ } catch (err) {
300
+ console.error('child-sizing-matrix-regression: FAIL');
301
+ console.error(err);
302
+ process.exit(1);
303
+ }