@hypen-space/web 0.4.82 → 0.4.84

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
@@ -58,10 +58,36 @@ export function createScrollState(): ScrollState {
58
58
  };
59
59
  }
60
60
 
61
- /** Returns true if the node is configured as a scrollable container. */
61
+ /** Returns true if the node is configured as a scrollable container.
62
+ *
63
+ * Accepts both forms the engine and user code emit:
64
+ * - `overflow: "scroll" | "auto"` — CSS-style (DOM parity)
65
+ * - `scrollable: true | "vertical" | "horizontal" | "both"` — Hypen DSL form
66
+ */
62
67
  export function isScrollable(node: VirtualNode): boolean {
68
+ const axes = getScrollAxes(node);
69
+ return axes.x || axes.y;
70
+ }
71
+
72
+ /**
73
+ * Resolve which axes a node is allowed to scroll on.
74
+ *
75
+ * `scrollable: "horizontal"` means a horizontal strip — the user expects
76
+ * vertical drags / wheel events to bubble out, and content overflowing the
77
+ * Y axis must NOT inflate scrollHeight (otherwise the strip jitters
78
+ * vertically on touch).
79
+ */
80
+ export function getScrollAxes(node: VirtualNode): { x: boolean; y: boolean } {
63
81
  const overflow = node.props.overflow;
64
- return overflow === "scroll" || overflow === "auto";
82
+ if (overflow === "scroll" || overflow === "auto") return { x: true, y: true };
83
+ const scrollable = node.props.scrollable;
84
+ if (scrollable === true) return { x: true, y: true };
85
+ if (typeof scrollable === "string") {
86
+ if (scrollable === "horizontal") return { x: true, y: false };
87
+ if (scrollable === "vertical") return { x: false, y: true };
88
+ if (scrollable === "both") return { x: true, y: true };
89
+ }
90
+ return { x: false, y: false };
65
91
  }
66
92
 
67
93
  /** Ensure a scroll state is attached; returns the (possibly new) state. */
