@hypen-space/web 0.4.81 → 0.4.83

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 (71) hide show
  1. package/dist/canvas/events.d.ts +30 -2
  2. package/dist/canvas/events.js +112 -30
  3. package/dist/canvas/events.js.map +1 -1
  4. package/dist/canvas/input.d.ts +15 -2
  5. package/dist/canvas/input.js +43 -24
  6. package/dist/canvas/input.js.map +1 -1
  7. package/dist/canvas/layout.js +479 -96
  8. package/dist/canvas/layout.js.map +1 -1
  9. package/dist/canvas/paint.d.ts +4 -0
  10. package/dist/canvas/paint.js +141 -29
  11. package/dist/canvas/paint.js.map +1 -1
  12. package/dist/canvas/props.d.ts +55 -0
  13. package/dist/canvas/props.js +121 -0
  14. package/dist/canvas/props.js.map +1 -0
  15. package/dist/canvas/renderer.d.ts +13 -0
  16. package/dist/canvas/renderer.js +109 -11
  17. package/dist/canvas/renderer.js.map +1 -1
  18. package/dist/canvas/scroll.d.ts +27 -3
  19. package/dist/canvas/scroll.js +66 -26
  20. package/dist/canvas/scroll.js.map +1 -1
  21. package/dist/canvas/selection.js +3 -4
  22. package/dist/canvas/selection.js.map +1 -1
  23. package/dist/canvas/utils.d.ts +23 -3
  24. package/dist/canvas/utils.js +90 -30
  25. package/dist/canvas/utils.js.map +1 -1
  26. package/dist/dom/applicators/border.js +9 -0
  27. package/dist/dom/applicators/border.js.map +1 -1
  28. package/dist/dom/applicators/font.js +18 -1
  29. package/dist/dom/applicators/font.js.map +1 -1
  30. package/dist/dom/applicators/layout.js +44 -8
  31. package/dist/dom/applicators/layout.js.map +1 -1
  32. package/dist/dom/applicators/margin.js +18 -53
  33. package/dist/dom/applicators/margin.js.map +1 -1
  34. package/dist/dom/applicators/padding.js +21 -33
  35. package/dist/dom/applicators/padding.js.map +1 -1
  36. package/dist/dom/applicators/typography.js +13 -1
  37. package/dist/dom/applicators/typography.js.map +1 -1
  38. package/dist/dom/components/column.d.ts +11 -2
  39. package/dist/dom/components/column.js +12 -4
  40. package/dist/dom/components/column.js.map +1 -1
  41. package/dist/dom/components/image.js +9 -3
  42. package/dist/dom/components/image.js.map +1 -1
  43. package/dist/dom/components/input.js +11 -0
  44. package/dist/dom/components/input.js.map +1 -1
  45. package/dist/dom/components/textarea.js +8 -0
  46. package/dist/dom/components/textarea.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +18 -0
  48. package/dist/dom/renderer.js +87 -3
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/canvas/PARITY.md +254 -0
  52. package/src/canvas/events.ts +113 -32
  53. package/src/canvas/input.ts +44 -25
  54. package/src/canvas/layout.ts +492 -91
  55. package/src/canvas/paint.ts +143 -29
  56. package/src/canvas/props.ts +117 -0
  57. package/src/canvas/renderer.ts +132 -11
  58. package/src/canvas/scroll.ts +62 -29
  59. package/src/canvas/selection.ts +3 -4
  60. package/src/canvas/utils.ts +83 -31
  61. package/src/dom/applicators/border.ts +9 -0
  62. package/src/dom/applicators/font.ts +16 -1
  63. package/src/dom/applicators/layout.ts +34 -7
  64. package/src/dom/applicators/margin.ts +16 -43
  65. package/src/dom/applicators/padding.ts +19 -27
  66. package/src/dom/applicators/typography.ts +11 -1
  67. package/src/dom/components/column.ts +12 -4
  68. package/src/dom/components/image.ts +8 -3
  69. package/src/dom/components/input.ts +11 -0
  70. package/src/dom/components/textarea.ts +8 -0
  71. package/src/dom/renderer.ts +92 -3
@@ -7,8 +7,27 @@
7
7
  */
8
8
 
9
9
  import type { VirtualNode, Layout, BoxSpacing } from "./types.js";
10
- import { parseSpacing, parseSize } from "./utils.js";
10
+ import { parseSpacing, parseSize, cssLengthToPx, cssLengthToDimension } from "./utils.js";
11
11
  import { measureText } from "./text.js";
12
+ import { getImageNaturalAspect } from "./paint.js";
13
+
14
+ /**
15
+ * Components whose size is intrinsic to the component itself (icon, avatar,
16
+ * checkbox, etc.) should not be shrunk by their flex parent. Without this,
17
+ * an icon inside a transparent button with no width collapses to 0×0 in
18
+ * tight cross-axis containers.
19
+ */
20
+ const INTRINSIC_SIZED = new Set([
21
+ "icon",
22
+ "avatar",
23
+ "badge",
24
+ "checkbox",
25
+ "radio",
26
+ "switch",
27
+ "toggle",
28
+ "spinner",
29
+ "loading",
30
+ ]);
12
31
 
13
32
  // Taffy imports — loaded lazily to avoid hard failure when WASM unavailable
14
33
  let taffy: typeof import("taffy-layout") | null = null;
