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.
- package/README.md +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- 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
|
|
40
|
-
// applied later by markGridColumnsNode, not stored in the IR.
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|