@hypen-space/web 0.4.82 → 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
@@ -5,8 +5,26 @@
5
5
  * layout. Falls back to a basic flexbox implementation in environments
6
6
  * without WASM support (e.g. test runners).
7
7
  */
8
- import { parseSpacing, parseSize } from "./utils.js";
8
+ import { parseSpacing, parseSize, cssLengthToPx, cssLengthToDimension } from "./utils.js";
9
9
  import { measureText } from "./text.js";
10
+ import { getImageNaturalAspect } from "./paint.js";
11
+ /**
12
+ * Components whose size is intrinsic to the component itself (icon, avatar,
13
+ * checkbox, etc.) should not be shrunk by their flex parent. Without this,
14
+ * an icon inside a transparent button with no width collapses to 0×0 in
15
+ * tight cross-axis containers.
16
+ */
17
+ const INTRINSIC_SIZED = new Set([
18
+ "icon",
19
+ "avatar",
20
+ "badge",
21
+ "checkbox",
22
+ "radio",
23
+ "switch",
24
+ "toggle",
25
+ "spinner",
26
+ "loading",
27
+ ]);
10
28
  // Taffy imports — loaded lazily to avoid hard failure when WASM unavailable
11
29
  let taffy = null;
12
30
  let taffyReady = false;
@@ -41,54 +59,119 @@ export async function initTaffyLayout() {
41
59
  // ---------------------------------------------------------------------------
42
60
  /** Parse a Hypen prop value into a Taffy Dimension ("auto" | number | "N%") */
43
61
  function toDimension(value) {
44
- if (value === undefined || value === null || value === "auto")
45
- return "auto";
46
- if (typeof value === "number")
47
- return value;
48
- if (typeof value === "string") {
49
- if (value.endsWith("%"))
50
- return value;
51
- const n = parseFloat(value);
52
- return isNaN(n) ? "auto" : n;
53
- }
54
- return "auto";
62
+ return cssLengthToDimension(value);
55
63
  }
56
64
  function toLengthPct(value) {
65
+ const d = cssLengthToDimension(value);
66
+ return d === "auto" ? 0 : d;
67
+ }
68
+ function toLengthPctAuto(value) {
69
+ return cssLengthToDimension(value);
70
+ }
71
+ /**
72
+ * Parse an aspect-ratio prop. Accepts a number, `"W / H"`, or `"W"`.
73
+ * Returns the width/height ratio, or null when unparseable.
74
+ */
75
+ function parseAspectRatio(value) {
57
76
  if (typeof value === "number")
58
- return value;
59
- if (typeof value === "string") {
60
- if (value.endsWith("%"))
61
- return value;
62
- const n = parseFloat(value);
63
- return isNaN(n) ? 0 : n;
77
+ return value > 0 ? value : null;
78
+ if (typeof value !== "string")
79
+ return null;
80
+ const s = value.trim();
81
+ const slash = s.indexOf("/");
82
+ if (slash !== -1) {
83
+ const w = parseFloat(s.slice(0, slash));
84
+ const h = parseFloat(s.slice(slash + 1));
85
+ if (isFinite(w) && isFinite(h) && h !== 0)
86
+ return w / h;
87
+ return null;
64
88
  }
65
- return 0;
89
+ const n = parseFloat(s);
90
+ return isFinite(n) && n > 0 ? n : null;
66
91
  }
67
- function toLengthPctAuto(value) {
68
- if (value === "auto" || value === undefined || value === null)
69
- return "auto";
70
- return toLengthPct(value);
92
+ /**
93
+ * Read whether a node is a scrollable container, and on which axes. We can't
94
+ * import from `scroll.ts` here without creating a circular dependency, so
95
+ * inline the parsing — keep it in sync with `getScrollAxes`.
96
+ */
97
+ function readScrollAxes(props) {
98
+ const overflow = props.overflow;
99
+ if (overflow === "scroll" || overflow === "auto")
100
+ return { x: true, y: true };
101
+ const scrollable = props.scrollable;
102
+ if (scrollable === true)
103
+ return { x: true, y: true };
104
+ if (scrollable === "horizontal")
105
+ return { x: true, y: false };
106
+ if (scrollable === "vertical")
107
+ return { x: false, y: true };
108
+ if (scrollable === "both")
109
+ return { x: true, y: true };
110
+ return { x: false, y: false };
71
111
  }
72
112
  /**
73
113
  * Build a Taffy Style from a VirtualNode's props. This centralises the
74
114
  * mapping between Hypen's declarative property names and CSS-level concepts.
115
+ *
116
+ * `parent` is the immediate parent VirtualNode (or null for the root) so
117
+ * children of a Stack can place themselves in the single grid cell, and
118
+ * children of a scrollable container can opt out of flex-shrink along the
119
+ * scroll axis (otherwise a horizontal strip's items collapse to the
120
+ * strip's width instead of overflowing).
75
121
  */
76
- function buildTaffyStyle(node) {
122
+ function buildTaffyStyle(node, parent = null) {
77
123
  const T = taffy;
78
124
  const props = node.props;
79
125
  const type = node.type.toLowerCase();
126
+ const parentType = parent?.type.toLowerCase() ?? null;
127
+ const parentScrollAxes = parent ? readScrollAxes(parent.props) : { x: false, y: false };
80
128
  const style = new T.Style();
81
129
  // --- Display / direction ---------------------------------------------------
82
- const isColumn = type === "column" || props.flexDirection === "column";
83
- const isGrid = type === "grid" || props.display === "grid";
84
- if (isGrid) {
130
+ // Stack overlays children in a single grid cell — matches the DOM
131
+ // renderer's `grid-template-areas: "stack"` strategy. Children get
132
+ // `gridColumn: 1 / 2; gridRow: 1 / 2` further down.
133
+ const isStack = type === "stack";
134
+ // `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
135
+ // — `flex-direction: column` is its default). Match that here so feeds
136
+ // like Notifications stack their rows vertically instead of flowing
137
+ // sideways. The `direction` prop can still flip it to row.
138
+ const isListColumn = type === "list" && props.direction !== "horizontal";
139
+ const isColumn = !isStack && (type === "column" || isListColumn || props.flexDirection === "column");
140
+ const isGrid = !isStack && (type === "grid" || props.display === "grid");
141
+ if (isStack) {
142
+ style.display = T.Display.Grid;
143
+ // Auto tracks: the single cell sizes to fit the largest child instead
144
+ // of `1fr` (which would inflate the Stack to its parent's full size and
145
+ // stretch the children to match — see PARITY.md).
146
+ style.gridTemplateColumns = [{ min: "auto", max: "auto" }];
147
+ style.gridTemplateRows = [{ min: "auto", max: "auto" }];
148
+ // Default top-left matches the DOM Stack (`justify-items: start;
149
+ // align-items: start`) and iOS/Android ZStack defaults — also keeps
150
+ // explicitly-sized children at their declared size instead of letting
151
+ // Taffy's default `stretch` overwrite the box.
152
+ style.justifyItems = props.horizontalAlignment
153
+ ? mapAlign(T, props.horizontalAlignment)
154
+ : T.AlignItems.Start;
155
+ style.alignItems = props.verticalAlignment
156
+ ? mapAlign(T, props.verticalAlignment)
157
+ : T.AlignItems.Start;
158
+ }
159
+ else if (isGrid) {
85
160
  style.display = T.Display.Grid;
86
161
  // --- Grid template tracks ------------------------------------------------
87
- if (props.gridTemplateColumns) {
88
- style.gridTemplateColumns = parseGridTemplate(props.gridTemplateColumns);
162
+ // The DSL's `.gridColumns(N)` / `.gridRows(N)` applicators land on
163
+ // props with the applicator's own name (`gridColumns`, `gridRows`).
164
+ // The DOM renderer also accepts the older shorthand `columns`/`rows`
165
+ // and the spec-name `gridTemplateColumns`/`gridTemplateRows`. A bare
166
+ // number N expands to N equal `1fr` tracks — same logic as
167
+ // `dom/applicators/advanced-layout.ts`.
168
+ const colsProp = props.gridTemplateColumns ?? props.gridColumns ?? props.columns;
169
+ const rowsProp = props.gridTemplateRows ?? props.gridRows ?? props.rows;
170
+ if (colsProp !== undefined) {
171
+ style.gridTemplateColumns = parseGridTemplate(colsProp);
89
172
  }
90
- if (props.gridTemplateRows) {
91
- style.gridTemplateRows = parseGridTemplate(props.gridTemplateRows);
173
+ if (rowsProp !== undefined) {
174
+ style.gridTemplateRows = parseGridTemplate(rowsProp);
92
175
  }
93
176
  // --- Grid auto flow ------------------------------------------------------
94
177
  if (props.gridAutoFlow) {
@@ -101,6 +184,13 @@ function buildTaffyStyle(node) {
101
184
  if (props.gridAutoRows) {
102
185
  style.gridAutoRows = parseTrackSizingList(props.gridAutoRows);
103
186
  }
187
+ else if (typeof props.__autoRowsPx === "number") {
188
+ // Set by `annotateCollapsedAspectGrids` after a first layout pass —
189
+ // pin implicit rows to the height we want each cell to be (column
190
+ // width / aspectRatio), so `aspect-square` images don't overlap.
191
+ const px = props.__autoRowsPx;
192
+ style.gridAutoRows = [{ min: px, max: px }];
193
+ }
104
194
  // --- Grid template areas -------------------------------------------------
105
195
  if (props.gridTemplateAreas) {
106
196
  style.gridTemplateAreas = parseGridTemplateAreas(props.gridTemplateAreas);
@@ -109,7 +199,15 @@ function buildTaffyStyle(node) {
109
199
  else {
110
200
  style.display = T.Display.Flex;
111
201
  }
112
- style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
202
+ if (!isStack) {
203
+ style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
204
+ }
205
+ // --- Stack child placement -------------------------------------------------
206
+ // Place every child of a Stack in the single 1×1 cell so they overlap.
207
+ if (parentType === "stack") {
208
+ style.gridColumn = { start: 1, end: 2 };
209
+ style.gridRow = { start: 1, end: 2 };
210
+ }
113
211
  // --- Grid child placement (applies regardless of parent display) ----------
114
212
  if (props.gridColumn) {
115
213
  style.gridColumn = parseGridLine(props.gridColumn);
@@ -130,25 +228,53 @@ function buildTaffyStyle(node) {
130
228
  style.gridRowEnd = parseGridPlacement(props.gridRowEnd);
131
229
  }
132
230
  // --- Flex properties -------------------------------------------------------
133
- style.flexGrow = parseFloat(props.flexGrow) || parseFloat(props.flex) || 0;
134
- style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : 1;
135
- if (props.flexBasis !== undefined)
231
+ // The `flex: <n>` shorthand (Tailwind's `flex-1` is `flex: 1 1 0%`) sets
232
+ // grow + shrink + basis at once. Without honouring the basis here, an
233
+ // overflowing flex item starts at its intrinsic content size (e.g. a
234
+ // huge feed) and Taffy redistributes from there, which under-allocates
235
+ // the BottomNav and pushes it off-screen on routes with tall content.
236
+ const flexShorthand = props.flex !== undefined ? parseFloat(props.flex) : NaN;
237
+ style.flexGrow = parseFloat(props.flexGrow) || (Number.isFinite(flexShorthand) ? flexShorthand : 0);
238
+ // Default flex-shrink is 0 when:
239
+ // - the component has an intrinsic size (icon, avatar, …) — otherwise it
240
+ // collapses to 0×0 in a tight cross-axis container, e.g. an icon inside
241
+ // a transparent button with only `padding.0`.
242
+ // - the parent is a scrollable container along the main axis — otherwise
243
+ // a horizontal strip's items shrink to fit instead of overflowing,
244
+ // making the strip pointless.
245
+ const parentIsRow = parentType !== "column" && parentType !== "stack" && parentType !== null;
246
+ const parentIsCol = parentType === "column";
247
+ const parentMainAxisScrolls = (parentIsRow && parentScrollAxes.x) || (parentIsCol && parentScrollAxes.y);
248
+ const defaultShrink = INTRINSIC_SIZED.has(type) || parentMainAxisScrolls ? 0 : 1;
249
+ style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : defaultShrink;
250
+ if (props.flexBasis !== undefined) {
136
251
  style.flexBasis = toDimension(props.flexBasis);
252
+ }
253
+ else if (Number.isFinite(flexShorthand)) {
254
+ // `flex: 1` shorthand → basis 0%. Pinning basis is the load-bearing
255
+ // bit: an item with `flex: 1 1 auto` starts at its content size and
256
+ // distributes from there, leaving siblings in a tight container with
257
+ // negative space; `flex: 1 1 0%` starts at zero and grows up.
258
+ style.flexBasis = 0;
259
+ }
137
260
  if (props.flexWrap === "wrap")
138
261
  style.flexWrap = T.FlexWrap.Wrap;
139
262
  else if (props.flexWrap === "wrap-reverse")
140
263
  style.flexWrap = T.FlexWrap.WrapReverse;
141
264
  // --- Alignment (Hypen uses verticalAlignment / horizontalAlignment) --------
142
- const justifyRaw = isColumn
143
- ? (props.verticalAlignment || props.justifyContent)
144
- : (props.horizontalAlignment || props.justifyContent);
145
- const alignRaw = isColumn
146
- ? (props.horizontalAlignment || props.alignItems)
147
- : (props.verticalAlignment || props.alignItems);
148
- if (justifyRaw)
149
- style.justifyContent = mapJustify(T, justifyRaw);
150
- if (alignRaw)
151
- style.alignItems = mapAlign(T, alignRaw);
265
+ // Stack handles its own alignment via justifyItems/alignItems above.
266
+ if (!isStack) {
267
+ const justifyRaw = isColumn
268
+ ? (props.verticalAlignment || props.justifyContent)
269
+ : (props.horizontalAlignment || props.justifyContent);
270
+ const alignRaw = isColumn
271
+ ? (props.horizontalAlignment || props.alignItems)
272
+ : (props.verticalAlignment || props.alignItems);
273
+ if (justifyRaw)
274
+ style.justifyContent = mapJustify(T, justifyRaw);
275
+ if (alignRaw)
276
+ style.alignItems = mapAlign(T, alignRaw);
277
+ }
152
278
  // --- Size ------------------------------------------------------------------
153
279
  const w = parseSize(props.width);
154
280
  const h = parseSize(props.height);
@@ -161,7 +287,7 @@ function buildTaffyStyle(node) {
161
287
  }
162
288
  else if (type === "divider" || type === "separator") {
163
289
  const orientation = props.orientation || "horizontal";
164
- const thickness = parseFloat(props.thickness) || 1;
290
+ const thickness = cssLengthToPx(props.thickness) ?? 1;
165
291
  if (orientation === "vertical") {
166
292
  style.size = { width: w ?? thickness, height: h !== null ? h : "100%" };
167
293
  }
@@ -170,7 +296,7 @@ function buildTaffyStyle(node) {
170
296
  }
171
297
  }
172
298
  else if (type === "checkbox" || type === "radio") {
173
- const sz = parseFloat(props.size) || 20;
299
+ const sz = cssLengthToPx(props.size) ?? 20;
174
300
  style.size = { width: w ?? sz, height: h ?? sz };
175
301
  }
176
302
  else if (type === "switch" || type === "toggle") {
@@ -183,30 +309,71 @@ function buildTaffyStyle(node) {
183
309
  style.size = { width: w ?? 200, height: h ?? 8 };
184
310
  }
185
311
  else if (type === "spinner" || type === "loading") {
186
- const sz = parseFloat(props.size) || 24;
312
+ const sz = cssLengthToPx(props.size) ?? 24;
187
313
  style.size = { width: w ?? sz, height: h ?? sz };
188
314
  }
189
315
  else if (type === "badge") {
190
316
  style.size = { width: w ?? 20, height: h ?? 20 };
191
317
  }
192
318
  else if (type === "avatar") {
193
- const sz = parseFloat(props.size) || 40;
319
+ const sz = cssLengthToPx(props.size) ?? 40;
194
320
  style.size = { width: w ?? sz, height: h ?? sz };
195
321
  }
196
322
  else if (type === "icon") {
197
- const sz = parseFloat(props.size) || 24;
323
+ const sz = cssLengthToPx(props.size) ?? 24;
198
324
  style.size = { width: w ?? sz, height: h ?? sz };
199
325
  }
326
+ else if (type === "input" || type === "textarea" || type === "select") {
327
+ // Form controls have no painted children, so without an intrinsic
328
+ // height they collapse to padding only (the social Search bar
329
+ // rendered as a 4-tall pill instead of the ~36 the DOM produces).
330
+ // Match the DOM box: outer = line-height(s) + padding + border.
331
+ // Taffy defaults to `box-sizing: border-box`, so we add the
332
+ // padding/border ourselves into `size.height`.
333
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
334
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.5;
335
+ const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
336
+ const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
337
+ const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
338
+ const borderTop = cssLengthToPx(props.borderTopWidth ?? props.borderWidth) ?? 0;
339
+ const borderBottom = cssLengthToPx(props.borderBottomWidth ?? props.borderWidth) ?? 0;
340
+ const intrinsicH = lineHeight * minRows + padTop + padBottom + borderTop + borderBottom;
341
+ style.size = { width: toDimension(props.width), height: h ?? intrinsicH };
342
+ }
200
343
  else {
201
- style.size = { width: toDimension(props.width), height: toDimension(props.height) };
344
+ // Children of a Grid that ask for `width: 100%` actually mean "fill
345
+ // the cell" — that's the DOM behavior (`grid > * { justify-self:
346
+ // stretch }`). Taffy handles this via the grid item's default
347
+ // stretch alignment when the size is auto, but a literal 100% creates
348
+ // a circular dependency with track sizing (the cell wants to be 1fr
349
+ // of the grid, the item wants to be 100% of the cell, and Taffy
350
+ // collapses both to the item's max-content). Strip the 100% on grid
351
+ // children so the Taffy grid stretch wins. Same for height.
352
+ const parentIsGridLayout = parent &&
353
+ (parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
354
+ const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
355
+ const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
356
+ style.size = { width: toDimension(widthIn), height: toDimension(heightIn) };
202
357
  }
203
358
  // --- Min / Max constraints -------------------------------------------------
359
+ // Default `min-width: 0` for children of a Grid container. CSS makes
360
+ // grid items default to `min-width: auto` (= min-content) which prevents
361
+ // them shrinking below their intrinsic min. For an Image with
362
+ // `aspect-ratio: 1`, that "min" is the image's natural max-content —
363
+ // so the grid track sizing collapses (one item ends up 360px wide and
364
+ // squashes its row). Explicitly setting min: 0 is the standard CSS
365
+ // workaround and matches the DOM grid's `justify-self: stretch` shape.
366
+ const parentIsGrid = parent &&
367
+ (parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
204
368
  if (props.minWidth !== undefined || props.minHeight !== undefined) {
205
369
  style.minSize = {
206
370
  width: toDimension(props.minWidth),
207
371
  height: toDimension(props.minHeight),
208
372
  };
209
373
  }
374
+ else if (parentIsGrid) {
375
+ style.minSize = { width: 0, height: 0 };
376
+ }
210
377
  if (props.maxWidth !== undefined || props.maxHeight !== undefined) {
211
378
  style.maxSize = {
212
379
  width: toDimension(props.maxWidth),
@@ -216,35 +383,71 @@ function buildTaffyStyle(node) {
216
383
  // --- Margin ----------------------------------------------------------------
217
384
  const m = parseSpacing(props.margin || 0);
218
385
  if (props.marginTop !== undefined)
219
- m.top = parseFloat(props.marginTop) || 0;
386
+ m.top = cssLengthToPx(props.marginTop) ?? 0;
220
387
  if (props.marginRight !== undefined)
221
- m.right = parseFloat(props.marginRight) || 0;
388
+ m.right = cssLengthToPx(props.marginRight) ?? 0;
222
389
  if (props.marginBottom !== undefined)
223
- m.bottom = parseFloat(props.marginBottom) || 0;
390
+ m.bottom = cssLengthToPx(props.marginBottom) ?? 0;
224
391
  if (props.marginLeft !== undefined)
225
- m.left = parseFloat(props.marginLeft) || 0;
392
+ m.left = cssLengthToPx(props.marginLeft) ?? 0;
226
393
  style.margin = { top: m.top, right: m.right, bottom: m.bottom, left: m.left };
227
394
  // --- Padding ---------------------------------------------------------------
228
395
  const p = parseSpacing(props.padding || 0);
229
396
  if (props.paddingTop !== undefined)
230
- p.top = parseFloat(props.paddingTop) || 0;
397
+ p.top = cssLengthToPx(props.paddingTop) ?? 0;
231
398
  if (props.paddingRight !== undefined)
232
- p.right = parseFloat(props.paddingRight) || 0;
399
+ p.right = cssLengthToPx(props.paddingRight) ?? 0;
233
400
  if (props.paddingBottom !== undefined)
234
- p.bottom = parseFloat(props.paddingBottom) || 0;
401
+ p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
235
402
  if (props.paddingLeft !== undefined)
236
- p.left = parseFloat(props.paddingLeft) || 0;
403
+ p.left = cssLengthToPx(props.paddingLeft) ?? 0;
237
404
  style.padding = { top: p.top, right: p.right, bottom: p.bottom, left: p.left };
238
405
  // --- Border ----------------------------------------------------------------
239
- const bw = parseFloat(props.borderWidth) || 0;
406
+ const bw = cssLengthToPx(props.borderWidth) ?? 0;
240
407
  if (bw > 0) {
241
408
  style.border = { top: bw, right: bw, bottom: bw, left: bw };
242
409
  }
243
410
  // --- Gap -------------------------------------------------------------------
244
- const gap = parseFloat(props.gap) || 0;
411
+ const gap = cssLengthToPx(props.gap) ?? 0;
245
412
  if (gap > 0) {
246
413
  style.gap = { width: gap, height: gap };
247
414
  }
415
+ // --- Aspect ratio ----------------------------------------------------------
416
+ // Engine emits values like `"1"` or `16/9`. Taffy expects a number.
417
+ if (props.aspectRatio !== undefined) {
418
+ const ar = parseAspectRatio(props.aspectRatio);
419
+ if (ar !== null)
420
+ style.aspectRatio = ar;
421
+ }
422
+ else if (type === "image") {
423
+ // Implicit aspect ratio from the decoded image's intrinsic size — only
424
+ // useful when the caller fixes one dimension and lets the other follow.
425
+ // Without this, an image with `width: 100` stretches to whatever Taffy
426
+ // picks for the height (the parent's full height under the default
427
+ // `align-items: stretch` of a flex row).
428
+ //
429
+ // We can't rely on Taffy's `aspectRatio` alone: with `align-items:
430
+ // stretch`, the cross-axis size is forced before aspect-ratio is
431
+ // applied. Pin both dimensions explicitly instead.
432
+ const wParsed = parseSize(props.width);
433
+ const hParsed = parseSize(props.height);
434
+ if ((wParsed === null) !== (hParsed === null)) {
435
+ const src = props.src ?? props[0];
436
+ if (typeof src === "string") {
437
+ const intrinsic = getImageNaturalAspect(src);
438
+ if (intrinsic !== null) {
439
+ if (wParsed !== null) {
440
+ style.size = { width: wParsed, height: wParsed / intrinsic };
441
+ style.aspectRatio = intrinsic;
442
+ }
443
+ else if (hParsed !== null) {
444
+ style.size = { width: hParsed * intrinsic, height: hParsed };
445
+ style.aspectRatio = intrinsic;
446
+ }
447
+ }
448
+ }
449
+ }
450
+ }
248
451
  // --- Position (absolute) ---------------------------------------------------
249
452
  if (props.position === "absolute") {
250
453
  style.position = T.Position.Absolute;
@@ -257,6 +460,28 @@ function buildTaffyStyle(node) {
257
460
  if (props.bottom !== undefined)
258
461
  style.bottom = toLengthPctAuto(props.bottom);
259
462
  }
463
+ // --- Overflow (scrollable containers) -------------------------------------
464
+ // For scroll containers we need a careful balance:
465
+ // - Children must lay out at their full intrinsic positions so
466
+ // `ScrollManager.updateScrollBounds` can compute scrollHeight (a feed
467
+ // with overflow:Hidden gets its children clamped and the page won't
468
+ // scroll).
469
+ // - But the CONTAINER itself must not bubble its overflowing min-content
470
+ // up into its parent (a horizontally-scrollable Stories row with 5+
471
+ // w-24 items inflated the whole feed to 607px on a 470px canvas).
472
+ //
473
+ // Setting overflow to `Scroll` on the relevant axis tells Taffy to treat
474
+ // this container as a scroll port — children flow at intrinsic sizes but
475
+ // the container's own contribution to the parent stays bounded. The
476
+ // non-scroll axis stays Visible so siblings still see the right content
477
+ // size when they need to.
478
+ const ownAxes = readScrollAxes(props);
479
+ if (ownAxes.x || ownAxes.y) {
480
+ style.overflow = {
481
+ x: ownAxes.x ? T.Overflow.Scroll : T.Overflow.Visible,
482
+ y: ownAxes.y ? T.Overflow.Scroll : T.Overflow.Visible,
483
+ };
484
+ }
260
485
  return style;
261
486
  }
262
487
  function mapJustify(T, value) {
@@ -325,10 +550,27 @@ function parseGridTemplate(value) {
325
550
  return value.map(parseTrackSizing);
326
551
  }
327
552
  if (typeof value === "string") {
553
+ // The DSL's `.gridColumns(3)` sometimes arrives here as `"3"` after
554
+ // stringification (engine emits numeric applicator args as strings).
555
+ // Treat a bare integer string the same way the DOM renderer does —
556
+ // expand to N equal 1fr tracks. `repeat(N, 1fr)` shorthand would work
557
+ // too but Taffy wants the expanded track list.
558
+ const trimmed = value.trim();
559
+ if (/^\d+$/.test(trimmed)) {
560
+ const n = parseInt(trimmed, 10);
561
+ return Array.from({ length: n }, () => ({
562
+ min: "auto",
563
+ max: "1fr",
564
+ }));
565
+ }
328
566
  return value.split(/\s+/).filter(Boolean).map(parseTrackSizing);
329
567
  }
330
568
  if (typeof value === "number") {
331
- return [parseTrackSizing(value)];
569
+ // Bare number = column count. DOM: `repeat(N, 1fr)`.
570
+ return Array.from({ length: value }, () => ({
571
+ min: "auto",
572
+ max: "1fr",
573
+ }));
332
574
  }
333
575
  return [];
334
576
  }
@@ -442,26 +684,31 @@ function parseGridTemplateAreas(value) {
442
684
  * Build a TaffyTree mirroring the VirtualNode tree. Returns the root Taffy
443
685
  * node id and a mapping from Taffy node ids → VirtualNodes.
444
686
  */
445
- function buildTree(tree, node) {
687
+ function buildTree(tree, node, parent = null) {
446
688
  const T = taffy;
447
689
  const props = node.props;
448
- const style = buildTaffyStyle(node);
449
- // Text leaf nodes — create with measurement context
450
- if (node.type === "text" && props[0] && node.children.length === 0) {
690
+ const style = buildTaffyStyle(node, parent);
691
+ // Text leaf nodes — create with measurement context. The engine emits the
692
+ // type capitalised (`"Text"`); compare case-insensitively so a Taffy leaf
693
+ // is created. Without this, every Text node was treated as an empty
694
+ // container with size 0×0, and downstream siblings were placed on top of
695
+ // each other ("1,431 likes" overlapping the caption row was the visible
696
+ // symptom).
697
+ if (node.type.toLowerCase() === "text" && props[0] && node.children.length === 0) {
451
698
  const text = String(props[0] || "");
452
- const fontSize = parseFloat(props.fontSize) || 16;
699
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
453
700
  const fontWeight = props.fontWeight || "normal";
454
701
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
455
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
702
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
456
703
  const p = parseSpacing(props.padding || 0);
457
704
  if (props.paddingTop !== undefined)
458
- p.top = parseFloat(props.paddingTop) || 0;
705
+ p.top = cssLengthToPx(props.paddingTop) ?? 0;
459
706
  if (props.paddingRight !== undefined)
460
- p.right = parseFloat(props.paddingRight) || 0;
707
+ p.right = cssLengthToPx(props.paddingRight) ?? 0;
461
708
  if (props.paddingBottom !== undefined)
462
- p.bottom = parseFloat(props.paddingBottom) || 0;
709
+ p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
463
710
  if (props.paddingLeft !== undefined)
464
- p.left = parseFloat(props.paddingLeft) || 0;
711
+ p.left = cssLengthToPx(props.paddingLeft) ?? 0;
465
712
  const ctx = {
466
713
  text,
467
714
  fontSize,
@@ -473,10 +720,24 @@ function buildTree(tree, node) {
473
720
  };
474
721
  return tree.newLeafWithContext(style, ctx);
475
722
  }
723
+ // Image leaves with aspect-ratio: contribute height = stretched-width /
724
+ // aspectRatio to grid/flex track sizing via the measure callback. The
725
+ // `style.aspectRatio` alone isn't enough — Taffy's grid auto-row pass asks
726
+ // for the leaf's max-content height, which for an image with no fixed
727
+ // height is 0; without the callback the row collapses.
728
+ if (node.type.toLowerCase() === "image" && node.children.length === 0) {
729
+ const ar = props.aspectRatio !== undefined
730
+ ? parseAspectRatio(props.aspectRatio)
731
+ : null;
732
+ if (ar !== null) {
733
+ const ictx = { aspectRatio: ar };
734
+ return tree.newLeafWithContext(style, ictx);
735
+ }
736
+ }
476
737
  // Container nodes
477
738
  const childIds = [];
478
739
  for (const child of node.children) {
479
- childIds.push(buildTree(tree, child));
740
+ childIds.push(buildTree(tree, child, node));
480
741
  }
481
742
  return tree.newWithChildren(style, childIds);
482
743
  }
@@ -496,7 +757,7 @@ function writeLayout(tree, taffyId, node, parentX, parentY, isRoot = false) {
496
757
  const absX = parentX + tl.x + (isRoot ? tl.marginLeft : 0);
497
758
  const absY = parentY + tl.y + (isRoot ? tl.marginTop : 0);
498
759
  const borderColor = node.props.borderColor || "transparent";
499
- const borderRadius = parseFloat(node.props.borderRadius) || 0;
760
+ const borderRadius = cssLengthToPx(node.props.borderRadius) ?? 0;
500
761
  // Taffy computes uniform border width per side; pick top as representative
501
762
  const borderWidth = tl.borderTop;
502
763
  const layout = {
@@ -533,6 +794,78 @@ function writeLayout(tree, taffyId, node, parentX, parentY, isRoot = false) {
533
794
  writeLayout(tree, childTaffyId, node.children[i], absX, absY);
534
795
  }
535
796
  }
797
+ /**
798
+ * After a layout pass, detect Grid containers whose implicit row tracks
799
+ * collapsed against aspect-ratio image children — Taffy treats the
800
+ * leaves' max-content height as 0 (the measure callback can't anchor the
801
+ * height without a known width during track sizing), so the rows get
802
+ * sized to gap-only and items overflow their tracks by ~150px each.
803
+ *
804
+ * Mark the grid with a private hint (`__autoRowsPx`) carrying the height
805
+ * we want each implicit row to be — derived from the first item's
806
+ * computed width and aspect-ratio after the first pass. The next layout
807
+ * pass reads this hint in `buildTaffyStyle` and sets explicit
808
+ * `gridAutoRows`. Returns true if any grid was marked (caller re-runs
809
+ * layout from scratch).
810
+ */
811
+ function annotateCollapsedAspectGrids(node) {
812
+ let touched = false;
813
+ for (const child of node.children) {
814
+ if (annotateCollapsedAspectGrids(child))
815
+ touched = true;
816
+ }
817
+ if (!node.layout)
818
+ return touched;
819
+ const t = node.type.toLowerCase();
820
+ const isGrid = t === "grid" || node.props.display === "grid";
821
+ if (!isGrid)
822
+ return touched;
823
+ // Only adjust grids that haven't already been pinned (avoid loops).
824
+ if (node.props.__autoRowsPx)
825
+ return touched;
826
+ let firstAspectKid = null;
827
+ let aspectRatio = 0;
828
+ for (const child of node.children) {
829
+ if (child.type.toLowerCase() !== "image" || !child.layout)
830
+ continue;
831
+ const ar = child.props.aspectRatio !== undefined
832
+ ? parseAspectRatio(child.props.aspectRatio)
833
+ : null;
834
+ if (ar === null)
835
+ continue;
836
+ firstAspectKid = child;
837
+ aspectRatio = ar;
838
+ break;
839
+ }
840
+ if (!firstAspectKid)
841
+ return touched;
842
+ const itemWidth = firstAspectKid.layout.width;
843
+ const expectedRowHeight = itemWidth / aspectRatio;
844
+ if (expectedRowHeight <= 0)
845
+ return touched;
846
+ // Find a sibling image in the next row to confirm collapse: if its y
847
+ // delta from the first row is less than the expected row height (minus
848
+ // a tolerance for fractional pixels and gap rounding), the rows
849
+ // overlapped — this is the symptom we're fixing.
850
+ let nextRowKid = null;
851
+ for (const child of node.children) {
852
+ if (child === firstAspectKid)
853
+ continue;
854
+ if (!child.layout)
855
+ continue;
856
+ if (child.layout.y > firstAspectKid.layout.y + 1) {
857
+ nextRowKid = child;
858
+ break;
859
+ }
860
+ }
861
+ if (!nextRowKid)
862
+ return touched;
863
+ const rowDelta = nextRowKid.layout.y - firstAspectKid.layout.y;
864
+ if (rowDelta >= expectedRowHeight - 1)
865
+ return touched;
866
+ node.props.__autoRowsPx = expectedRowHeight;
867
+ return true;
868
+ }
536
869
  /**
537
870
  * Compute layout for a virtual node tree using Taffy.
538
871
  * Requires `initTaffyLayout()` to have been called and resolved.
@@ -542,8 +875,41 @@ function computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y) {
542
875
  const tree = new T.TaffyTree();
543
876
  try {
544
877
  const rootId = buildTree(tree, node);
878
+ // Pin the root to the canvas's available size when it has no explicit
879
+ // size of its own. Without this, Taffy lets the root grow to its
880
+ // min-content — and a horizontally-scrollable Stories row with 5+
881
+ // items at w-24 inflated the entire feed to 607px on a 470px canvas,
882
+ // pushing the BottomNav off-screen.
883
+ const rootProps = node.props;
884
+ const rootHasExplicitWidth = rootProps.width !== undefined || rootProps["width.0"] !== undefined;
885
+ const rootHasExplicitHeight = rootProps.height !== undefined || rootProps["height.0"] !== undefined;
886
+ if (!rootHasExplicitWidth || !rootHasExplicitHeight) {
887
+ const rootStyle = tree.getStyle(rootId);
888
+ rootStyle.size = {
889
+ width: rootHasExplicitWidth
890
+ ? cssLengthToDimension(rootProps.width ?? rootProps["width.0"])
891
+ : availableWidth,
892
+ height: rootHasExplicitHeight
893
+ ? cssLengthToDimension(rootProps.height ?? rootProps["height.0"])
894
+ : availableHeight,
895
+ };
896
+ tree.setStyle(rootId, rootStyle);
897
+ }
545
898
  // Compute layout with a measure function for text leaf nodes
546
899
  tree.computeLayoutWithMeasure(rootId, { width: availableWidth, height: availableHeight }, (knownDimensions, availableSpace, _nodeId, context, _style) => {
900
+ // Image-with-aspect-ratio leaf: derive missing dim from the other
901
+ // via aspectRatio so grid track sizing sees a proper max-content
902
+ // height. Falls back to availableSpace.width when nothing's known.
903
+ const ictx = context;
904
+ if (ictx && typeof ictx.aspectRatio === "number") {
905
+ const w = knownDimensions.width ??
906
+ (typeof availableSpace.width === "number"
907
+ ? availableSpace.width
908
+ : undefined);
909
+ const h = knownDimensions.height ??
910
+ (w !== undefined ? w / ictx.aspectRatio : undefined);
911
+ return { width: w ?? 0, height: h ?? 0 };
912
+ }
547
913
  const tctx = context;
548
914
  if (!tctx?.text) {
549
915
  return { width: knownDimensions.width ?? 0, height: knownDimensions.height ?? 0 };
@@ -557,9 +923,14 @@ function computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y) {
557
923
  fontFamily: tctx.fontFamily,
558
924
  lineHeight: tctx.lineHeight,
559
925
  }, maxWidth);
926
+ // `Math.ceil` the reported width: pretext returns a fractional
927
+ // pixel width (e.g. 141.71875 for "View all 87 comments"), Taffy
928
+ // gives the box exactly that, but at paint time we re-measure with
929
+ // a maxWidth of the integer-rounded box width and pretext then
930
+ // wraps because 141.71 > 141. Reserve the next whole pixel up.
560
931
  return {
561
- width: knownDimensions.width ?? (metrics.width + tctx.paddingH),
562
- height: knownDimensions.height ?? (metrics.height + tctx.paddingV),
932
+ width: knownDimensions.width ?? (Math.ceil(metrics.width) + tctx.paddingH),
933
+ height: knownDimensions.height ?? (Math.ceil(metrics.height) + tctx.paddingV),
563
934
  };
564
935
  });
565
936
  writeLayout(tree, rootId, node, x, y, /* isRoot */ true);
@@ -580,6 +951,14 @@ function computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y) {
580
951
  export function computeLayout(ctx, node, availableWidth, availableHeight, x = 0, y = 0) {
581
952
  if (taffyReady && taffy) {
582
953
  computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
954
+ // After the first pass, Taffy's grid auto-row sizing for aspect-ratio
955
+ // image leaves can collapse rows (the leaf's max-content height is 0
956
+ // before column widths are known). Detect that pattern and re-run
957
+ // with explicit `gridAutoRows` derived from the now-known column
958
+ // width — see `annotateCollapsedAspectGrids`.
959
+ if (annotateCollapsedAspectGrids(node)) {
960
+ computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
961
+ }
583
962
  }
584
963
  else {
585
964
  computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
@@ -592,25 +971,25 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
592
971
  const props = node.props;
593
972
  let margin = parseSpacing(props.margin || 0);
594
973
  if (props.marginTop !== undefined)
595
- margin.top = parseFloat(props.marginTop) || 0;
974
+ margin.top = cssLengthToPx(props.marginTop) ?? 0;
596
975
  if (props.marginRight !== undefined)
597
- margin.right = parseFloat(props.marginRight) || 0;
976
+ margin.right = cssLengthToPx(props.marginRight) ?? 0;
598
977
  if (props.marginBottom !== undefined)
599
- margin.bottom = parseFloat(props.marginBottom) || 0;
978
+ margin.bottom = cssLengthToPx(props.marginBottom) ?? 0;
600
979
  if (props.marginLeft !== undefined)
601
- margin.left = parseFloat(props.marginLeft) || 0;
980
+ margin.left = cssLengthToPx(props.marginLeft) ?? 0;
602
981
  let padding = parseSpacing(props.padding || 0);
603
982
  if (props.paddingTop !== undefined)
604
- padding.top = parseFloat(props.paddingTop) || 0;
983
+ padding.top = cssLengthToPx(props.paddingTop) ?? 0;
605
984
  if (props.paddingRight !== undefined)
606
- padding.right = parseFloat(props.paddingRight) || 0;
985
+ padding.right = cssLengthToPx(props.paddingRight) ?? 0;
607
986
  if (props.paddingBottom !== undefined)
608
- padding.bottom = parseFloat(props.paddingBottom) || 0;
987
+ padding.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
609
988
  if (props.paddingLeft !== undefined)
610
- padding.left = parseFloat(props.paddingLeft) || 0;
611
- const borderWidth = parseFloat(props.borderWidth) || 0;
989
+ padding.left = cssLengthToPx(props.paddingLeft) ?? 0;
990
+ const borderWidth = cssLengthToPx(props.borderWidth) ?? 0;
612
991
  const borderColor = props.borderColor || "transparent";
613
- const borderRadius = parseFloat(props.borderRadius) || 0;
992
+ const borderRadius = cssLengthToPx(props.borderRadius) ?? 0;
614
993
  const availableAfterMargin = {
615
994
  width: availableWidth - margin.left - margin.right,
616
995
  height: availableHeight - margin.top - margin.bottom,
@@ -632,7 +1011,7 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
632
1011
  }
633
1012
  else if (type === "divider" || type === "separator") {
634
1013
  const orientation = props.orientation || "horizontal";
635
- const thickness = parseFloat(props.thickness) || 1;
1014
+ const thickness = cssLengthToPx(props.thickness) ?? 1;
636
1015
  if (orientation === "vertical") {
637
1016
  if (width === null)
638
1017
  width = thickness;
@@ -647,7 +1026,7 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
647
1026
  }
648
1027
  }
649
1028
  else if (type === "checkbox" || type === "radio") {
650
- const size = parseFloat(props.size) || 20;
1029
+ const size = cssLengthToPx(props.size) ?? 20;
651
1030
  if (width === null)
652
1031
  width = size;
653
1032
  if (height === null)
@@ -672,7 +1051,7 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
672
1051
  height = 8;
673
1052
  }
674
1053
  else if (type === "spinner" || type === "loading") {
675
- const size = parseFloat(props.size) || 24;
1054
+ const size = cssLengthToPx(props.size) ?? 24;
676
1055
  if (width === null)
677
1056
  width = size;
678
1057
  if (height === null)
@@ -685,25 +1064,25 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
685
1064
  height = 20;
686
1065
  }
687
1066
  else if (type === "avatar") {
688
- const size = parseFloat(props.size) || 40;
1067
+ const size = cssLengthToPx(props.size) ?? 40;
689
1068
  if (width === null)
690
1069
  width = size;
691
1070
  if (height === null)
692
1071
  height = size;
693
1072
  }
694
1073
  else if (type === "icon") {
695
- const size = parseFloat(props.size) || 24;
1074
+ const size = cssLengthToPx(props.size) ?? 24;
696
1075
  if (width === null)
697
1076
  width = size;
698
1077
  if (height === null)
699
1078
  height = size;
700
1079
  }
701
- if (node.type === "text" && node.props[0]) {
1080
+ if (node.type.toLowerCase() === "text" && node.props[0]) {
702
1081
  const text = String(node.props[0] || "");
703
- const fontSize = parseFloat(props.fontSize) || 16;
1082
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
704
1083
  const fontWeight = props.fontWeight || "normal";
705
1084
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
706
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
1085
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
707
1086
  const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
708
1087
  const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, maxWidth);
709
1088
  if (!width)
@@ -764,7 +1143,7 @@ function layoutChildrenFallback(ctx, parent) {
764
1143
  const alignItems = isColumn
765
1144
  ? (props.horizontalAlignment || "flex-start")
766
1145
  : (props.verticalAlignment || "flex-start");
767
- const gap = parseFloat(props.gap) || 0;
1146
+ const gap = cssLengthToPx(props.gap) ?? 0;
768
1147
  const availableWidth = layout.contentWidth;
769
1148
  const availableHeight = layout.contentHeight;
770
1149
  const childInfo = [];
@@ -773,7 +1152,11 @@ function layoutChildrenFallback(ctx, parent) {
773
1152
  let totalFlexShrink = 0;
774
1153
  for (const child of parent.children) {
775
1154
  const flexGrow = parseFloat(child.props.flexGrow) || parseFloat(child.props.flex) || 0;
776
- const flexShrink = parseFloat(child.props.flexShrink) || 1;
1155
+ const childType = child.type.toLowerCase();
1156
+ const defaultShrink = INTRINSIC_SIZED.has(childType) ? 0 : 1;
1157
+ const flexShrink = child.props.flexShrink !== undefined
1158
+ ? parseFloat(child.props.flexShrink)
1159
+ : defaultShrink;
777
1160
  const flexBasis = parseSize(child.props.flexBasis);
778
1161
  computeLayoutFallback(ctx, child, availableWidth, availableHeight, 0, 0);
779
1162
  const childLayout = child.layout;