@@ -45,55 +64,122 @@ export async function initTaffyLayout(): Promise<boolean> {
45
64
 
46
65
  /** Parse a Hypen prop value into a Taffy Dimension ("auto" | number | "N%") */
47
66
  function toDimension(value: any): "auto" | number | `${number}%` {
48
- if (value === undefined || value === null || value === "auto") return "auto";
49
- if (typeof value === "number") return value;
50
- if (typeof value === "string") {
51
- if (value.endsWith("%")) return value as `${number}%`;
52
- const n = parseFloat(value);
53
- return isNaN(n) ? "auto" : n;
54
- }
55
- return "auto";
67
+ return cssLengthToDimension(value);
56
68
  }
57
69
 
58
70
  function toLengthPct(value: any): number | `${number}%` {
59
- if (typeof value === "number") return value;
60
- if (typeof value === "string") {
61
- if (value.endsWith("%")) return value as `${number}%`;
62
- const n = parseFloat(value);
63
- return isNaN(n) ? 0 : n;
64
- }
65
- return 0;
71
+ const d = cssLengthToDimension(value);
72
+ return d === "auto" ? 0 : d;
66
73
  }
67
74
 
68
75
  function toLengthPctAuto(value: any): "auto" | number | `${number}%` {
69
- if (value === "auto" || value === undefined || value === null) return "auto";
70
- return toLengthPct(value) as any;
76
+ return cssLengthToDimension(value);
77
+ }
78
+
79
+ /**
80
+ * Parse an aspect-ratio prop. Accepts a number, `"W / H"`, or `"W"`.
81
+ * Returns the width/height ratio, or null when unparseable.
82
+ */
83
+ function parseAspectRatio(value: any): number | null {
84
+ if (typeof value === "number") return value > 0 ? value : null;
85
+ if (typeof value !== "string") return null;
86
+ const s = value.trim();
87
+ const slash = s.indexOf("/");
88
+ if (slash !== -1) {
89
+ const w = parseFloat(s.slice(0, slash));
90
+ const h = parseFloat(s.slice(slash + 1));
91
+ if (isFinite(w) && isFinite(h) && h !== 0) return w / h;
92
+ return null;
93
+ }
94
+ const n = parseFloat(s);
95
+ return isFinite(n) && n > 0 ? n : null;
96
+ }
97
+
98
+ /**
99
+ * Read whether a node is a scrollable container, and on which axes. We can't
100
+ * import from `scroll.ts` here without creating a circular dependency, so
101
+ * inline the parsing — keep it in sync with `getScrollAxes`.
102
+ */
103
+ function readScrollAxes(props: Record<string, any>): { x: boolean; y: boolean } {
104
+ const overflow = props.overflow;
105
+ if (overflow === "scroll" || overflow === "auto") return { x: true, y: true };
106
+ const scrollable = props.scrollable;
107
+ if (scrollable === true) return { x: true, y: true };
108
+ if (scrollable === "horizontal") return { x: true, y: false };
109
+ if (scrollable === "vertical") return { x: false, y: true };
110
+ if (scrollable === "both") return { x: true, y: true };
111
+ return { x: false, y: false };
71
112
  }
72
113
 
73
114
  /**
74
115
  * Build a Taffy Style from a VirtualNode's props. This centralises the
75
116
  * mapping between Hypen's declarative property names and CSS-level concepts.
117
+ *
118
+ * `parent` is the immediate parent VirtualNode (or null for the root) so
119
+ * children of a Stack can place themselves in the single grid cell, and
120
+ * children of a scrollable container can opt out of flex-shrink along the
121
+ * scroll axis (otherwise a horizontal strip's items collapse to the
122
+ * strip's width instead of overflowing).
76
123
  */
77
- function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-layout").Style> {
124
+ function buildTaffyStyle(
125
+ node: VirtualNode,
126
+ parent: VirtualNode | null = null,
127
+ ): InstanceType<typeof import("taffy-layout").Style> {
78
128
  const T = taffy!;
79
129
  const props = node.props;
80
130
  const type = node.type.toLowerCase();
131
+ const parentType = parent?.type.toLowerCase() ?? null;
132
+ const parentScrollAxes = parent ? readScrollAxes(parent.props) : { x: false, y: false };
81
133
 
82
134
  const style = new T.Style();
83
135
 
84
136
  // --- Display / direction ---------------------------------------------------
85
- const isColumn = type === "column" || props.flexDirection === "column";
86
- const isGrid = type === "grid" || props.display === "grid";
87
-
88
- if (isGrid) {
137
+ // Stack overlays children in a single grid cell — matches the DOM
138
+ // renderer's `grid-template-areas: "stack"` strategy. Children get
139
+ // `gridColumn: 1 / 2; gridRow: 1 / 2` further down.
140
+ const isStack = type === "stack";
141
+ // `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
142
+ // — `flex-direction: column` is its default). Match that here so feeds
143
+ // like Notifications stack their rows vertically instead of flowing
144
+ // sideways. The `direction` prop can still flip it to row.
145
+ const isListColumn = type === "list" && props.direction !== "horizontal";
146
+ const isColumn = !isStack && (type === "column" || isListColumn || props.flexDirection === "column");
147
+ const isGrid = !isStack && (type === "grid" || props.display === "grid");
148
+
149
+ if (isStack) {
150
+ style.display = T.Display.Grid;
151
+ // Auto tracks: the single cell sizes to fit the largest child instead
152
+ // of `1fr` (which would inflate the Stack to its parent's full size and
153
+ // stretch the children to match — see PARITY.md).
154
+ style.gridTemplateColumns = [{ min: "auto", max: "auto" }];
155
+ style.gridTemplateRows = [{ min: "auto", max: "auto" }];
156
+ // Default top-left matches the DOM Stack (`justify-items: start;
157
+ // align-items: start`) and iOS/Android ZStack defaults — also keeps
158
+ // explicitly-sized children at their declared size instead of letting
159
+ // Taffy's default `stretch` overwrite the box.
160
+ style.justifyItems = props.horizontalAlignment
161
+ ? mapAlign(T, props.horizontalAlignment)
162
+ : T.AlignItems.Start;
163
+ style.alignItems = props.verticalAlignment
164
+ ? mapAlign(T, props.verticalAlignment)
165
+ : T.AlignItems.Start;
166
+ } else if (isGrid) {
89
167
  style.display = T.Display.Grid;
90
168
 
91
169
  // --- Grid template tracks ------------------------------------------------
92
- if (props.gridTemplateColumns) {
93
- style.gridTemplateColumns = parseGridTemplate(props.gridTemplateColumns);
170
+ // The DSL's `.gridColumns(N)` / `.gridRows(N)` applicators land on
171
+ // props with the applicator's own name (`gridColumns`, `gridRows`).
172
+ // The DOM renderer also accepts the older shorthand `columns`/`rows`
173
+ // and the spec-name `gridTemplateColumns`/`gridTemplateRows`. A bare
174
+ // number N expands to N equal `1fr` tracks — same logic as
175
+ // `dom/applicators/advanced-layout.ts`.
176
+ const colsProp = props.gridTemplateColumns ?? props.gridColumns ?? props.columns;
177
+ const rowsProp = props.gridTemplateRows ?? props.gridRows ?? props.rows;
178
+ if (colsProp !== undefined) {
179
+ style.gridTemplateColumns = parseGridTemplate(colsProp);
94
180
  }
95
- if (props.gridTemplateRows) {
96
- style.gridTemplateRows = parseGridTemplate(props.gridTemplateRows);
181
+ if (rowsProp !== undefined) {
182
+ style.gridTemplateRows = parseGridTemplate(rowsProp);
97
183
  }
98
184
 
99
185
  // --- Grid auto flow ------------------------------------------------------
@@ -107,6 +193,12 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
107
193
  }
108
194
  if (props.gridAutoRows) {
109
195
  style.gridAutoRows = parseTrackSizingList(props.gridAutoRows);
196
+ } else if (typeof props.__autoRowsPx === "number") {
197
+ // Set by `annotateCollapsedAspectGrids` after a first layout pass —
198
+ // pin implicit rows to the height we want each cell to be (column
199
+ // width / aspectRatio), so `aspect-square` images don't overlap.
200
+ const px = props.__autoRowsPx as number;
201
+ style.gridAutoRows = [{ min: px, max: px }];
110
202
  }
111
203
 
112
204
  // --- Grid template areas -------------------------------------------------
@@ -117,7 +209,16 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
117
209
  style.display = T.Display.Flex;
118
210
  }
119
211
 
120
- style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
212
+ if (!isStack) {
213
+ style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
214
+ }
215
+
216
+ // --- Stack child placement -------------------------------------------------
217
+ // Place every child of a Stack in the single 1×1 cell so they overlap.
218
+ if (parentType === "stack") {
219
+ style.gridColumn = { start: 1, end: 2 };
220
+ style.gridRow = { start: 1, end: 2 };
221
+ }
121
222
 
122
223
  // --- Grid child placement (applies regardless of parent display) ----------
123
224
  if (props.gridColumn) {
@@ -140,23 +241,52 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
140
241
  }
141
242
 
142
243
  // --- Flex properties -------------------------------------------------------
143
- style.flexGrow = parseFloat(props.flexGrow) || parseFloat(props.flex) || 0;
144
- style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : 1;
145
- if (props.flexBasis !== undefined) style.flexBasis = toDimension(props.flexBasis);
244
+ // The `flex: <n>` shorthand (Tailwind's `flex-1` is `flex: 1 1 0%`) sets
245
+ // grow + shrink + basis at once. Without honouring the basis here, an
246
+ // overflowing flex item starts at its intrinsic content size (e.g. a
247
+ // huge feed) and Taffy redistributes from there, which under-allocates
248
+ // the BottomNav and pushes it off-screen on routes with tall content.
249
+ const flexShorthand = props.flex !== undefined ? parseFloat(props.flex) : NaN;
250
+ style.flexGrow = parseFloat(props.flexGrow) || (Number.isFinite(flexShorthand) ? flexShorthand : 0);
251
+ // Default flex-shrink is 0 when:
252
+ // - the component has an intrinsic size (icon, avatar, …) — otherwise it
253
+ // collapses to 0×0 in a tight cross-axis container, e.g. an icon inside
254
+ // a transparent button with only `padding.0`.
255
+ // - the parent is a scrollable container along the main axis — otherwise
256
+ // a horizontal strip's items shrink to fit instead of overflowing,
257
+ // making the strip pointless.
258
+ const parentIsRow = parentType !== "column" && parentType !== "stack" && parentType !== null;
259
+ const parentIsCol = parentType === "column";
260
+ const parentMainAxisScrolls =
261
+ (parentIsRow && parentScrollAxes.x) || (parentIsCol && parentScrollAxes.y);
262
+ const defaultShrink = INTRINSIC_SIZED.has(type) || parentMainAxisScrolls ? 0 : 1;
263
+ style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : defaultShrink;
264
+ if (props.flexBasis !== undefined) {
265
+ style.flexBasis = toDimension(props.flexBasis);
266
+ } else if (Number.isFinite(flexShorthand)) {
267
+ // `flex: 1` shorthand → basis 0%. Pinning basis is the load-bearing
268
+ // bit: an item with `flex: 1 1 auto` starts at its content size and
269
+ // distributes from there, leaving siblings in a tight container with
270
+ // negative space; `flex: 1 1 0%` starts at zero and grows up.
271
+ style.flexBasis = 0;
272
+ }
146
273
 
147
274
  if (props.flexWrap === "wrap") style.flexWrap = T.FlexWrap.Wrap;
148
275
  else if (props.flexWrap === "wrap-reverse") style.flexWrap = T.FlexWrap.WrapReverse;
149
276
 
150
277
  // --- Alignment (Hypen uses verticalAlignment / horizontalAlignment) --------
151
- const justifyRaw = isColumn
152
- ? (props.verticalAlignment || props.justifyContent)
153
- : (props.horizontalAlignment || props.justifyContent);
154
- const alignRaw = isColumn
155
- ? (props.horizontalAlignment || props.alignItems)
156
- : (props.verticalAlignment || props.alignItems);
157
-
158
- if (justifyRaw) style.justifyContent = mapJustify(T, justifyRaw);
159
- if (alignRaw) style.alignItems = mapAlign(T, alignRaw);
278
+ // Stack handles its own alignment via justifyItems/alignItems above.
279
+ if (!isStack) {
280
+ const justifyRaw = isColumn
281
+ ? (props.verticalAlignment || props.justifyContent)
282
+ : (props.horizontalAlignment || props.justifyContent);
283
+ const alignRaw = isColumn
284
+ ? (props.horizontalAlignment || props.alignItems)
285
+ : (props.verticalAlignment || props.alignItems);
286
+
287
+ if (justifyRaw) style.justifyContent = mapJustify(T, justifyRaw);
288
+ if (alignRaw) style.alignItems = mapAlign(T, alignRaw);
289
+ }
160
290
 
161
291
  // --- Size ------------------------------------------------------------------
162
292
  const w = parseSize(props.width);
@@ -170,14 +300,14 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
170
300
  };
171
301
  } else if (type === "divider" || type === "separator") {
172
302
  const orientation = props.orientation || "horizontal";
173
- const thickness = parseFloat(props.thickness) || 1;
303
+ const thickness = cssLengthToPx(props.thickness) ?? 1;
174
304
  if (orientation === "vertical") {
175
305
  style.size = { width: w ?? thickness, height: h !== null ? h : "100%" };
176
306
  } else {
177
307
  style.size = { width: w !== null ? w : "100%", height: h ?? thickness };
178
308
  }
179
309
  } else if (type === "checkbox" || type === "radio") {
180
- const sz = parseFloat(props.size) || 20;
310
+ const sz = cssLengthToPx(props.size) ?? 20;
181
311
  style.size = { width: w ?? sz, height: h ?? sz };
182
312
  } else if (type === "switch" || type === "toggle") {
183
313
  style.size = { width: w ?? 44, height: h ?? 24 };
@@ -186,26 +316,67 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
186
316
  } else if (type === "progress" || type === "progressbar") {
187
317
  style.size = { width: w ?? 200, height: h ?? 8 };
188
318
  } else if (type === "spinner" || type === "loading") {
189
- const sz = parseFloat(props.size) || 24;
319
+ const sz = cssLengthToPx(props.size) ?? 24;
190
320
  style.size = { width: w ?? sz, height: h ?? sz };
191
321
  } else if (type === "badge") {
192
322
  style.size = { width: w ?? 20, height: h ?? 20 };
193
323
  } else if (type === "avatar") {
194
- const sz = parseFloat(props.size) || 40;
324
+ const sz = cssLengthToPx(props.size) ?? 40;
195
325
  style.size = { width: w ?? sz, height: h ?? sz };
196
326
  } else if (type === "icon") {
197
- const sz = parseFloat(props.size) || 24;
327
+ const sz = cssLengthToPx(props.size) ?? 24;
198
328
  style.size = { width: w ?? sz, height: h ?? sz };
329
+ } else if (type === "input" || type === "textarea" || type === "select") {
330
+ // Form controls have no painted children, so without an intrinsic
331
+ // height they collapse to padding only (the social Search bar
332
+ // rendered as a 4-tall pill instead of the ~36 the DOM produces).
333
+ // Match the DOM box: outer = line-height(s) + padding + border.
334
+ // Taffy defaults to `box-sizing: border-box`, so we add the
335
+ // padding/border ourselves into `size.height`.
336
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
337
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.5;
338
+ const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
339
+ const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
340
+ const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
341
+ const borderTop = cssLengthToPx(props.borderTopWidth ?? props.borderWidth) ?? 0;
342
+ const borderBottom = cssLengthToPx(props.borderBottomWidth ?? props.borderWidth) ?? 0;
343
+ const intrinsicH = lineHeight * minRows + padTop + padBottom + borderTop + borderBottom;
344
+ style.size = { width: toDimension(props.width), height: h ?? intrinsicH };
199
345
  } else {
200
- style.size = { width: toDimension(props.width), height: toDimension(props.height) };
346
+ // Children of a Grid that ask for `width: 100%` actually mean "fill
347
+ // the cell" — that's the DOM behavior (`grid > * { justify-self:
348
+ // stretch }`). Taffy handles this via the grid item's default
349
+ // stretch alignment when the size is auto, but a literal 100% creates
350
+ // a circular dependency with track sizing (the cell wants to be 1fr
351
+ // of the grid, the item wants to be 100% of the cell, and Taffy
352
+ // collapses both to the item's max-content). Strip the 100% on grid
353
+ // children so the Taffy grid stretch wins. Same for height.
354
+ const parentIsGridLayout =
355
+ parent &&
356
+ (parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
357
+ const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
358
+ const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
359
+ style.size = { width: toDimension(widthIn), height: toDimension(heightIn) };
201
360
  }
202
361
 
203
362
  // --- Min / Max constraints -------------------------------------------------
363
+ // Default `min-width: 0` for children of a Grid container. CSS makes
364
+ // grid items default to `min-width: auto` (= min-content) which prevents
365
+ // them shrinking below their intrinsic min. For an Image with
366
+ // `aspect-ratio: 1`, that "min" is the image's natural max-content —
367
+ // so the grid track sizing collapses (one item ends up 360px wide and
368
+ // squashes its row). Explicitly setting min: 0 is the standard CSS
369
+ // workaround and matches the DOM grid's `justify-self: stretch` shape.
370
+ const parentIsGrid =
371
+ parent &&
372
+ (parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
204
373
  if (props.minWidth !== undefined || props.minHeight !== undefined) {
205
374
  style.minSize = {
206
375
  width: toDimension(props.minWidth),
207
376
  height: toDimension(props.minHeight),
208
377
  };
378
+ } else if (parentIsGrid) {
379
+ style.minSize = { width: 0, height: 0 };
209
380
  }
210
381
  if (props.maxWidth !== undefined || props.maxHeight !== undefined) {
211
382
  style.maxSize = {
@@ -216,32 +387,66 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
216
387
 
217
388
  // --- Margin ----------------------------------------------------------------
218
389
  const m = parseSpacing(props.margin || 0);
219
- if (props.marginTop !== undefined) m.top = parseFloat(props.marginTop) || 0;
220
- if (props.marginRight !== undefined) m.right = parseFloat(props.marginRight) || 0;
221
- if (props.marginBottom !== undefined) m.bottom = parseFloat(props.marginBottom) || 0;
222
- if (props.marginLeft !== undefined) m.left = parseFloat(props.marginLeft) || 0;
390
+ if (props.marginTop !== undefined) m.top = cssLengthToPx(props.marginTop) ?? 0;
391
+ if (props.marginRight !== undefined) m.right = cssLengthToPx(props.marginRight) ?? 0;
392
+ if (props.marginBottom !== undefined) m.bottom = cssLengthToPx(props.marginBottom) ?? 0;
393
+ if (props.marginLeft !== undefined) m.left = cssLengthToPx(props.marginLeft) ?? 0;
223
394
  style.margin = { top: m.top, right: m.right, bottom: m.bottom, left: m.left };
224
395
 
225
396
  // --- Padding ---------------------------------------------------------------
226
397
  const p = parseSpacing(props.padding || 0);
227
- if (props.paddingTop !== undefined) p.top = parseFloat(props.paddingTop) || 0;
228
- if (props.paddingRight !== undefined) p.right = parseFloat(props.paddingRight) || 0;
229
- if (props.paddingBottom !== undefined) p.bottom = parseFloat(props.paddingBottom) || 0;
230
- if (props.paddingLeft !== undefined) p.left = parseFloat(props.paddingLeft) || 0;
398
+ if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
399
+ if (props.paddingRight !== undefined) p.right = cssLengthToPx(props.paddingRight) ?? 0;
400
+ if (props.paddingBottom !== undefined) p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
401
+ if (props.paddingLeft !== undefined) p.left = cssLengthToPx(props.paddingLeft) ?? 0;
231
402
  style.padding = { top: p.top, right: p.right, bottom: p.bottom, left: p.left };
232
403
 
233
404
  // --- Border ----------------------------------------------------------------
234
- const bw = parseFloat(props.borderWidth) || 0;
405
+ const bw = cssLengthToPx(props.borderWidth) ?? 0;
235
406
  if (bw > 0) {
236
407
  style.border = { top: bw, right: bw, bottom: bw, left: bw };
237
408
  }
238
409
 
239
410
  // --- Gap -------------------------------------------------------------------
240
- const gap = parseFloat(props.gap) || 0;
411
+ const gap = cssLengthToPx(props.gap) ?? 0;
241
412
  if (gap > 0) {
242
413
  style.gap = { width: gap, height: gap };
243
414
  }
244
415
 
416
+ // --- Aspect ratio ----------------------------------------------------------
417
+ // Engine emits values like `"1"` or `16/9`. Taffy expects a number.
418
+ if (props.aspectRatio !== undefined) {
419
+ const ar = parseAspectRatio(props.aspectRatio);
420
+ if (ar !== null) style.aspectRatio = ar;
421
+ } else if (type === "image") {
422
+ // Implicit aspect ratio from the decoded image's intrinsic size — only
423
+ // useful when the caller fixes one dimension and lets the other follow.
424
+ // Without this, an image with `width: 100` stretches to whatever Taffy
425
+ // picks for the height (the parent's full height under the default
426
+ // `align-items: stretch` of a flex row).
427
+ //
428
+ // We can't rely on Taffy's `aspectRatio` alone: with `align-items:
429
+ // stretch`, the cross-axis size is forced before aspect-ratio is
430
+ // applied. Pin both dimensions explicitly instead.
431
+ const wParsed = parseSize(props.width);
432
+ const hParsed = parseSize(props.height);
433
+ if ((wParsed === null) !== (hParsed === null)) {
434
+ const src = props.src ?? props[0];
435
+ if (typeof src === "string") {
436
+ const intrinsic = getImageNaturalAspect(src);
437
+ if (intrinsic !== null) {
438
+ if (wParsed !== null) {
439
+ style.size = { width: wParsed, height: wParsed / intrinsic };
440
+ style.aspectRatio = intrinsic;
441
+ } else if (hParsed !== null) {
442
+ style.size = { width: hParsed * intrinsic, height: hParsed };
443
+ style.aspectRatio = intrinsic;
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+
245
450
  // --- Position (absolute) ---------------------------------------------------
246
451
  if (props.position === "absolute") {
247
452
  style.position = T.Position.Absolute;
@@ -251,6 +456,29 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
251
456
  if (props.bottom !== undefined) style.bottom = toLengthPctAuto(props.bottom);
252
457
  }
253
458
 
459
+ // --- Overflow (scrollable containers) -------------------------------------
460
+ // For scroll containers we need a careful balance:
461
+ // - Children must lay out at their full intrinsic positions so
462
+ // `ScrollManager.updateScrollBounds` can compute scrollHeight (a feed
463
+ // with overflow:Hidden gets its children clamped and the page won't
464
+ // scroll).
465
+ // - But the CONTAINER itself must not bubble its overflowing min-content
466
+ // up into its parent (a horizontally-scrollable Stories row with 5+
467
+ // w-24 items inflated the whole feed to 607px on a 470px canvas).
468
+ //
469
+ // Setting overflow to `Scroll` on the relevant axis tells Taffy to treat
470
+ // this container as a scroll port — children flow at intrinsic sizes but
471
+ // the container's own contribution to the parent stays bounded. The
472
+ // non-scroll axis stays Visible so siblings still see the right content
473
+ // size when they need to.
474
+ const ownAxes = readScrollAxes(props);
475
+ if (ownAxes.x || ownAxes.y) {
476
+ style.overflow = {
477
+ x: ownAxes.x ? T.Overflow.Scroll : T.Overflow.Visible,
478
+ y: ownAxes.y ? T.Overflow.Scroll : T.Overflow.Visible,
479
+ };
480
+ }
481
+
254
482
  return style;
255
483
  }
256
484
 
@@ -324,10 +552,27 @@ function parseGridTemplate(value: any): any[] {
324
552
  return value.map(parseTrackSizing);
325
553
  }
326
554
  if (typeof value === "string") {
555
+ // The DSL's `.gridColumns(3)` sometimes arrives here as `"3"` after
556
+ // stringification (engine emits numeric applicator args as strings).
557
+ // Treat a bare integer string the same way the DOM renderer does —
558
+ // expand to N equal 1fr tracks. `repeat(N, 1fr)` shorthand would work
559
+ // too but Taffy wants the expanded track list.
560
+ const trimmed = value.trim();
561
+ if (/^\d+$/.test(trimmed)) {
562
+ const n = parseInt(trimmed, 10);
563
+ return Array.from({ length: n }, () => ({
564
+ min: "auto",
565
+ max: "1fr" as `${number}fr`,
566
+ }));
567
+ }
327
568
  return value.split(/\s+/).filter(Boolean).map(parseTrackSizing);
328
569
  }
329
570
  if (typeof value === "number") {
330
- return [parseTrackSizing(value)];
571
+ // Bare number = column count. DOM: `repeat(N, 1fr)`.
572
+ return Array.from({ length: value }, () => ({
573
+ min: "auto",
574
+ max: "1fr" as `${number}fr`,
575
+ }));
331
576
  }
332
577
  return [];
333
578
  }
@@ -454,6 +699,17 @@ interface TextMeasureContext {
454
699
  paddingV: number;
455
700
  }
456
701
 
702
+ /**
703
+ * Marker context for image leaves whose height should follow the column-
704
+ * stretched width via aspect-ratio. Without this, Taffy's grid auto-row
705
+ * sizing only sees the image's max-content (= 0 for a leaf with no fixed
706
+ * height) and a `gridColumns(3)` of `aspect-square` images collapses to
707
+ * gap-tall rows that overlap by 150px each (the Search explore grid).
708
+ */
709
+ interface ImageAspectMeasureContext {
710
+ aspectRatio: number;
711
+ }
712
+
457
713
  /**
458
714
  * Build a TaffyTree mirroring the VirtualNode tree. Returns the root Taffy
459
715
  * node id and a mapping from Taffy node ids → VirtualNodes.
@@ -461,24 +717,30 @@ interface TextMeasureContext {
461
717
  function buildTree(
462
718
  tree: InstanceType<typeof import("taffy-layout").TaffyTree>,
463
719
  node: VirtualNode,
720
+ parent: VirtualNode | null = null,
464
721
  ): bigint {
465
722
  const T = taffy!;
466
723
  const props = node.props;
467
- const style = buildTaffyStyle(node);
468
-
469
- // Text leaf nodes — create with measurement context
470
- if (node.type === "text" && props[0] && node.children.length === 0) {
724
+ const style = buildTaffyStyle(node, parent);
725
+
726
+ // Text leaf nodes — create with measurement context. The engine emits the
727
+ // type capitalised (`"Text"`); compare case-insensitively so a Taffy leaf
728
+ // is created. Without this, every Text node was treated as an empty
729
+ // container with size 0×0, and downstream siblings were placed on top of
730
+ // each other ("1,431 likes" overlapping the caption row was the visible
731
+ // symptom).
732
+ if (node.type.toLowerCase() === "text" && props[0] && node.children.length === 0) {
471
733
  const text = String(props[0] || "");
472
- const fontSize = parseFloat(props.fontSize) || 16;
734
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
473
735
  const fontWeight = props.fontWeight || "normal";
474
736
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
475
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
737
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
476
738
 
477
739
  const p = parseSpacing(props.padding || 0);
478
- if (props.paddingTop !== undefined) p.top = parseFloat(props.paddingTop) || 0;
479
- if (props.paddingRight !== undefined) p.right = parseFloat(props.paddingRight) || 0;
480
- if (props.paddingBottom !== undefined) p.bottom = parseFloat(props.paddingBottom) || 0;
481
- if (props.paddingLeft !== undefined) p.left = parseFloat(props.paddingLeft) || 0;
740
+ if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
741
+ if (props.paddingRight !== undefined) p.right = cssLengthToPx(props.paddingRight) ?? 0;
742
+ if (props.paddingBottom !== undefined) p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
743
+ if (props.paddingLeft !== undefined) p.left = cssLengthToPx(props.paddingLeft) ?? 0;
482
744
 
483
745
  const ctx: TextMeasureContext = {
484
746
  text,
@@ -493,10 +755,26 @@ function buildTree(
493
755
  return tree.newLeafWithContext(style, ctx);
494
756
  }
495
757
 
758
+ // Image leaves with aspect-ratio: contribute height = stretched-width /
759
+ // aspectRatio to grid/flex track sizing via the measure callback. The
760
+ // `style.aspectRatio` alone isn't enough — Taffy's grid auto-row pass asks
761
+ // for the leaf's max-content height, which for an image with no fixed
762
+ // height is 0; without the callback the row collapses.
763
+ if (node.type.toLowerCase() === "image" && node.children.length === 0) {
764
+ const ar =
765
+ props.aspectRatio !== undefined
766
+ ? parseAspectRatio(props.aspectRatio)
767
+ : null;
768
+ if (ar !== null) {
769
+ const ictx: ImageAspectMeasureContext = { aspectRatio: ar };
770
+ return tree.newLeafWithContext(style, ictx);
771
+ }
772
+ }
773
+
496
774
  // Container nodes
497
775
  const childIds: bigint[] = [];
498
776
  for (const child of node.children) {
499
- childIds.push(buildTree(tree, child));
777
+ childIds.push(buildTree(tree, child, node));
500
778
  }
501
779
 
502
780
  return tree.newWithChildren(style, childIds);
@@ -527,7 +805,7 @@ function writeLayout(
527
805
  const absY = parentY + tl.y + (isRoot ? tl.marginTop : 0);
528
806
 
529
807
  const borderColor = node.props.borderColor || "transparent";
530
- const borderRadius = parseFloat(node.props.borderRadius) || 0;
808
+ const borderRadius = cssLengthToPx(node.props.borderRadius) ?? 0;
531
809
  // Taffy computes uniform border width per side; pick top as representative
532
810
  const borderWidth = tl.borderTop;
533
811
 
@@ -568,6 +846,73 @@ function writeLayout(
568
846
  }
569
847
  }
570
848
 
849
+ /**
850
+ * After a layout pass, detect Grid containers whose implicit row tracks
851
+ * collapsed against aspect-ratio image children — Taffy treats the
852
+ * leaves' max-content height as 0 (the measure callback can't anchor the
853
+ * height without a known width during track sizing), so the rows get
854
+ * sized to gap-only and items overflow their tracks by ~150px each.
855
+ *
856
+ * Mark the grid with a private hint (`__autoRowsPx`) carrying the height
857
+ * we want each implicit row to be — derived from the first item's
858
+ * computed width and aspect-ratio after the first pass. The next layout
859
+ * pass reads this hint in `buildTaffyStyle` and sets explicit
860
+ * `gridAutoRows`. Returns true if any grid was marked (caller re-runs
861
+ * layout from scratch).
862
+ */
863
+ function annotateCollapsedAspectGrids(node: VirtualNode): boolean {
864
+ let touched = false;
865
+ for (const child of node.children) {
866
+ if (annotateCollapsedAspectGrids(child)) touched = true;
867
+ }
868
+
869
+ if (!node.layout) return touched;
870
+ const t = node.type.toLowerCase();
871
+ const isGrid = t === "grid" || node.props.display === "grid";
872
+ if (!isGrid) return touched;
873
+ // Only adjust grids that haven't already been pinned (avoid loops).
874
+ if (node.props.__autoRowsPx) return touched;
875
+
876
+ let firstAspectKid: VirtualNode | null = null;
877
+ let aspectRatio = 0;
878
+ for (const child of node.children) {
879
+ if (child.type.toLowerCase() !== "image" || !child.layout) continue;
880
+ const ar =
881
+ child.props.aspectRatio !== undefined
882
+ ? parseAspectRatio(child.props.aspectRatio)
883
+ : null;
884
+ if (ar === null) continue;
885
+ firstAspectKid = child;
886
+ aspectRatio = ar;
887
+ break;
888
+ }
889
+ if (!firstAspectKid) return touched;
890
+
891
+ const itemWidth = firstAspectKid.layout!.width;
892
+ const expectedRowHeight = itemWidth / aspectRatio;
893
+ if (expectedRowHeight <= 0) return touched;
894
+
895
+ // Find a sibling image in the next row to confirm collapse: if its y
896
+ // delta from the first row is less than the expected row height (minus
897
+ // a tolerance for fractional pixels and gap rounding), the rows
898
+ // overlapped — this is the symptom we're fixing.
899
+ let nextRowKid: VirtualNode | null = null;
900
+ for (const child of node.children) {
901
+ if (child === firstAspectKid) continue;
902
+ if (!child.layout) continue;
903
+ if (child.layout.y > firstAspectKid.layout!.y + 1) {
904
+ nextRowKid = child;
905
+ break;
906
+ }
907
+ }
908
+ if (!nextRowKid) return touched;
909
+ const rowDelta = nextRowKid.layout!.y - firstAspectKid.layout!.y;
910
+ if (rowDelta >= expectedRowHeight - 1) return touched;
911
+
912
+ node.props.__autoRowsPx = expectedRowHeight;
913
+ return true;
914
+ }
915
+
571
916
  /**
572
917
  * Compute layout for a virtual node tree using Taffy.
573
918
  * Requires `initTaffyLayout()` to have been called and resolved.
@@ -586,11 +931,50 @@ function computeLayoutTaffy(
586
931
  try {
587
932
  const rootId = buildTree(tree, node);
588
933
 
934
+ // Pin the root to the canvas's available size when it has no explicit
935
+ // size of its own. Without this, Taffy lets the root grow to its
936
+ // min-content — and a horizontally-scrollable Stories row with 5+
937
+ // items at w-24 inflated the entire feed to 607px on a 470px canvas,
938
+ // pushing the BottomNav off-screen.
939
+ const rootProps = node.props;
940
+ const rootHasExplicitWidth =
941
+ rootProps.width !== undefined || rootProps["width.0"] !== undefined;
942
+ const rootHasExplicitHeight =
943
+ rootProps.height !== undefined || rootProps["height.0"] !== undefined;
944
+ if (!rootHasExplicitWidth || !rootHasExplicitHeight) {
945
+ const rootStyle = tree.getStyle(rootId);
946
+ rootStyle.size = {
947
+ width: rootHasExplicitWidth
948
+ ? cssLengthToDimension(rootProps.width ?? rootProps["width.0"])
949
+ : availableWidth,
950
+ height: rootHasExplicitHeight
951
+ ? cssLengthToDimension(rootProps.height ?? rootProps["height.0"])
952
+ : availableHeight,
953
+ };
954
+ tree.setStyle(rootId, rootStyle);
955
+ }
956
+
589
957
  // Compute layout with a measure function for text leaf nodes
590
958
  tree.computeLayoutWithMeasure(
591
959
  rootId,
592
960
  { width: availableWidth, height: availableHeight },
593
961
  (knownDimensions, availableSpace, _nodeId, context, _style) => {
962
+ // Image-with-aspect-ratio leaf: derive missing dim from the other
963
+ // via aspectRatio so grid track sizing sees a proper max-content
964
+ // height. Falls back to availableSpace.width when nothing's known.
965
+ const ictx = context as ImageAspectMeasureContext | undefined;
966
+ if (ictx && typeof ictx.aspectRatio === "number") {
967
+ const w =
968
+ knownDimensions.width ??
969
+ (typeof availableSpace.width === "number"
970
+ ? availableSpace.width
971
+ : undefined);
972
+ const h =
973
+ knownDimensions.height ??
974
+ (w !== undefined ? w / ictx.aspectRatio : undefined);
975
+ return { width: w ?? 0, height: h ?? 0 };
976
+ }
977
+
594
978
  const tctx = context as TextMeasureContext | undefined;
595
979
  if (!tctx?.text) {
596
980
  return { width: knownDimensions.width ?? 0, height: knownDimensions.height ?? 0 };
@@ -607,9 +991,14 @@ function computeLayoutTaffy(
607
991
  lineHeight: tctx.lineHeight,
608
992
  }, maxWidth);
609
993
 
994
+ // `Math.ceil` the reported width: pretext returns a fractional
995
+ // pixel width (e.g. 141.71875 for "View all 87 comments"), Taffy
996
+ // gives the box exactly that, but at paint time we re-measure with
997
+ // a maxWidth of the integer-rounded box width and pretext then
998
+ // wraps because 141.71 > 141. Reserve the next whole pixel up.
610
999
  return {
611
- width: knownDimensions.width ?? (metrics.width + tctx.paddingH),
612
- height: knownDimensions.height ?? (metrics.height + tctx.paddingV),
1000
+ width: knownDimensions.width ?? (Math.ceil(metrics.width) + tctx.paddingH),
1001
+ height: knownDimensions.height ?? (Math.ceil(metrics.height) + tctx.paddingV),
613
1002
  };
614
1003
  },
615
1004
  );
@@ -640,6 +1029,14 @@ export function computeLayout(
640
1029
  ): void {
641
1030
  if (taffyReady && taffy) {
642
1031
  computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
1032
+ // After the first pass, Taffy's grid auto-row sizing for aspect-ratio
1033
+ // image leaves can collapse rows (the leaf's max-content height is 0
1034
+ // before column widths are known). Detect that pattern and re-run
1035
+ // with explicit `gridAutoRows` derived from the now-known column
1036
+ // width — see `annotateCollapsedAspectGrids`.
1037
+ if (annotateCollapsedAspectGrids(node)) {
1038
+ computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
1039
+ }
643
1040
  } else {
644
1041
  computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
645
1042
  }
@@ -660,20 +1057,20 @@ function computeLayoutFallback(
660
1057
  const props = node.props;
661
1058
 
662
1059
  let margin = parseSpacing(props.margin || 0);
663
- if (props.marginTop !== undefined) margin.top = parseFloat(props.marginTop) || 0;
664
- if (props.marginRight !== undefined) margin.right = parseFloat(props.marginRight) || 0;
665
- if (props.marginBottom !== undefined) margin.bottom = parseFloat(props.marginBottom) || 0;
666
- if (props.marginLeft !== undefined) margin.left = parseFloat(props.marginLeft) || 0;
1060
+ if (props.marginTop !== undefined) margin.top = cssLengthToPx(props.marginTop) ?? 0;
1061
+ if (props.marginRight !== undefined) margin.right = cssLengthToPx(props.marginRight) ?? 0;
1062
+ if (props.marginBottom !== undefined) margin.bottom = cssLengthToPx(props.marginBottom) ?? 0;
1063
+ if (props.marginLeft !== undefined) margin.left = cssLengthToPx(props.marginLeft) ?? 0;
667
1064
 
668
1065
  let padding = parseSpacing(props.padding || 0);
669
- if (props.paddingTop !== undefined) padding.top = parseFloat(props.paddingTop) || 0;
670
- if (props.paddingRight !== undefined) padding.right = parseFloat(props.paddingRight) || 0;
671
- if (props.paddingBottom !== undefined) padding.bottom = parseFloat(props.paddingBottom) || 0;
672
- if (props.paddingLeft !== undefined) padding.left = parseFloat(props.paddingLeft) || 0;
1066
+ if (props.paddingTop !== undefined) padding.top = cssLengthToPx(props.paddingTop) ?? 0;
1067
+ if (props.paddingRight !== undefined) padding.right = cssLengthToPx(props.paddingRight) ?? 0;
1068
+ if (props.paddingBottom !== undefined) padding.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
1069
+ if (props.paddingLeft !== undefined) padding.left = cssLengthToPx(props.paddingLeft) ?? 0;
673
1070
 
674
- const borderWidth = parseFloat(props.borderWidth) || 0;
1071
+ const borderWidth = cssLengthToPx(props.borderWidth) ?? 0;
675
1072
  const borderColor = props.borderColor || "transparent";
676
- const borderRadius = parseFloat(props.borderRadius) || 0;
1073
+ const borderRadius = cssLengthToPx(props.borderRadius) ?? 0;
677
1074
 
678
1075
  const availableAfterMargin = {
679
1076
  width: availableWidth - margin.left - margin.right,
@@ -693,7 +1090,7 @@ function computeLayoutFallback(
693
1090
  if (height === null) height = availableAfterMargin.height;
694
1091
  } else if (type === "divider" || type === "separator") {
695
1092
  const orientation = props.orientation || "horizontal";
696
- const thickness = parseFloat(props.thickness) || 1;
1093
+ const thickness = cssLengthToPx(props.thickness) ?? 1;
697
1094
  if (orientation === "vertical") {
698
1095
  if (width === null) width = thickness;
699
1096
  if (height === null) height = availableAfterMargin.height;
@@ -702,7 +1099,7 @@ function computeLayoutFallback(
702
1099
  if (height === null) height = thickness;
703
1100
  }
704
1101
  } else if (type === "checkbox" || type === "radio") {
705
- const size = parseFloat(props.size) || 20;
1102
+ const size = cssLengthToPx(props.size) ?? 20;
706
1103
  if (width === null) width = size;
707
1104
  if (height === null) height = size;
708
1105
  } else if (type === "switch" || type === "toggle") {
@@ -715,28 +1112,28 @@ function computeLayoutFallback(
715
1112
  if (width === null) width = 200;
716
1113
  if (height === null) height = 8;
717
1114
  } else if (type === "spinner" || type === "loading") {
718
- const size = parseFloat(props.size) || 24;
1115
+ const size = cssLengthToPx(props.size) ?? 24;
719
1116
  if (width === null) width = size;
720
1117
  if (height === null) height = size;
721
1118
  } else if (type === "badge") {
722
1119
  if (width === null) width = 20;
723
1120
  if (height === null) height = 20;
724
1121
  } else if (type === "avatar") {
725
- const size = parseFloat(props.size) || 40;
1122
+ const size = cssLengthToPx(props.size) ?? 40;
726
1123
  if (width === null) width = size;
727
1124
  if (height === null) height = size;
728
1125
  } else if (type === "icon") {
729
- const size = parseFloat(props.size) || 24;
1126
+ const size = cssLengthToPx(props.size) ?? 24;
730
1127
  if (width === null) width = size;
731
1128
  if (height === null) height = size;
732
1129
  }
733
1130
 
734
- if (node.type === "text" && node.props[0]) {
1131
+ if (node.type.toLowerCase() === "text" && node.props[0]) {
735
1132
  const text = String(node.props[0] || "");
736
- const fontSize = parseFloat(props.fontSize) || 16;
1133
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
737
1134
  const fontWeight = props.fontWeight || "normal";
738
1135
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
739
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
1136
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
740
1137
 
741
1138
  const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
742
1139
  const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, maxWidth);
@@ -801,7 +1198,7 @@ function layoutChildrenFallback(ctx: CanvasRenderingContext2D, parent: VirtualNo
801
1198
  const alignItems = isColumn
802
1199
  ? (props.horizontalAlignment || "flex-start")
803
1200
  : (props.verticalAlignment || "flex-start");
804
- const gap = parseFloat(props.gap) || 0;
1201
+ const gap = cssLengthToPx(props.gap) ?? 0;
805
1202
 
806
1203
  const availableWidth = layout.contentWidth;
807
1204
  const availableHeight = layout.contentHeight;
@@ -819,7 +1216,11 @@ function layoutChildrenFallback(ctx: CanvasRenderingContext2D, parent: VirtualNo
819
1216
 
820
1217
  for (const child of parent.children) {
821
1218
  const flexGrow = parseFloat(child.props.flexGrow) || parseFloat(child.props.flex) || 0;
822
- const flexShrink = parseFloat(child.props.flexShrink) || 1;
1219
+ const childType = child.type.toLowerCase();
1220
+ const defaultShrink = INTRINSIC_SIZED.has(childType) ? 0 : 1;
1221
+ const flexShrink = child.props.flexShrink !== undefined
1222
+ ? parseFloat(child.props.flexShrink)
1223
+ : defaultShrink;
823
1224
  const flexBasis = parseSize(child.props.flexBasis);
824
1225
 
825
1226
  computeLayoutFallback(ctx, child, availableWidth, availableHeight, 0, 0);