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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Intrinsic-sizing applier.
3
+ *
4
+ * Walks a built Figma tree and rewrites the auto-layout properties
5
+ * that would otherwise leave HUG containers stuck at Figma's frame-
6
+ * creation default (~100px) due to the STRETCH-in-HUG-chain deadlock
7
+ * described in `intrinsic-sizing.ts`.
8
+ *
9
+ * ## Why we transform alignment, not size
10
+ *
11
+ * Per the Figma plugin API:
12
+ *
13
+ * > AUTO: The primary axis length is determined by the size of the
14
+ * > children.
15
+ * > AUTO: The counter axis length is determined by the size of the
16
+ * > children. If set, the auto-layout frame will automatically resize
17
+ * > along the counter axis to fit its children.
18
+ *
19
+ * Calling `frame.resize(w, h)` on a HUG frame does NOT stick — the
20
+ * engine recomputes from children on the next pass. So we can't force
21
+ * a width on a HUG container without flipping `layoutMode` to `'NONE'`
22
+ * (which destroys designer auto-layout editability).
23
+ *
24
+ * Instead, we change the *child* contribution to the parent's HUG
25
+ * computation. For each STRETCH child whose parent's chain isn't
26
+ * width-anchored:
27
+ *
28
+ * 1. Flip the child's `layoutAlign` from `'STRETCH'` to `'INHERIT'`
29
+ * (drop the cross-axis stretch directive).
30
+ * 2. Reset the sizing axis that the STRETCH side-effect had silently
31
+ * flipped to FIXED back to `'AUTO'`. Parent is VERTICAL (per the
32
+ * predicate), so the affected child axis is its horizontal axis:
33
+ * `primaryAxisSizingMode` for a HORIZONTAL child,
34
+ * `counterAxisSizingMode` for a VERTICAL child.
35
+ *
36
+ * After the flip, the child hugs its own content, the parent's HUG
37
+ * resolves to max-of-children — the exact result CSS produces via
38
+ * max-content. Designer affordance is preserved: if the designer
39
+ * later anchors the chain by setting an ancestor to FIXED, they can
40
+ * re-enable Fill / STRETCH on the children manually and Figma's
41
+ * STRETCH cascade will work correctly against the new anchor.
42
+ *
43
+ * ## Phase
44
+ *
45
+ * Phase 3 (deferred layout). Runs once per built story-layout tree
46
+ * from `populateStoryLayout`, before `reflowDeferredAbsolutePositioningTree`.
47
+ */
48
+
49
+ import { isUnanchoredStretchChild } from './intrinsic-sizing';
50
+
51
+ export interface IntrinsicApplierStats {
52
+ /** Number of children whose layoutAlign was flipped STRETCH → INHERIT. */
53
+ alignmentFlips: number;
54
+ /** Number of nodes walked. */
55
+ nodesWalked: number;
56
+ }
57
+
58
+ /**
59
+ * Walk the tree rooted at `root`. For every STRETCH child of a VERTICAL
60
+ * parent whose chain doesn't reach a width-anchored ancestor, flip the
61
+ * child's `layoutAlign` to `'INHERIT'` and reset its horizontal sizing
62
+ * axis to `'AUTO'`. Children then hug their own content; the parent's
63
+ * HUG resolves to max-of-children.
64
+ *
65
+ * Returns a stats object for observability / tests.
66
+ */
67
+ export function breakHugStretchDeadlocks(root: SceneNode): IntrinsicApplierStats {
68
+ const stats: IntrinsicApplierStats = { alignmentFlips: 0, nodesWalked: 0 };
69
+ walk(root, stats);
70
+ return stats;
71
+ }
72
+
73
+ function walk(node: SceneNode, stats: IntrinsicApplierStats): void {
74
+ stats.nodesWalked += 1;
75
+
76
+ if (isUnanchoredStretchChild(node)) {
77
+ try {
78
+ const frame = node as FrameNode;
79
+ // Order matters per Figma API: AUTO is not valid on an axis where
80
+ // layoutAlign === 'STRETCH'. Drop the STRETCH first, then reset
81
+ // the sizing axis that the STRETCH side-effect had flipped to
82
+ // FIXED. Parent is VERTICAL (guaranteed by the predicate), so the
83
+ // affected axis is child's HORIZONTAL: primary for a HORIZONTAL
84
+ // child, counter for a VERTICAL child.
85
+ frame.layoutAlign = 'INHERIT';
86
+ if ('layoutMode' in frame) {
87
+ if (frame.layoutMode === 'HORIZONTAL') {
88
+ frame.primaryAxisSizingMode = 'AUTO';
89
+ } else if (frame.layoutMode === 'VERTICAL') {
90
+ frame.counterAxisSizingMode = 'AUTO';
91
+ }
92
+ }
93
+ stats.alignmentFlips += 1;
94
+ } catch (_err) {
95
+ // Some node types may not accept the assignment in edge cases;
96
+ // ignore and continue — best-effort transform.
97
+ }
98
+ }
99
+
100
+ if ('children' in node && Array.isArray(node.children)) {
101
+ for (const child of node.children) {
102
+ walk(child, stats);
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Intrinsic-sizing predicates.
3
+ *
4
+ * Pure inspectors that detect where Figma's auto-layout reaches the
5
+ * intrinsic-sizing deadlock that CSS resolves via its two-pass
6
+ * algorithm (max-content bottom-up, then constraints top-down).
7
+ *
8
+ * ## The deadlock
9
+ *
10
+ * Consider a chain where a HUG container has STRETCH children:
11
+ *
12
+ * parent VERTICAL HUG counter ← width = max(children widths)
13
+ * └── child VERTICAL layoutAlign=STRETCH ← width = parent.width
14
+ *
15
+ * CSS resolves this by computing the child's intrinsic max-content,
16
+ * making that the parent's HUG width, then stretching the child back
17
+ * to that width. Figma's layout engine doesn't compute max-content —
18
+ * the dependency is circular, and Figma falls back to the frame-
19
+ * creation default (~100px) at the top, which then cascades down as
20
+ * "stretch to 100" through every level of the chain.
21
+ *
22
+ * ## What "width propagation" means here
23
+ *
24
+ * A frame's horizontal width is "definite" (i.e. its source is not
25
+ * one of its own children) if EITHER:
26
+ *
27
+ * 1. It is the root frame with an explicit width (Story Layout etc.).
28
+ * 2. It is a STRETCH child of a VERTICAL parent whose width is
29
+ * definite — the parent's width flows down to the child's
30
+ * horizontal axis.
31
+ * 3. It is a GROW (layoutGrow=1) child of a HORIZONTAL parent whose
32
+ * primary axis (horizontal) is definite — parent's primary width
33
+ * minus siblings flows down.
34
+ *
35
+ * Otherwise the frame's width is HUG-of-children — i.e. determined by
36
+ * walking into the subtree. If every ancestor in turn is also HUG-of-
37
+ * children, the chain is unanchored and the deadlock applies.
38
+ *
39
+ * ## Why we don't trust `primaryAxisSizingMode === 'FIXED'` alone
40
+ *
41
+ * Per the Figma plugin API, `layoutAlign='STRETCH'` cannot coexist
42
+ * with `primaryAxisSizingMode='AUTO'` on the relevant axis — Figma
43
+ * silently flips it to FIXED. So a HORIZONTAL frame inside a VERTICAL
44
+ * parent with STRETCH appears as `primaryAxisSizingMode=FIXED` even
45
+ * when the user wrote no explicit width utility. Reading the mode
46
+ * alone tells us only that Figma's resolver decided FIXED, not whether
47
+ * the width was authored by intent. The propagation walk is the
48
+ * authoritative check.
49
+ *
50
+ * ## Phase
51
+ *
52
+ * Phase 3 (deferred layout). The predicates need full ancestor context
53
+ * — they can only run after `appendChild`. Use from
54
+ * `reflowDeferredAbsolutePositioningTree` or another post-build pass.
55
+ */
56
+
57
+ const WIDTH_DEFINITE_CACHE = new WeakMap<SceneNode, boolean>();
58
+
59
+ interface ChainRule {
60
+ /** Walks parent → child to see if the child's width is propagated from the parent. */
61
+ childWidthPropagates: (parent: FrameNode, child: SceneNode) => boolean;
62
+ }
63
+
64
+ const VERTICAL_PARENT_RULE: ChainRule = {
65
+ childWidthPropagates(_parent, child) {
66
+ // In a VERTICAL parent the child's horizontal axis is the parent's
67
+ // counter axis. The child gets the parent's width only if it
68
+ // explicitly stretches.
69
+ return 'layoutAlign' in child && child.layoutAlign === 'STRETCH';
70
+ },
71
+ };
72
+
73
+ const HORIZONTAL_PARENT_RULE: ChainRule = {
74
+ childWidthPropagates(_parent, child) {
75
+ // In a HORIZONTAL parent the child's horizontal axis is the parent's
76
+ // primary axis. The child gets a definite slice of the parent's
77
+ // width only if it grows to fill remaining primary space.
78
+ return 'layoutGrow' in child && child.layoutGrow === 1;
79
+ },
80
+ };
81
+
82
+ function getParentRule(parent: FrameNode): ChainRule | null {
83
+ if (parent.layoutMode === 'VERTICAL') return VERTICAL_PARENT_RULE;
84
+ if (parent.layoutMode === 'HORIZONTAL') return HORIZONTAL_PARENT_RULE;
85
+ // NONE / freeform parent does not propagate sizing through auto-layout.
86
+ return null;
87
+ }
88
+
89
+ function isAuthoredRoot(node: SceneNode): boolean {
90
+ // Top-level frames (no parent or a non-frame parent like PageNode) are
91
+ // treated as definite — their width came from upstream code that knows
92
+ // what it's doing (Story Layout, component master, etc.).
93
+ const parent = node.parent;
94
+ if (!parent) return true;
95
+ if (!('layoutMode' in parent)) return true;
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Does this node's horizontal width come from somewhere other than its
101
+ * own children? Memoized — a tree walk evaluates each node at most once.
102
+ *
103
+ * NOTE: callers must invalidate the cache (call `clearWidthDefiniteCache`)
104
+ * if they mutate any property the predicate reads (layoutAlign,
105
+ * layoutGrow, layoutMode) between calls. Within a single Phase-3 pass
106
+ * the tree is stable; this is the intended use.
107
+ */
108
+ export function hasDefiniteWidth(node: SceneNode): boolean {
109
+ const cached = WIDTH_DEFINITE_CACHE.get(node);
110
+ if (cached !== undefined) return cached;
111
+ const result = computeHasDefiniteWidth(node, new Set());
112
+ WIDTH_DEFINITE_CACHE.set(node, result);
113
+ return result;
114
+ }
115
+
116
+ function computeHasDefiniteWidth(node: SceneNode, seen: Set<SceneNode>): boolean {
117
+ // Cycle guard for pathological trees.
118
+ if (seen.has(node)) return false;
119
+ seen.add(node);
120
+
121
+ if (isAuthoredRoot(node)) return true;
122
+
123
+ // Trust an explicit FIXED width set by the plugin's own code paths
124
+ // (applyFullWidthIfPossible, applyGridColumnsIfPossible, w-[Npx], etc.)
125
+ // when it's NOT a layoutAlign=STRETCH / layoutGrow=1 side-effect.
126
+ // Figma silently flips primaryAxisSizingMode to FIXED when those
127
+ // child-side directives are set, but the resulting "FIXED" width may
128
+ // be the frame-creation default (~100) — we can't trust those. When
129
+ // the child is neither STRETCH nor GROW, a FIXED axis means the value
130
+ // was authored.
131
+ if (hasAuthoredFixedWidth(node)) return true;
132
+
133
+ const parent = node.parent as FrameNode;
134
+ const rule = getParentRule(parent);
135
+ if (rule == null) return false;
136
+ if (!rule.childWidthPropagates(parent, node)) return false;
137
+ return computeHasDefiniteWidth(parent, seen);
138
+ }
139
+
140
+ function hasAuthoredFixedWidth(node: SceneNode): boolean {
141
+ // STRETCH / GROW side-effects: the FIXED axis came from Figma silently
142
+ // upgrading the sizing mode, not from authored intent.
143
+ if ('layoutAlign' in node && node.layoutAlign === 'STRETCH') return false;
144
+ if ('layoutGrow' in node && node.layoutGrow === 1) return false;
145
+ if (!('layoutMode' in node)) return false;
146
+ const frame = node as FrameNode;
147
+ if (!('width' in frame) || !Number.isFinite(frame.width) || frame.width <= 0) return false;
148
+ if (frame.layoutMode === 'HORIZONTAL') {
149
+ return frame.primaryAxisSizingMode === 'FIXED';
150
+ }
151
+ if (frame.layoutMode === 'VERTICAL') {
152
+ return frame.counterAxisSizingMode === 'FIXED';
153
+ }
154
+ // NONE freeform — width is just whatever the user / plugin set.
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * Is this node a STRETCH child of a VERTICAL parent whose width is not
160
+ * definite — i.e. the STRETCH cascades into the frame-creation default
161
+ * and produces a ~100px stuck frame width?
162
+ *
163
+ * Restricted to VERTICAL parents because that's the width-deadlock
164
+ * shape: in a VERTICAL parent, `layoutAlign=STRETCH` on a child means
165
+ * "stretch horizontally to parent's width". If parent's width is HUG
166
+ * and unanchored, the STRETCH has nothing to stretch to.
167
+ *
168
+ * (HORIZONTAL parents have an analogous height-deadlock for
169
+ * `layoutAlign=STRETCH` and a width-deadlock for `layoutGrow=1`, but
170
+ * those are separate concerns covered by other heuristics in the
171
+ * pipeline. Width deadlocks via STRETCH in a VERTICAL chain are the
172
+ * canonical pattern this module addresses.)
173
+ */
174
+ export function isUnanchoredStretchChild(node: SceneNode): boolean {
175
+ if (!('layoutAlign' in node)) return false;
176
+ if (node.layoutAlign !== 'STRETCH') return false;
177
+ const parent = node.parent as SceneNode | null;
178
+ if (!parent || !('layoutMode' in parent)) return false;
179
+ const parentFrame = parent as FrameNode;
180
+ if (parentFrame.layoutMode !== 'VERTICAL') return false;
181
+ return !hasDefiniteWidth(parent);
182
+ }
183
+
@@ -259,12 +259,48 @@ export class LayoutParser {
259
259
  const parentWantsStretch =
260
260
  fromFlexStretchBranch
261
261
  || (fromBlockFlowMinBranch && !childHasInlineDisplay);
262
+ // Only honour implicit STRETCH when the parent has a definite
263
+ // counter-axis width (FIXED). With AUTO counter sizing the parent
264
+ // is shrink-to-fit; stretching the child to "parent's width" then
265
+ // means stretching to whatever earlier non-stretch siblings hugged
266
+ // the parent to — which in CSS would never happen because CSS
267
+ // intrinsic sizing considers every child's max-content width
268
+ // before resolving the parent. Without this guard a `<div><h3>x</h3>
269
+ // <p>longer text</p></div>` collapses the `<p>` to the `<h3>`'s
270
+ // width and the longer text wraps. Skipping the stretch lets the
271
+ // `<p>` hug to its natural width and the parent grow to the wider
272
+ // of the two — CSS-equivalent.
273
+ const parentCounterFixed =
274
+ 'counterAxisSizingMode' in parent
275
+ && parent.counterAxisSizingMode === 'FIXED';
262
276
  const shouldImplicitStretch = parentLayout === 'VERTICAL'
263
277
  && parentWantsStretch
278
+ && parentCounterFixed
264
279
  && ir.selfAlign === undefined
265
280
  && !isOutOfFlowChild;
266
281
  if (shouldImplicitStretch && 'layoutAlign' in child && !this.hasNonAutoWidthConstraint(childClasses)) {
267
282
  child.layoutAlign = 'STRETCH';
283
+ // Lock the counter axis to FIXED so the child's *own* children
284
+ // recognise it as definite-width in the next applyChildProperties
285
+ // pass. Without this, the implicit-STRETCH cascade stops one
286
+ // level deep — a block descendant of a STRETCH-aligned wrapper
287
+ // sees `counterAxisSizingMode === 'AUTO'` on its parent and the
288
+ // `parentCounterFixed` guard above skips its own STRETCH. The
289
+ // wrapper then HUGs to the widest non-stretch sibling (typically
290
+ // a short title) and longer block siblings (a description `<p>`)
291
+ // get cut to that width — which in CSS would never happen
292
+ // because the wrapper's effective width came from *its* parent.
293
+ // Setting FIXED here is safe: layoutAlign='STRETCH' already
294
+ // forces width = parent.contentWidth, so locking the sizing
295
+ // mode doesn't change the rendered size — it just signals the
296
+ // definite-width contract downstream.
297
+ if ('counterAxisSizingMode' in child) {
298
+ try {
299
+ child.counterAxisSizingMode = 'FIXED';
300
+ } catch (_err) {
301
+ // ignore — some node types (instance/text) reject the property
302
+ }
303
+ }
268
304
  }
269
305
 
270
306
  // CSS `text-align` on a block container centers/aligns inline content
@@ -36,10 +36,20 @@ export function parseLayoutMode(classes: string[]): 'HORIZONTAL' | 'VERTICAL' |
36
36
  }
37
37
  if (cls === 'grid' || cls === 'inline-grid') {
38
38
  // grid without grid-cols-N is a single-column layout (like flex-col).
39
- // grid WITH grid-cols-N wraps horizontally — the column count is
40
- // applied later by markGridColumnsNode, not stored in the IR.
41
- const hasColumns = classes.some(c => /^grid-cols-\d+$/.test(c));
42
- mode = hasColumns ? 'HORIZONTAL' : 'VERTICAL';
39
+ // grid WITH grid-cols-N (N >= 2) wraps horizontally — the column count
40
+ // is applied later by markGridColumnsNode, not stored in the IR.
41
+ //
42
+ // `grid-cols-1` is special: one column in CSS means children stack
43
+ // vertically (single column = no horizontal flow). Treating it as
44
+ // HORIZONTAL — like grid-cols-2+ — flips the layout, eats the
45
+ // primary-axis gap (no vertical itemSpacing), and forces wrap. Comes
46
+ // up via `mapClassNameForBreakpoint`'s grid-cols-1 injection at the
47
+ // base breakpoint when the source had a responsive `sm:grid-cols-N`.
48
+ const hasMultiColumns = classes.some(c => {
49
+ const match = c.match(/^grid-cols-(\d+)$/);
50
+ return match != null && parseInt(match[1], 10) >= 2;
51
+ });
52
+ mode = hasMultiColumns ? 'HORIZONTAL' : 'VERTICAL';
43
53
  directionSet = true;
44
54
  continue;
45
55
  }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Table column-width unification.
3
+ *
4
+ * Semantic HTML `<table>` columns align across rows because the browser
5
+ * computes column widths globally (max of any cell's max-content
6
+ * width per column, then distributed if a definite table width
7
+ * applies). Figma has no equivalent — each `<tr>` is its own HORIZONTAL
8
+ * auto-layout frame and its cells size independently. Rows whose
9
+ * content sums to different totals therefore render with misaligned
10
+ * columns: column N starts at a different X-offset on each row, and
11
+ * `whitespace-nowrap` cells visually run into their neighbours when
12
+ * the row's hugged width happens to be tighter than the sum of
13
+ * untruncated cells.
14
+ *
15
+ * This module runs in Phase 3 (deferred layout) and walks the built
16
+ * tree for `<table>` frames. For each table, it computes the
17
+ * max-content width per column across all rows (incl. `<thead>` /
18
+ * `<tbody>` / `<tfoot>`) and resizes every cell at that column index
19
+ * to the column's max width. The result mirrors what the browser
20
+ * produces from `table-fixed` — columns line up across rows.
21
+ *
22
+ * ## What this fix does NOT touch
23
+ *
24
+ * - **Cells with explicit width** (`w-32`, `flex-1`, etc.). Existing
25
+ * `hasTableCellSizeOverride` cells are left alone; designer intent
26
+ * wins over the inferred column width.
27
+ * - **Colspan > 1 cells**. They take their own natural width and are
28
+ * excluded from per-column max calculation; without this exclusion a
29
+ * `colSpan={7}` empty-state row would dominate column 0's max-width
30
+ * and push every other row's first column to span the full table.
31
+ * Skipping leaves them as-is — they render at content width and may
32
+ * not perfectly align under the multi-cell rows, but the multi-cell
33
+ * rows themselves align correctly with each other.
34
+ * - **Tables nested inside tables**. Each table is processed
35
+ * independently; outer-table cells that contain an inner table are
36
+ * sized by the inner table's column unification, which is fine.
37
+ *
38
+ * ## Phase
39
+ *
40
+ * Phase 3 (deferred layout). Runs once per built story-layout tree,
41
+ * after `reflowDeferredAbsolutePositioningTree` so cell sizes are
42
+ * settled. Earlier passes set every cell's `layoutGrow = 1` and let
43
+ * the row hug to content — at this point each cell's `width` is its
44
+ * max-content width per row, which is exactly what we need to compute
45
+ * the per-column max.
46
+ */
47
+
48
+ import { applyDeferredTruncationPass } from './text-truncate-pass';
49
+
50
+ const TABLE_TAGS = new Set(['table']);
51
+ const ROW_GROUP_TAGS = new Set(['thead', 'tbody', 'tfoot']);
52
+ const ROW_TAG = 'tr';
53
+ const CELL_TAGS = new Set(['td', 'th']);
54
+
55
+ export interface TableAlignStats {
56
+ tablesProcessed: number;
57
+ cellsResized: number;
58
+ }
59
+
60
+ /**
61
+ * Walk the tree rooted at `root`. For every `<table>` frame, unify
62
+ * each column's width across all its rows to the column's max-content
63
+ * width.
64
+ *
65
+ * Returns observability stats. Caller may discard.
66
+ */
67
+ export function applyTableColumnAlignment(root: SceneNode): TableAlignStats {
68
+ const stats: TableAlignStats = { tablesProcessed: 0, cellsResized: 0 };
69
+ walk(root, stats);
70
+ return stats;
71
+ }
72
+
73
+ function walk(node: SceneNode, stats: TableAlignStats): void {
74
+ if ('name' in node && TABLE_TAGS.has(String(node.name).toLowerCase()) && 'children' in node) {
75
+ alignTable(node as FrameNode, stats);
76
+ // Tables can be nested — keep walking children so an outer-table
77
+ // alignment doesn't prevent a nested table from being processed.
78
+ }
79
+ if ('children' in node && Array.isArray(node.children)) {
80
+ for (const child of node.children) walk(child, stats);
81
+ }
82
+ }
83
+
84
+ function alignTable(tableFrame: FrameNode, stats: TableAlignStats): void {
85
+ stats.tablesProcessed += 1;
86
+
87
+ // Collect every <tr> under thead/tbody/tfoot (and any direct <tr> child).
88
+ const rows: FrameNode[] = [];
89
+ collectRows(tableFrame, rows);
90
+ if (rows.length < 2) {
91
+ // Single-row tables don't have a misalignment to fix.
92
+ return;
93
+ }
94
+
95
+ // First pass: per-column max of single-cell (colspan===1) cells.
96
+ // Multi-cell rows (colspan>1) are excluded from the max so an
97
+ // empty-state cell spanning all columns doesn't blow up column 0.
98
+ const maxWidths: number[] = [];
99
+ for (const row of rows) {
100
+ const cells = inFlowCells(row);
101
+ for (let c = 0; c < cells.length; c++) {
102
+ const cell = cells[c];
103
+ if (getColSpan(cell) !== 1) continue;
104
+ const w = Number.isFinite(cell.width) ? cell.width : 0;
105
+ if (w <= 0) continue;
106
+ if (maxWidths[c] === undefined || w > maxWidths[c]) {
107
+ maxWidths[c] = w;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Fit the column sum to the table's available width. Mirrors the
113
+ // common consumer pattern of `min-w-full table-fixed`:
114
+ // - sum > available → scale DOWN proportionally (columns shrink
115
+ // to fit; cells truncate / wrap as in `table-fixed`).
116
+ // - sum < available → scale UP proportionally (columns expand to
117
+ // fill the row; matches `min-w-full` enforcing table = parent
118
+ // width).
119
+ // - no available anchor → leave at natural max-content widths.
120
+ // Without scale-up, Figma rows under-fill the card and the row
121
+ // separator borders end mid-card. Without scale-down they overflow
122
+ // the card right edge.
123
+ const totalNaturalWidth = maxWidths.reduce((acc, w) => acc + (w || 0), 0);
124
+ const availableWidth = computeTableAvailableWidth(tableFrame);
125
+ const finalWidths = (availableWidth != null && totalNaturalWidth > 0 && availableWidth > 0)
126
+ ? maxWidths.map((w) => (w ? Math.max(1, Math.floor(w * (availableWidth / totalNaturalWidth))) : w))
127
+ : maxWidths;
128
+
129
+ // Second pass: lock every single-cell to its column's final width and
130
+ // normalise sizing state (layoutGrow=0, primaryAxisSizingMode=FIXED).
131
+ // Even when the cell's width happens to already match the target, the
132
+ // sizing-mode normalisation must run so the cell doesn't snap back to
133
+ // a different width if the row's overall width changes later.
134
+ //
135
+ // Immediately after locking each cell, run the deferred-truncation
136
+ // pass against the cell. Any TextNode inside that the renderer
137
+ // marked via `markForTruncation` (because its source had `truncate`
138
+ // / `line-clamp-N`) is now resized to the cell's final content
139
+ // width with `textTruncation = 'ENDING'`. Cells whose text already
140
+ // fits are no-ops.
141
+ for (const row of rows) {
142
+ const cells = inFlowCells(row);
143
+ for (let c = 0; c < cells.length; c++) {
144
+ const cell = cells[c];
145
+ if (getColSpan(cell) !== 1) continue;
146
+ const target = finalWidths[c];
147
+ if (target === undefined || target <= 0) continue;
148
+ const needsResize = Math.abs(cell.width - target) >= 0.5;
149
+ lockCellWidth(cell, target);
150
+ if (needsResize) stats.cellsResized += 1;
151
+ applyDeferredTruncationPass(cell);
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Walk up from the table to find the nearest ancestor with a definite
158
+ * width, accumulating paddings consumed along the path. Returns the
159
+ * available width at the table's slot, or `null` if no definite-width
160
+ * ancestor is reachable (in which case the table is allowed to take
161
+ * its natural max-content width).
162
+ *
163
+ * Sibling widths in horizontal ancestors are intentionally NOT
164
+ * subtracted — tables typically sit alone in a vertical card layout,
165
+ * and over-subtracting would shrink the table when it shouldn't.
166
+ * If a future use case needs sibling-aware budgeting, the shape mirrors
167
+ * `intrinsic-sizing.computeWrapAvailableWidth` and could be lifted into
168
+ * a shared helper at that point.
169
+ */
170
+ function computeTableAvailableWidth(tableFrame: FrameNode): number | null {
171
+ let cur: SceneNode = tableFrame;
172
+ let consumed = 0;
173
+ for (let depth = 0; depth < 32; depth++) {
174
+ const parent = cur.parent as SceneNode | null;
175
+ if (!parent || !('children' in parent) || !('layoutMode' in parent)) {
176
+ return null;
177
+ }
178
+ const frame = parent as FrameNode;
179
+ consumed += (frame.paddingLeft || 0) + (frame.paddingRight || 0);
180
+
181
+ if (frame.layoutMode === 'VERTICAL') {
182
+ // Vertical parent — width is the counter axis. Definite when
183
+ // counter sizing is FIXED with a real width.
184
+ if (
185
+ frame.counterAxisSizingMode === 'FIXED'
186
+ && Number.isFinite(frame.width)
187
+ && frame.width > 0
188
+ ) {
189
+ return Math.max(0, frame.width - consumed);
190
+ }
191
+ } else if (frame.layoutMode === 'HORIZONTAL') {
192
+ // Horizontal parent — width is the primary axis. Definite when
193
+ // primary sizing is FIXED.
194
+ if (
195
+ frame.primaryAxisSizingMode === 'FIXED'
196
+ && Number.isFinite(frame.width)
197
+ && frame.width > 0
198
+ ) {
199
+ return Math.max(0, frame.width - consumed);
200
+ }
201
+ } else {
202
+ // NONE / freeform — width is whatever it is.
203
+ if (Number.isFinite(frame.width) && frame.width > 0) {
204
+ return Math.max(0, frame.width - consumed);
205
+ }
206
+ }
207
+ cur = frame;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ function collectRows(node: SceneNode, out: FrameNode[]): void {
213
+ if (!('children' in node) || !Array.isArray(node.children)) return;
214
+ for (const child of node.children) {
215
+ const tagName = ('name' in child ? String(child.name).toLowerCase() : '');
216
+ if (tagName === ROW_TAG) {
217
+ out.push(child as FrameNode);
218
+ continue;
219
+ }
220
+ if (ROW_GROUP_TAGS.has(tagName)) {
221
+ collectRows(child, out);
222
+ }
223
+ // Anything else (e.g. nested table, divider) is skipped — we only
224
+ // unify columns of the immediate table's rows.
225
+ }
226
+ }
227
+
228
+ function inFlowCells(row: FrameNode): FrameNode[] {
229
+ const out: FrameNode[] = [];
230
+ if (!('children' in row) || !Array.isArray(row.children)) return out;
231
+ for (const child of row.children) {
232
+ if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') continue;
233
+ const tagName = ('name' in child ? String(child.name).toLowerCase() : '');
234
+ if (!CELL_TAGS.has(tagName)) continue;
235
+ out.push(child as FrameNode);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ function getColSpan(cell: FrameNode): number {
241
+ // colspan is stored on the scene node via setPluginData when emitted
242
+ // from the JSX scanner. Default to 1 when absent.
243
+ try {
244
+ const raw = cell.getPluginData('inkbridge:col-span');
245
+ if (!raw) return 1;
246
+ const n = parseInt(raw, 10);
247
+ return Number.isFinite(n) && n > 0 ? n : 1;
248
+ } catch (_e) {
249
+ return 1;
250
+ }
251
+ }
252
+
253
+ function lockCellWidth(cell: FrameNode, width: number): void {
254
+ // Drop layoutGrow so the cell honours its FIXED width instead of
255
+ // re-stretching to fill the row. Then flip primary sizing to FIXED
256
+ // and resize. Order matters per Figma API: AUTO is invalid when
257
+ // layoutGrow > 0 on the relevant axis.
258
+ try {
259
+ if ('layoutGrow' in cell && cell.layoutGrow !== 0) {
260
+ cell.layoutGrow = 0;
261
+ }
262
+ if ('primaryAxisSizingMode' in cell) {
263
+ cell.primaryAxisSizingMode = 'FIXED';
264
+ }
265
+ cell.resize(width, cell.height);
266
+ } catch (_e) {
267
+ // Best effort — some node subtypes (e.g. instance) may reject
268
+ // resize; the visual misalignment for that one cell is preferable
269
+ // to a crash that takes down the whole table.
270
+ }
271
+ }