@@ -165,12 +191,13 @@ export class ScrollManager {
165
191
  // -------------------------------------------------------------------------
166
192
 
167
193
  private canvasPoint(clientX: number, clientY: number): Point {
194
+ // Logical CSS pixels — layout is in logical units (the renderer scales the
195
+ // ctx by dpr once at setup), so hit tests must match. See the same note in
196
+ // `events.ts#getCanvasCoordinates`.
168
197
  const rect = this.canvas.getBoundingClientRect();
169
- const scaleX = this.canvas.width / rect.width;
170
- const scaleY = this.canvas.height / rect.height;
171
198
  return {
172
- x: (clientX - rect.left) * scaleX,
173
- y: (clientY - rect.top) * scaleY,
199
+ x: clientX - rect.left,
200
+ y: clientY - rect.top,
174
201
  };
175
202
  }
176
203
 
@@ -234,12 +261,13 @@ export class ScrollManager {
234
261
  ): boolean {
235
262
  const ss = node.scrollState;
236
263
  if (!ss) return false;
264
+ const axes = getScrollAxes(node);
237
265
  const { maxX, maxY } = maxScroll(node);
238
266
 
239
- if (dy < 0 && ss.scrollY > 0) return true;
240
- if (dy > 0 && ss.scrollY < maxY) return true;
241
- if (dx < 0 && ss.scrollX > 0) return true;
242
- if (dx > 0 && ss.scrollX < maxX) return true;
267
+ if (axes.y && dy < 0 && ss.scrollY > 0) return true;
268
+ if (axes.y && dy > 0 && ss.scrollY < maxY) return true;
269
+ if (axes.x && dx < 0 && ss.scrollX > 0) return true;
270
+ if (axes.x && dx > 0 && ss.scrollX < maxX) return true;
243
271
 
244
272
  return false;
245
273
  }
@@ -264,10 +292,11 @@ export class ScrollManager {
264
292
  if (!target) return;
265
293
 
266
294
  const ss = ensureScrollState(target);
295
+ const axes = getScrollAxes(target);
267
296
  const { maxX, maxY } = maxScroll(target);
268
297
 
269
- const canScrollX = maxX > 0;
270
- const canScrollY = maxY > 0;
298
+ const canScrollX = axes.x && maxX > 0;
299
+ const canScrollY = axes.y && maxY > 0;
271
300
  if (!canScrollX && !canScrollY) return;
272
301
 
273
302
  if (canScrollY) {
@@ -364,16 +393,14 @@ export class ScrollManager {
364
393
  if (!node) return;
365
394
 
366
395
  const ss = ensureScrollState(node);
396
+ const axes = getScrollAxes(node);
367
397
  const { maxX, maxY } = maxScroll(node);
368
398
  const now = performance.now();
369
399
  const dt = now - this.lastPointerTime;
370
400
 
371
- const rect = this.canvas.getBoundingClientRect();
372
- const scaleX = this.canvas.width / rect.width;
373
- const scaleY = this.canvas.height / rect.height;
374
-
375
- const dx = -(clientX - this.lastPointerX) * scaleX;
376
- const dy = -(clientY - this.lastPointerY) * scaleY;
401
+ // Logical pixel deltas — layout (and scroll bounds) live in logical units.
402
+ const dx = axes.x ? -(clientX - this.lastPointerX) : 0;
403
+ const dy = axes.y ? -(clientY - this.lastPointerY) : 0;
377
404
 
378
405
  ss.scrollX = clampScroll(ss.scrollX + dx, maxX, true);
379
406
  ss.scrollY = clampScroll(ss.scrollY + dy, maxY, true);
@@ -626,7 +653,8 @@ export class ScrollManager {
626
653
  ScrollManager.updateScrollBounds(child);
627
654
  }
628
655
 
629
- if (!isScrollable(node)) return;
656
+ const axes = getScrollAxes(node);
657
+ if (!axes.x && !axes.y) return;
630
658
 
631
659
  const ss = ensureScrollState(node);
632
660
  let maxRight = 0;
@@ -641,8 +669,11 @@ export class ScrollManager {
641
669
  maxBottom = Math.max(maxBottom, relY + child.layout.height + child.layout.margin.bottom);
642
670
  }
643
671
 
644
- ss.scrollWidth = maxRight;
645
- ss.scrollHeight = maxBottom;
672
+ // Cap content size to the viewport on disabled axes — otherwise a
673
+ // horizontal strip with tall children would report a phantom scrollHeight
674
+ // and the touch handler would let users drag vertically into nothing.
675
+ ss.scrollWidth = axes.x ? maxRight : Math.min(maxRight, node.layout.contentWidth);
676
+ ss.scrollHeight = axes.y ? maxBottom : Math.min(maxBottom, node.layout.contentHeight);
646
677
 
647
678
  // Clamp current scroll to new bounds
648
679
  const { maxX, maxY } = maxScroll(node);
@@ -677,8 +708,15 @@ export class ScrollManager {
677
708
  // ---------------------------------------------------------------------------
678
709
 
679
710
  /**
680
- * Get absolute bounds for a node, accounting for scroll offsets of all
681
- * ancestors. This is the scroll-aware replacement for getAbsoluteBounds.
711
+ * Get the on-screen bounds for a node, accounting for scroll offsets of all
712
+ * ancestors. `node.layout.{x,y}` is already absolute (computed in
713
+ * `writeLayout` for the Taffy path and the fallback equivalent), so we only
714
+ * need to subtract each scrollable ancestor's offset — the same translation
715
+ * the paint pipeline applies via `ctx.translate(-scrollX, -scrollY)`.
716
+ *
717
+ * NOTE: an earlier version walked up to add `current.layout.contentX`,
718
+ * which double-counted the parent's padding+border. That made hit testing
719
+ * land on the wrong node any time content was nested more than a level deep.
682
720
  */
683
721
  export function getScrollAwareBounds(node: VirtualNode): Rectangle | null {
684
722
  if (!node.layout) return null;
@@ -687,16 +725,11 @@ export function getScrollAwareBounds(node: VirtualNode): Rectangle | null {
687
725
  let y = node.layout.y;
688
726
 
689
727
  let current = node.parent;
690
- while (current && current.layout) {
691
- x += current.layout.contentX;
692
- y += current.layout.contentY;
693
-
694
- // Apply ancestor scroll offset
728
+ while (current) {
695
729
  if (current.scrollState) {
696
730
  x -= current.scrollState.scrollX;
697
731
  y -= current.scrollState.scrollY;
698
732
  }
699
-
700
733
  current = current.parent;
701
734
  }
702
735
 
@@ -101,12 +101,11 @@ export class SelectionManager {
101
101
  // -------------------------------------------------------------------------
102
102
 
103
103
  private canvasPoint(e: MouseEvent): Point {
104
+ // Logical CSS pixels — same convention as `events.ts#getCanvasCoordinates`.
104
105
  const rect = this.canvas.getBoundingClientRect();
105
- const scaleX = this.canvas.width / rect.width;
106
- const scaleY = this.canvas.height / rect.height;
107
106
  return {
108
- x: (e.clientX - rect.left) * scaleX,
109
- y: (e.clientY - rect.top) * scaleY,
107
+ x: e.clientX - rect.left,
108
+ y: e.clientY - rect.top,
110
109
  };
111
110
  }
112
111
 
@@ -6,6 +6,65 @@
6
6
 
7
7
  import type { BoxSpacing, Rectangle, Point, VirtualNode } from "./types.js";
8
8
 
9
+ // Pixel equivalent for 1rem (and 1em, which we approximate the same without a
10
+ // real inheritance chain). The engine emits Tailwind values — `0.75rem`,
11
+ // `1rem`, `3.5rem` — and any earlier `parseFloat` path was stripping the unit
12
+ // and treating `3.5rem` as 3.5px, collapsing whole layouts to 1/16 size.
13
+ const ROOT_FONT_PX = 16;
14
+
15
+ /**
16
+ * Parse a CSS length string (or number) into pixels.
17
+ * Returns `null` for `"auto"` or unparseable input; returns `null` for `%`
18
+ * values so callers can decide whether to keep the percentage verbatim or
19
+ * fall back to 0.
20
+ */
21
+ export function cssLengthToPx(value: any): number | null {
22
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
23
+ if (typeof value !== "string") return null;
24
+ const s = value.trim();
25
+ if (s === "" || s === "auto") return null;
26
+ if (s.endsWith("%")) return null;
27
+ const match = s.match(/^(-?\d*\.?\d+)\s*([a-zA-Z]+)?$/);
28
+ if (!match) return null;
29
+ const n = parseFloat(match[1]);
30
+ if (!Number.isFinite(n)) return null;
31
+ const unit = (match[2] || "px").toLowerCase();
32
+ switch (unit) {
33
+ case "px": return n;
34
+ case "rem":
35
+ case "em": return n * ROOT_FONT_PX;
36
+ // We don't have a viewport reference here; treat vw/vh as unitless px
37
+ // fallback so the layout gets _some_ numeric value rather than zero.
38
+ case "vw":
39
+ case "vh": return n;
40
+ // Point/pica — rarely seen from the engine but honoured for completeness.
41
+ case "pt": return n * (96 / 72);
42
+ case "pc": return n * 16;
43
+ case "in": return n * 96;
44
+ case "cm": return n * (96 / 2.54);
45
+ case "mm": return n * (96 / 25.4);
46
+ default: return n;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Parse a CSS length string for Taffy's Dimension type. Keeps `%` values as
52
+ * the tagged percentage string, turns `"auto"`/empty/invalid into `"auto"`,
53
+ * converts everything else (including rem/em) through `cssLengthToPx`.
54
+ */
55
+ export function cssLengthToDimension(value: any): "auto" | number | `${number}%` {
56
+ if (value === undefined || value === null || value === "auto") return "auto";
57
+ if (typeof value === "number") return Number.isFinite(value) ? value : "auto";
58
+ if (typeof value === "string") {
59
+ const s = value.trim();
60
+ if (s === "" || s === "auto") return "auto";
61
+ if (s.endsWith("%")) return s as `${number}%`;
62
+ const px = cssLengthToPx(s);
63
+ return px !== null ? px : "auto";
64
+ }
65
+ return "auto";
66
+ }
67
+
9
68
  /**
10
69
  * Parse spacing value (margin, padding).
11
70
  *
@@ -22,7 +81,7 @@ export function parseSpacing(value: any): BoxSpacing {
22
81
  }
23
82
 
24
83
  if (typeof value === "string") {
25
- const parts = value.split(/\s+/).map((v) => parseFloat(v) || 0);
84
+ const parts = value.split(/\s+/).map((v) => cssLengthToPx(v) ?? 0);
26
85
  return spacingFromArray(parts);
27
86
  }
28
87
 
@@ -35,17 +94,17 @@ export function parseSpacing(value: any): BoxSpacing {
35
94
  value.left !== undefined
36
95
  ) {
37
96
  return {
38
- top: parseFloat(value.top) || 0,
39
- right: parseFloat(value.right) || 0,
40
- bottom: parseFloat(value.bottom) || 0,
41
- left: parseFloat(value.left) || 0,
97
+ top: cssLengthToPx(value.top) ?? 0,
98
+ right: cssLengthToPx(value.right) ?? 0,
99
+ bottom: cssLengthToPx(value.bottom) ?? 0,
100
+ left: cssLengthToPx(value.left) ?? 0,
42
101
  };
43
102
  }
44
103
 
45
104
  // Positional form: walk contiguous "0", "1", … keys.
46
105
  const args: number[] = [];
47
106
  for (let i = 0; value[String(i)] !== undefined; i++) {
48
- args.push(parseFloat(value[String(i)]) || 0);
107
+ args.push(cssLengthToPx(value[String(i)]) ?? 0);
49
108
  }
50
109
  if (args.length > 0) {
51
110
  return spacingFromArray(args);
@@ -71,17 +130,12 @@ function spacingFromArray(parts: number[]): BoxSpacing {
71
130
  }
72
131
 
73
132
  /**
74
- * Parse size value (width, height)
75
- * Returns pixel value or null for "auto"
133
+ * Parse size value (width, height) into pixels.
134
+ * Returns `null` for `"auto"`, percentages, or unparseable input — callers
135
+ * that want to keep the percentage form should use `cssLengthToDimension`.
76
136
  */
77
137
  export function parseSize(value: any): number | null {
78
- if (typeof value === "number") return value;
79
- if (typeof value === "string") {
80
- if (value === "auto") return null;
81
- const num = parseFloat(value);
82
- return isNaN(num) ? null : num;
83
- }
84
- return null;
138
+ return cssLengthToPx(value);
85
139
  }
86
140
 
87
141
  /**
@@ -109,8 +163,12 @@ export function isPointInRoundedRect(
109
163
  // Quick reject if outside bounding box
110
164
  if (!isPointInRect(point, rect)) return false;
111
165
 
112
- // No radius means simple rectangle
166
+ // No radius means simple rectangle. Clamp the radius to the box, since
167
+ // Tailwind's `rounded-full` arrives as 9999 — without clamping, the
168
+ // corner-test math thinks the corners are far outside the box and
169
+ // mis-classifies hits.
113
170
  if (radius <= 0) return true;
171
+ radius = Math.min(radius, width / 2, height / 2);
114
172
 
115
173
  const px = point.x;
116
174
  const py = point.y;
@@ -212,25 +270,19 @@ export function findNodeById(root: VirtualNode, id: string): VirtualNode | null
212
270
  }
213
271
 
214
272
  /**
215
- * Get absolute bounds of node (including transformations)
273
+ * Get absolute bounds of a node. `node.layout.{x,y}` is already absolute
274
+ * (the layout pass writes parent.absX + child.x in both the Taffy and JS
275
+ * fallback paths), so this is a thin wrapper for callers that work in
276
+ * `Rectangle` shape.
277
+ *
278
+ * Use `getScrollAwareBounds` (in `scroll.ts`) when ancestor scroll offsets
279
+ * matter — e.g. for hit testing.
216
280
  */
217
281
  export function getAbsoluteBounds(node: VirtualNode): Rectangle | null {
218
282
  if (!node.layout) return null;
219
-
220
- let x = node.layout.x;
221
- let y = node.layout.y;
222
-
223
- // Walk up tree to accumulate offsets
224
- let current = node.parent;
225
- while (current && current.layout) {
226
- x += current.layout.contentX;
227
- y += current.layout.contentY;
228
- current = current.parent;
229
- }
230
-
231
283
  return {
232
- x,
233
- y,
284
+ x: node.layout.x,
285
+ y: node.layout.y,
234
286
  width: node.layout.width,
235
287
  height: node.layout.height,
236
288
  };
@@ -44,6 +44,15 @@ export const borderHandlers: Record<string, ApplicatorHandler> = {
44
44
 
45
45
  borderWidth: (el, value) => {
46
46
  el.style.borderWidth = typeof value === "number" ? `${value}px` : String(value);
47
+ // CSS `border-style` default is `none`, so a width-only border draws
48
+ // nothing. The Hypen DSL writes `.borderWidth(2).borderColor(...)`
49
+ // expecting a visible stroke (the social Stories ring + the Profile
50
+ // page Edit button). Override `none` too — the Button component
51
+ // resets `border: none` at create time, so the inherited
52
+ // border-style is "none" by the time this applicator lands.
53
+ if (!el.style.borderStyle || el.style.borderStyle === "none") {
54
+ el.style.borderStyle = "solid";
55
+ }
47
56
  },
48
57
 
49
58
  borderStyle: (el, value) => {
@@ -89,7 +89,22 @@ export const fontHandlers: Record<string, ApplicatorHandler> = {
89
89
  },
90
90
 
91
91
  textAlign: (el, value) => {
92
- el.style.textAlign = String(value);
92
+ const v = String(value);
93
+ el.style.textAlign = v;
94
+ // `text-align: center | right | end | justify` only takes visible
95
+ // effect when the box is wider than the text. Hypen's Text defaults
96
+ // to `inline-block` (shrink-to-fit), so without this an author
97
+ // writing `Text("Edit Profile").tw("text-center")` saw the same
98
+ // left-flush text whether they set `text-center` or not (the
99
+ // social Profile page Edit button). Stretch the box across its
100
+ // flex parent and switch to block so the alignment has room to act.
101
+ if (v === "center" || v === "right" || v === "end" || v === "justify") {
102
+ if (!el.style.alignSelf) el.style.alignSelf = "stretch";
103
+ if (!el.style.width) el.style.width = "100%";
104
+ if (el.style.display === "inline-block" || !el.style.display) {
105
+ el.style.display = "block";
106
+ }
107
+ }
93
108
  },
94
109
 
95
110
  lineHeight: (el, value) => {
@@ -104,14 +104,24 @@ export const layoutHandlers: Record<string, ApplicatorHandler> = {
104
104
  // Use .weight(1) to make element take remaining space in Row/Column
105
105
  weight: (el, value) => {
106
106
  el.style.flex = String(value);
107
- // Mark element as having flex for CSS parent detection
107
+ // CSS sets `min-width/min-height: auto` (= min-content) on flex items by
108
+ // default, so a flex-1 child can never shrink below its intrinsic
109
+ // content size — meaning a 20,000-tall feed inside a 900px viewport
110
+ // overflowed because HomePage's `flex-1` couldn't shrink. The classic
111
+ // CSS workaround is `min-*: 0`. Apply it here so authors don't need to
112
+ // remember; explicit user `minWidth`/`minHeight` overrides it (the
113
+ // applicator runs after `weight`/`flex`).
114
+ if (!el.style.minWidth) el.style.minWidth = "0";
115
+ if (!el.style.minHeight) el.style.minHeight = "0";
108
116
  el.dataset.hypenFlex = "true";
109
117
  },
110
118
 
111
119
  // flex: CSS flex shorthand (kept for CSS compatibility)
112
120
  flex: (el, value) => {
113
121
  el.style.flex = String(value);
114
- // Mark element as having flex for CSS parent detection
122
+ // See note on `weight` above — flex children need `min-*: 0` to shrink.
123
+ if (!el.style.minWidth) el.style.minWidth = "0";
124
+ if (!el.style.minHeight) el.style.minHeight = "0";
115
125
  el.dataset.hypenFlex = "true";
116
126
  },
117
127
 
@@ -132,18 +142,35 @@ export const layoutHandlers: Record<string, ApplicatorHandler> = {
132
142
  },
133
143
 
134
144
  scrollable: (el, value) => {
135
- if (value === true || value === "true") {
145
+ // CSS spec: setting `overflow-x` to a non-visible value forces
146
+ // `overflow-y: auto` (and vice versa) — you can't mix `visible` with
147
+ // a scrolling axis. That combo makes the element a scroll container
148
+ // on both axes, and flex items with overflow get `min-height: auto`
149
+ // collapsed to 0, so a `scrollable("horizontal")` row inside a
150
+ // flex-column parent (the social Stories carousel) shrank to just
151
+ // its padding (18 tall) instead of hugging its 92-tall avatars. We
152
+ // pin `min-height`/`min-width` to `fit-content` on the non-scroll
153
+ // axis so the intrinsic size survives.
154
+ if (value === true || value === "true" || value === "both") {
136
155
  el.style.overflow = "auto";
137
156
  } else if (value === false || value === "false") {
138
157
  el.style.overflow = "hidden";
139
158
  } else if (value === "vertical") {
140
- el.style.overflowX = "hidden";
141
159
  el.style.overflowY = "auto";
160
+ if (!el.style.minWidth) el.style.minWidth = "fit-content";
142
161
  } else if (value === "horizontal") {
143
162
  el.style.overflowX = "auto";
144
- el.style.overflowY = "hidden";
145
- } else if (value === "both") {
146
- el.style.overflow = "auto";
163
+ if (!el.style.minHeight) el.style.minHeight = "fit-content";
164
+ // A flex-column parent with `align-items: flex-start` (our Column
165
+ // default, matching iOS/Android "wrap to content") leaves a child
166
+ // row at its natural width — so a 5×96 carousel was 498 wide inside
167
+ // a 470 viewport and the whole page scrolled horizontally. Force
168
+ // the scroll container to fit the parent's cross-axis and clamp at
169
+ // that width so the overflow is trapped inside.
170
+ if (!el.style.alignSelf) el.style.alignSelf = "stretch";
171
+ if (!el.style.width) el.style.width = "100%";
172
+ if (!el.style.maxWidth) el.style.maxWidth = "100%";
173
+ if (!el.style.minWidth) el.style.minWidth = "0";
147
174
  } else {
148
175
  el.style.overflow = String(value);
149
176
  }
@@ -4,33 +4,13 @@
4
4
 
5
5
  import type { ApplicatorHandler } from "./types.js";
6
6
 
7
- // Helper to extract numeric value from applicator value
8
- const getNumericValue = (value: any): number | null => {
9
- if (typeof value === "number") return value;
10
- if (typeof value === "object" && value["0"] !== undefined) return Number(value["0"]);
11
- if (typeof value === "string") return parseFloat(value);
12
- return null;
13
- };
14
-
15
- // Extract a CSS length value, supporting numbers, strings ("auto", "10px",
16
- // percentages), or `{0: value}` positional form. Returns null if no value.
17
- const getCssLengthValue = (value: any): string | null => {
18
- if (value === null || value === undefined) return null;
19
- if (typeof value === "number") return `${value}px`;
20
- if (typeof value === "string") return value;
21
- if (typeof value === "object") {
22
- if (value["0"] !== undefined) {
23
- const inner = value["0"];
24
- if (typeof inner === "number") return `${inner}px`;
25
- if (typeof inner === "string") return inner;
26
- }
27
- }
28
- return null;
29
- };
30
-
31
- // Format a value as a CSS length: numbers become "Npx", strings pass through
7
+ // Format a value as a CSS length: numbers become "Npx", strings pass through.
8
+ // Same caveat as `padding.ts#toCssLength`: rem/em/% values must survive,
9
+ // otherwise `parseFloat("0.5rem")` collapses to literal `0.5px`.
32
10
  const toCssLength = (v: any): string => {
11
+ if (v == null) return "";
33
12
  if (typeof v === "number") return `${v}px`;
13
+ if (typeof v === "object" && v["0"] !== undefined) return toCssLength(v["0"]);
34
14
  return String(v);
35
15
  };
36
16
 
@@ -77,38 +57,31 @@ export const marginHandler: ApplicatorHandler = (el, value) => {
77
57
  };
78
58
 
79
59
  // Directional margin handlers for .marginTop(8), .marginBottom(8), etc.
60
+ // All routed through `toCssLength` so `rem` / `em` / `%` units survive.
80
61
  export const marginTopHandler: ApplicatorHandler = (el, value) => {
81
- const v = getNumericValue(value);
82
- if (v !== null) el.style.marginTop = `${v}px`;
62
+ el.style.marginTop = toCssLength(value);
83
63
  };
84
64
 
85
65
  export const marginBottomHandler: ApplicatorHandler = (el, value) => {
86
- const v = getNumericValue(value);
87
- if (v !== null) el.style.marginBottom = `${v}px`;
66
+ el.style.marginBottom = toCssLength(value);
88
67
  };
89
68
 
90
69
  export const marginLeftHandler: ApplicatorHandler = (el, value) => {
91
- const v = getNumericValue(value);
92
- if (v !== null) el.style.marginLeft = `${v}px`;
70
+ el.style.marginLeft = toCssLength(value);
93
71
  };
94
72
 
95
73
  export const marginRightHandler: ApplicatorHandler = (el, value) => {
96
- const v = getNumericValue(value);
97
- if (v !== null) el.style.marginRight = `${v}px`;
74
+ el.style.marginRight = toCssLength(value);
98
75
  };
99
76
 
100
77
  export const marginHorizontalHandler: ApplicatorHandler = (el, value) => {
101
- const v = getNumericValue(value);
102
- if (v !== null) {
103
- el.style.marginLeft = `${v}px`;
104
- el.style.marginRight = `${v}px`;
105
- }
78
+ const css = toCssLength(value);
79
+ el.style.marginLeft = css;
80
+ el.style.marginRight = css;
106
81
  };
107
82
 
108
83
  export const marginVerticalHandler: ApplicatorHandler = (el, value) => {
109
- const v = getNumericValue(value);
110
- if (v !== null) {
111
- el.style.marginTop = `${v}px`;
112
- el.style.marginBottom = `${v}px`;
113
- }
84
+ const css = toCssLength(value);
85
+ el.style.marginTop = css;
86
+ el.style.marginBottom = css;
114
87
  };
@@ -4,17 +4,16 @@
4
4
 
5
5
  import type { ApplicatorHandler } from "./types.js";
6
6
 
7
- // Helper to extract numeric value from applicator value
8
- const getNumericValue = (value: any): number | null => {
9
- if (typeof value === "number") return value;
10
- if (typeof value === "object" && value["0"] !== undefined) return Number(value["0"]);
11
- if (typeof value === "string") return parseFloat(value);
12
- return null;
13
- };
14
-
15
- // Format a value as a CSS length: numbers become "Npx", strings pass through
7
+ // Format a value as a CSS length: numbers become "Npx", strings pass through.
8
+ // Strings come through verbatim because the Tailwind→CSS pipeline emits
9
+ // values like `"0.5rem"` (= py-2) or `"1rem"` (= px-4); stripping the unit
10
+ // via `parseFloat` collapsed them to literal `0.5px` / `1px` and the
11
+ // social feed rendered with no usable spacing (avatars touched the
12
+ // container edges, the BottomNav fell off-screen, etc.).
16
13
  const toCssLength = (v: any): string => {
14
+ if (v == null) return "";
17
15
  if (typeof v === "number") return `${v}px`;
16
+ if (typeof v === "object" && v["0"] !== undefined) return toCssLength(v["0"]);
18
17
  return String(v);
19
18
  };
20
19
 
@@ -65,38 +64,31 @@ export const paddingHandler: ApplicatorHandler = (el, value) => {
65
64
  };
66
65
 
67
66
  // Directional padding handlers for .paddingTop(8), .paddingBottom(8), etc.
67
+ // All routed through `toCssLength` so `rem` / `em` / `%` units survive.
68
68
  export const paddingTopHandler: ApplicatorHandler = (el, value) => {
69
- const v = getNumericValue(value);
70
- if (v !== null) el.style.paddingTop = `${v}px`;
69
+ el.style.paddingTop = toCssLength(value);
71
70
  };
72
71
 
73
72
  export const paddingBottomHandler: ApplicatorHandler = (el, value) => {
74
- const v = getNumericValue(value);
75
- if (v !== null) el.style.paddingBottom = `${v}px`;
73
+ el.style.paddingBottom = toCssLength(value);
76
74
  };
77
75
 
78
76
  export const paddingLeftHandler: ApplicatorHandler = (el, value) => {
79
- const v = getNumericValue(value);
80
- if (v !== null) el.style.paddingLeft = `${v}px`;
77
+ el.style.paddingLeft = toCssLength(value);
81
78
  };
82
79
 
83
80
  export const paddingRightHandler: ApplicatorHandler = (el, value) => {
84
- const v = getNumericValue(value);
85
- if (v !== null) el.style.paddingRight = `${v}px`;
81
+ el.style.paddingRight = toCssLength(value);
86
82
  };
87
83
 
88
84
  export const paddingHorizontalHandler: ApplicatorHandler = (el, value) => {
89
- const v = getNumericValue(value);
90
- if (v !== null) {
91
- el.style.paddingLeft = `${v}px`;
92
- el.style.paddingRight = `${v}px`;
93
- }
85
+ const css = toCssLength(value);
86
+ el.style.paddingLeft = css;
87
+ el.style.paddingRight = css;
94
88
  };
95
89
 
96
90
  export const paddingVerticalHandler: ApplicatorHandler = (el, value) => {
97
- const v = getNumericValue(value);
98
- if (v !== null) {
99
- el.style.paddingTop = `${v}px`;
100
- el.style.paddingBottom = `${v}px`;
101
- }
91
+ const css = toCssLength(value);
92
+ el.style.paddingTop = css;
93
+ el.style.paddingBottom = css;
102
94
  };
@@ -6,7 +6,17 @@ import type { ApplicatorHandler } from "./types.js";
6
6
 
7
7
  export const typographyHandlers: Record<string, ApplicatorHandler> = {
8
8
  textAlign: (el, value) => {
9
- el.style.textAlign = String(value);
9
+ const v = String(value);
10
+ el.style.textAlign = v;
11
+ // Mirror `font.ts#textAlign`: stretch + block so non-left alignments
12
+ // actually have room to act in a shrink-to-fit `Text` element.
13
+ if (v === "center" || v === "right" || v === "end" || v === "justify") {
14
+ if (!el.style.alignSelf) el.style.alignSelf = "stretch";
15
+ if (!el.style.width) el.style.width = "100%";
16
+ if (el.style.display === "inline-block" || !el.style.display) {
17
+ el.style.display = "block";
18
+ }
19
+ }
10
20
  },
11
21
 
12
22
  textTransform: (el, value) => {