inkbridge 0.1.0-beta.21 → 0.1.0-beta.22

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.
@@ -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
+
@@ -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
  }
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build.mjs. Do not edit manually.
2
- export const RENDER_ENGINE_VERSION = '011e89524e15bfda';
2
+ export const RENDER_ENGINE_VERSION = '40e3e9df6d50c02c';
@@ -328,15 +328,20 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
328
328
  continue;
329
329
  }
330
330
  if (prop === 'display' && (value === 'grid' || value === 'inline-grid')) {
331
- if (declaredGridColumns != null) {
331
+ // `grid-cols-1` (1 explicit column) is CSS-semantically a vertical
332
+ // stack — same as `grid` without any column class. Only flip to
333
+ // HORIZONTAL+WRAP when there are >=2 columns; otherwise route through
334
+ // the vertical-flow path so itemSpacing (primary axis = vertical)
335
+ // applies correctly and children don't get a stale horizontal layout.
336
+ if (declaredGridColumns != null && declaredGridColumns >= 2) {
332
337
  frame.layoutMode = 'HORIZONTAL';
333
338
  markGridColumnsNode(frame, declaredGridColumns);
334
339
  if (frame.layoutWrap !== undefined) {
335
340
  frame.layoutWrap = 'WRAP';
336
341
  }
337
342
  } else {
338
- // CSS grid without explicit template columns behaves like a single-column
339
- // vertical flow in our Figma mapping.
343
+ // CSS grid without explicit template columns (or with exactly 1
344
+ // column) behaves like a single-column vertical flow.
340
345
  frame.layoutMode = 'VERTICAL';
341
346
  markCssGridVerticalFrame(frame);
342
347
  }
@@ -842,6 +847,13 @@ function applySemanticUtilitiesToFrame(
842
847
  const atom = parseUtilityClass(cls);
843
848
  if (!atom.utility) continue;
844
849
 
850
+ // Filter breakpoint-prefixed atoms FIRST so utilities like `sm:grid-cols-2`
851
+ // don't leak through to `markGridColumnsNode` at the base breakpoint.
852
+ // The responsive-analyzer normally strips breakpoint prefixes before
853
+ // calling here, but defense-in-depth: any atom carrying a non-active
854
+ // variant must be skipped before any structural side-effects run.
855
+ if (!shouldApplyAtom(atom, 'default')) continue;
856
+
845
857
  const utility = atom.utility;
846
858
 
847
859
  const gridColsMatch = utility.match(/^grid-cols-(\d+)$/);
@@ -860,8 +872,6 @@ function applySemanticUtilitiesToFrame(
860
872
  continue;
861
873
  }
862
874
 
863
- if (!shouldApplyAtom(atom, 'default')) continue;
864
-
865
875
  if (utility === 'flex' || utility === 'inline-flex') {
866
876
  // Bare `flex` enables flex layout but does not set direction. Only write
867
877
  // HORIZONTAL when the direction hasn't been declared yet by a prior
@@ -906,14 +916,25 @@ function applySemanticUtilitiesToFrame(
906
916
  continue;
907
917
  }
908
918
  if (utility === 'grid' || utility === 'inline-grid') {
909
- const hasColumns = classes.some((c: string) => /^grid-cols-\d+$/.test(c));
910
- if (hasColumns) {
919
+ // Match parseLayoutMode + applyCssDeclarationsToFrame: only flip
920
+ // to HORIZONTAL+WRAP when there are >=2 columns. `grid-cols-1` is
921
+ // CSS-semantically a vertical stack (single column = no horizontal
922
+ // flow) and must route through the VERTICAL path so itemSpacing
923
+ // applies on the right axis. The `mapClassNameForBreakpoint`
924
+ // helper injects `grid-cols-1` at the base breakpoint when the
925
+ // source had a responsive `sm:grid-cols-N`, so this case is hit
926
+ // any time a grid with responsive columns renders at base.
927
+ const hasMultiColumns = classes.some((c: string) => {
928
+ const match = c.match(/^grid-cols-(\d+)$/);
929
+ return match != null && parseInt(match[1], 10) >= 2;
930
+ });
931
+ if (hasMultiColumns) {
911
932
  frame.layoutMode = 'HORIZONTAL';
912
933
  if (frame.layoutWrap !== undefined) {
913
934
  frame.layoutWrap = 'WRAP';
914
935
  }
915
936
  } else {
916
- // Single-column implicit grid → VERTICAL; children stretch to fill (CSS default align-items: stretch)
937
+ // Single-column implicit grid or `grid-cols-1` → VERTICAL; children stretch to fill (CSS default align-items: stretch)
917
938
  frame.layoutMode = 'VERTICAL';
918
939
  markCssGridVerticalFrame(frame);
919
940
  }