@hypen-space/web 0.4.980 → 0.5.2

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 (169) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/events.js +46 -7
  3. package/dist/canvas/events.js.map +1 -1
  4. package/dist/canvas/index.d.ts +0 -1
  5. package/dist/canvas/index.js +0 -1
  6. package/dist/canvas/index.js.map +1 -1
  7. package/dist/canvas/layout.js +68 -98
  8. package/dist/canvas/layout.js.map +1 -1
  9. package/dist/canvas/paint.js +168 -71
  10. package/dist/canvas/paint.js.map +1 -1
  11. package/dist/canvas/renderer.d.ts +8 -3
  12. package/dist/canvas/renderer.js +32 -43
  13. package/dist/canvas/renderer.js.map +1 -1
  14. package/dist/canvas/text.js +2 -9
  15. package/dist/canvas/text.js.map +1 -1
  16. package/dist/canvas/types.d.ts +3 -0
  17. package/dist/canvas/utils.d.ts +0 -23
  18. package/dist/canvas/utils.js +3 -67
  19. package/dist/canvas/utils.js.map +1 -1
  20. package/dist/canvas/variants.d.ts +49 -0
  21. package/dist/canvas/variants.js +137 -0
  22. package/dist/canvas/variants.js.map +1 -0
  23. package/dist/dom/applicators/events.js +1 -0
  24. package/dist/dom/applicators/events.js.map +1 -1
  25. package/dist/dom/applicators/font.d.ts +11 -6
  26. package/dist/dom/applicators/font.js +16 -27
  27. package/dist/dom/applicators/font.js.map +1 -1
  28. package/dist/dom/applicators/index.js +47 -45
  29. package/dist/dom/applicators/index.js.map +1 -1
  30. package/dist/dom/canvas/index.js +2 -2
  31. package/dist/dom/canvas/index.js.map +1 -1
  32. package/dist/dom/components/app.js +2 -2
  33. package/dist/dom/components/app.js.map +1 -1
  34. package/dist/dom/components/audio.js +2 -2
  35. package/dist/dom/components/audio.js.map +1 -1
  36. package/dist/dom/components/avatar.js +3 -4
  37. package/dist/dom/components/avatar.js.map +1 -1
  38. package/dist/dom/components/badge.js +3 -4
  39. package/dist/dom/components/badge.js.map +1 -1
  40. package/dist/dom/components/button.js +2 -2
  41. package/dist/dom/components/button.js.map +1 -1
  42. package/dist/dom/components/card.js +2 -2
  43. package/dist/dom/components/card.js.map +1 -1
  44. package/dist/dom/components/center.js +2 -2
  45. package/dist/dom/components/center.js.map +1 -1
  46. package/dist/dom/components/checkbox.js +4 -5
  47. package/dist/dom/components/checkbox.js.map +1 -1
  48. package/dist/dom/components/column.js +2 -2
  49. package/dist/dom/components/column.js.map +1 -1
  50. package/dist/dom/components/container.js +2 -2
  51. package/dist/dom/components/container.js.map +1 -1
  52. package/dist/dom/components/divider.js +2 -2
  53. package/dist/dom/components/divider.js.map +1 -1
  54. package/dist/dom/components/grid.js +10 -10
  55. package/dist/dom/components/grid.js.map +1 -1
  56. package/dist/dom/components/heading.js +4 -6
  57. package/dist/dom/components/heading.js.map +1 -1
  58. package/dist/dom/components/hypenapp.js +2 -2
  59. package/dist/dom/components/hypenapp.js.map +1 -1
  60. package/dist/dom/components/icon.js +4 -5
  61. package/dist/dom/components/icon.js.map +1 -1
  62. package/dist/dom/components/image.js +2 -2
  63. package/dist/dom/components/image.js.map +1 -1
  64. package/dist/dom/components/index.d.ts +2 -2
  65. package/dist/dom/components/index.js +2 -2
  66. package/dist/dom/components/index.js.map +1 -1
  67. package/dist/dom/components/input.js +2 -2
  68. package/dist/dom/components/input.js.map +1 -1
  69. package/dist/dom/components/link.js +2 -2
  70. package/dist/dom/components/link.js.map +1 -1
  71. package/dist/dom/components/list.js +2 -2
  72. package/dist/dom/components/list.js.map +1 -1
  73. package/dist/dom/components/paragraph.js +3 -4
  74. package/dist/dom/components/paragraph.js.map +1 -1
  75. package/dist/dom/components/progressbar.js +3 -3
  76. package/dist/dom/components/progressbar.js.map +1 -1
  77. package/dist/dom/components/route.js +2 -2
  78. package/dist/dom/components/route.js.map +1 -1
  79. package/dist/dom/components/router.js +2 -2
  80. package/dist/dom/components/router.js.map +1 -1
  81. package/dist/dom/components/row.js +10 -10
  82. package/dist/dom/components/row.js.map +1 -1
  83. package/dist/dom/components/select.js +3 -4
  84. package/dist/dom/components/select.js.map +1 -1
  85. package/dist/dom/components/slider.js +2 -2
  86. package/dist/dom/components/slider.js.map +1 -1
  87. package/dist/dom/components/spacer.js +2 -2
  88. package/dist/dom/components/spacer.js.map +1 -1
  89. package/dist/dom/components/spinner.js +4 -4
  90. package/dist/dom/components/spinner.js.map +1 -1
  91. package/dist/dom/components/stack.js +10 -10
  92. package/dist/dom/components/stack.js.map +1 -1
  93. package/dist/dom/components/switch.js +5 -6
  94. package/dist/dom/components/switch.js.map +1 -1
  95. package/dist/dom/components/text.js +4 -7
  96. package/dist/dom/components/text.js.map +1 -1
  97. package/dist/dom/components/textarea.js +2 -2
  98. package/dist/dom/components/textarea.js.map +1 -1
  99. package/dist/dom/components/video.js +2 -2
  100. package/dist/dom/components/video.js.map +1 -1
  101. package/dist/dom/debug.js +6 -8
  102. package/dist/dom/debug.js.map +1 -1
  103. package/dist/dom/index.d.ts +0 -1
  104. package/dist/dom/index.js +0 -1
  105. package/dist/dom/index.js.map +1 -1
  106. package/dist/dom/renderer.js +2 -3
  107. package/dist/dom/renderer.js.map +1 -1
  108. package/dist/variants.d.ts +90 -0
  109. package/dist/variants.js +206 -0
  110. package/dist/variants.js.map +1 -0
  111. package/package.json +2 -2
  112. package/src/canvas/QUICKSTART.md +12 -7
  113. package/src/canvas/README.md +8 -5
  114. package/src/canvas/events.ts +49 -7
  115. package/src/canvas/index.ts +0 -8
  116. package/src/canvas/layout.ts +68 -103
  117. package/src/canvas/paint.ts +172 -69
  118. package/src/canvas/renderer.ts +38 -46
  119. package/src/canvas/text.ts +2 -9
  120. package/src/canvas/types.ts +14 -0
  121. package/src/canvas/utils.ts +3 -69
  122. package/src/canvas/variants.ts +153 -0
  123. package/src/dom/README.md +8 -7
  124. package/src/dom/applicators/events.ts +1 -0
  125. package/src/dom/applicators/font.ts +16 -29
  126. package/src/dom/applicators/index.ts +56 -46
  127. package/src/dom/canvas/index.ts +2 -2
  128. package/src/dom/components/app.ts +2 -2
  129. package/src/dom/components/audio.ts +2 -2
  130. package/src/dom/components/avatar.ts +3 -4
  131. package/src/dom/components/badge.ts +3 -4
  132. package/src/dom/components/button.ts +2 -2
  133. package/src/dom/components/card.ts +2 -2
  134. package/src/dom/components/center.ts +2 -2
  135. package/src/dom/components/checkbox.ts +4 -5
  136. package/src/dom/components/column.ts +2 -2
  137. package/src/dom/components/container.ts +2 -2
  138. package/src/dom/components/divider.ts +2 -2
  139. package/src/dom/components/grid.ts +10 -10
  140. package/src/dom/components/heading.ts +4 -6
  141. package/src/dom/components/hypenapp.ts +2 -2
  142. package/src/dom/components/icon.ts +4 -5
  143. package/src/dom/components/image.ts +2 -2
  144. package/src/dom/components/index.ts +3 -3
  145. package/src/dom/components/input.ts +2 -2
  146. package/src/dom/components/link.ts +2 -2
  147. package/src/dom/components/list.ts +2 -2
  148. package/src/dom/components/paragraph.ts +3 -4
  149. package/src/dom/components/progressbar.ts +3 -3
  150. package/src/dom/components/route.ts +2 -2
  151. package/src/dom/components/router.ts +2 -2
  152. package/src/dom/components/row.ts +10 -10
  153. package/src/dom/components/select.ts +3 -4
  154. package/src/dom/components/slider.ts +2 -2
  155. package/src/dom/components/spacer.ts +2 -2
  156. package/src/dom/components/spinner.ts +4 -4
  157. package/src/dom/components/stack.ts +10 -10
  158. package/src/dom/components/switch.ts +5 -6
  159. package/src/dom/components/text.ts +4 -7
  160. package/src/dom/components/textarea.ts +2 -2
  161. package/src/dom/components/video.ts +2 -2
  162. package/src/dom/debug.ts +6 -8
  163. package/src/dom/index.ts +0 -8
  164. package/src/dom/renderer.ts +2 -3
  165. package/src/variants.ts +249 -0
  166. package/dist/client.d.ts +0 -36
  167. package/dist/client.js +0 -47
  168. package/dist/client.js.map +0 -1
  169. package/src/client.ts +0 -88
@@ -30,7 +30,7 @@ bun add @hypen-space/core @hypen-space/web @hypen-space/web-engine
30
30
  ```typescript
31
31
  import { app, HypenModuleInstance } from "@hypen-space/core";
32
32
  import { Engine } from "@hypen-space/web-engine";
33
- import { createHypenClient } from "@hypen-space/web/canvas";
33
+ import { CanvasRenderer } from "@hypen-space/web/canvas";
34
34
 
35
35
  // Define state
36
36
  type AppState = {
@@ -54,12 +54,17 @@ async function main() {
54
54
  const engine = new Engine();
55
55
  await engine.init();
56
56
 
57
- // Create renderer + patch subscription in one call
58
- createHypenClient(canvas, engine, {
57
+ // Create renderer
58
+ const renderer = new CanvasRenderer(canvas, engine, {
59
59
  devicePixelRatio: window.devicePixelRatio,
60
60
  backgroundColor: "#ffffff",
61
61
  });
62
62
 
63
+ // Connect renderer
64
+ engine.setRenderCallback((patches) => {
65
+ renderer.applyPatches(patches);
66
+ });
67
+
63
68
  // Create module
64
69
  const instance = new HypenModuleInstance(engine, appModule);
65
70
 
@@ -244,7 +249,7 @@ Container {
244
249
  ## Configuration Options
245
250
 
246
251
  ```typescript
247
- const { renderer } = createHypenClient(canvas, engine, {
252
+ const renderer = new CanvasRenderer(canvas, engine, {
248
253
  // Display
249
254
  devicePixelRatio: window.devicePixelRatio, // HiDPI support
250
255
  backgroundColor: "#ffffff", // Canvas background
@@ -349,7 +354,7 @@ let timeoutId: number;
349
354
  ### 3. Measure Performance
350
355
 
351
356
  ```typescript
352
- const { renderer } = createHypenClient(canvas, engine, {
357
+ const renderer = new CanvasRenderer(canvas, engine, {
353
358
  logPerformance: true, // Logs FPS and frame times
354
359
  });
355
360
  ```
@@ -361,7 +366,7 @@ const { renderer } = createHypenClient(canvas, engine, {
361
366
  Make sure to set `devicePixelRatio`:
362
367
 
363
368
  ```typescript
364
- const { renderer } = createHypenClient(canvas, engine, {
369
+ const renderer = new CanvasRenderer(canvas, engine, {
365
370
  devicePixelRatio: window.devicePixelRatio,
366
371
  });
367
372
  ```
@@ -371,7 +376,7 @@ const { renderer } = createHypenClient(canvas, engine, {
371
376
  Ensure `enableInputOverlay` is true:
372
377
 
373
378
  ```typescript
374
- const { renderer } = createHypenClient(canvas, engine, {
379
+ const renderer = new CanvasRenderer(canvas, engine, {
375
380
  enableInputOverlay: true,
376
381
  });
377
382
  ```
@@ -13,12 +13,10 @@ The Canvas Renderer is a complete alternative to the DOM renderer that draws all
13
13
 
14
14
  ## Quick Start
15
15
 
16
- `createHypenClient` is the recommended one-call wiring (constructs the renderer and subscribes to engine patches). `CanvasRenderer` is still exported directly for advanced setups.
17
-
18
16
  ```typescript
19
17
  import { app } from "@hypen-space/core";
20
18
  import { Engine } from "@hypen-space/web-engine";
21
- import { createHypenClient } from "@hypen-space/web/canvas";
19
+ import { CanvasRenderer } from "@hypen-space/web/canvas";
22
20
 
23
21
  // Setup canvas
24
22
  const canvas = document.getElementById("app") as HTMLCanvasElement;
@@ -27,8 +25,13 @@ const canvas = document.getElementById("app") as HTMLCanvasElement;
27
25
  const engine = new Engine();
28
26
  await engine.init();
29
27
 
30
- // Create canvas renderer + patch subscription in one call
31
- createHypenClient(canvas, engine);
28
+ // Create canvas renderer
29
+ const renderer = new CanvasRenderer(canvas, engine);
30
+
31
+ // Set render callback
32
+ engine.setRenderCallback((patches) => {
33
+ renderer.applyPatches(patches);
34
+ });
32
35
 
33
36
  // Render UI
34
37
  await engine.renderSource(`
@@ -14,6 +14,21 @@ interface IEngine {
14
14
  dispatchAction(name: string, payload?: any): void;
15
15
  }
16
16
 
17
+ // Maps a DOM event type to the applicator prop names the engine may have set
18
+ // on a node, in priority order. Multi-word events (`mouseenter`) need their
19
+ // proper camelCase form (`onMouseEnter`) since the engine emits applicator
20
+ // names verbatim. `mouseenter` also accepts `onHover` as an alias.
21
+ const CANVAS_EVENT_PROP_NAMES: Record<string, string[]> = {
22
+ mouseenter: ["onMouseEnter", "onHover", "onmouseenter", "mouseenter"],
23
+ mouseleave: ["onMouseLeave", "onmouseleave", "mouseleave"],
24
+ mousedown: ["onMouseDown", "onmousedown", "mousedown"],
25
+ mouseup: ["onMouseUp", "onmouseup", "mouseup"],
26
+ dblclick: ["onDblClick", "onDoubleClick", "ondblclick", "dblclick"],
27
+ contextmenu: ["onContextMenu", "oncontextmenu", "contextmenu"],
28
+ keydown: ["onKeyDown", "onkeydown", "keydown"],
29
+ keyup: ["onKeyUp", "onkeyup", "keyup"],
30
+ };
31
+
17
32
  /**
18
33
  * Canvas Event Manager
19
34
  */
@@ -210,6 +225,13 @@ export class CanvasEventManager {
210
225
 
211
226
  this.mouseDownNode = node;
212
227
 
228
+ // Track pressed (`:active`) state so paint-time `:active` variants resolve.
229
+ // Repaint so the active style appears immediately on press.
230
+ if (node) {
231
+ node.pressed = true;
232
+ this.requestRedraw();
233
+ }
234
+
213
235
  if (node && node.clickable) {
214
236
  this.dispatchNodeEvent(node, "mousedown", {
215
237
  button: e.button,
@@ -234,6 +256,13 @@ export class CanvasEventManager {
234
256
  const hit = this.hitTest(point);
235
257
  const node = this.findClickableAncestor(hit) ?? hit;
236
258
 
259
+ // Clear the pressed (`:active`) flag from the node that was pressed —
260
+ // release ends `:active` even if the pointer drifted off the node first.
261
+ if (this.mouseDownNode && this.mouseDownNode.pressed) {
262
+ this.mouseDownNode.pressed = false;
263
+ this.requestRedraw();
264
+ }
265
+
237
266
  if (node && node.clickable) {
238
267
  this.dispatchNodeEvent(node, "mouseup", {
239
268
  button: e.button,
@@ -383,13 +412,25 @@ export class CanvasEventManager {
383
412
  * Dispatch event to engine
384
413
  */
385
414
  private dispatchNodeEvent(node: VirtualNode, eventType: string, data: any): void {
386
- // Engine emits event applicators in camelCase (`onClick`, `onChange`). The
387
- // older flat form `onclick` is still accepted. After prop normalisation the
388
- // value is either a string (action name) or an object carrying an action
389
- // name at `"0"` plus an auxiliary payload (e.g. `{ "0": "@router.push",
390
- // to: "/notifications" }` for `@router.push(to: "/notifications")`).
391
- const camel = `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`;
392
- let spec: unknown = node.props[camel] ?? node.props[`on${eventType}`] ?? node.props[eventType];
415
+ // Engine emits event applicators in camelCase (`onClick`, `onMouseEnter`).
416
+ // Multi-word DOM events like `mouseenter` must map to `onMouseEnter`, not
417
+ // the naive `onMouseenter` that `on${capitalize(eventType)}` would produce.
418
+ // The older flat form `onclick`/`onmouseenter` is still accepted. After
419
+ // prop normalisation the value is either a string (action name) or an
420
+ // object carrying an action name at `"0"` plus an auxiliary payload.
421
+ const propNames = CANVAS_EVENT_PROP_NAMES[eventType] ?? [
422
+ `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`,
423
+ `on${eventType}`,
424
+ eventType,
425
+ ];
426
+
427
+ let spec: unknown;
428
+ for (const name of propNames) {
429
+ if (node.props[name] != null) {
430
+ spec = node.props[name];
431
+ break;
432
+ }
433
+ }
393
434
 
394
435
  // Actionable components fall back to the bare `action` prop on click.
395
436
  if (spec == null && eventType === "click") {
@@ -429,6 +470,7 @@ export class CanvasEventManager {
429
470
  this.canvas.removeEventListener("contextmenu", this.boundOnContextMenu);
430
471
  this.canvas.removeEventListener("keydown", this.boundOnKeyDown);
431
472
  this.canvas.removeEventListener("keyup", this.boundOnKeyUp);
473
+ if (this.mouseDownNode) this.mouseDownNode.pressed = false;
432
474
  this.rootNode = null;
433
475
  this.hoveredNode = null;
434
476
  this.focusedNode = null;
@@ -5,14 +5,6 @@
5
5
  */
6
6
 
7
7
  export { CanvasRenderer } from "./renderer.js";
8
- export {
9
- createCanvasClient as createHypenClient,
10
- createCanvasClient,
11
- type CanvasClient as HypenClient,
12
- type CanvasClient,
13
- type CanvasClientOptions as HypenClientOptions,
14
- type CanvasClientOptions,
15
- } from "../client.js";
16
8
  export { registerPainter } from "./paint.js";
17
9
  export { CanvasEventManager } from "./events.js";
18
10
  export { InputOverlay } from "./input.js";
@@ -7,15 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { VirtualNode, Layout, BoxSpacing } from "./types.js";
10
- import {
11
- parseSpacing,
12
- parseSize,
13
- cssLengthToPx,
14
- cssLengthToDimension,
15
- resolveLineHeight,
16
- setCurrentViewport,
17
- getCurrentViewport,
18
- } from "./utils.js";
10
+ import { parseSpacing, parseSize, cssLengthToPx, cssLengthToDimension } from "./utils.js";
19
11
  import { measureText } from "./text.js";
20
12
  import { getImageNaturalAspect } from "./paint.js";
21
13
 
@@ -55,6 +47,19 @@ export async function initTaffyLayout(): Promise<boolean> {
55
47
  taffyInitPromise = (async () => {
56
48
  try {
57
49
  taffy = await import("taffy-layout");
50
+ // In a server runtime (Bun/Node — including test runs where a JSDOM
51
+ // `window` may be globally registered by another test file), load the
52
+ // WASM from node_modules via fs. This path is deterministic; the
53
+ // fetch-based branch below depends on a reachable origin/CDN and must
54
+ // never be selected just because a test polyfilled `window`.
55
+ const isServerRuntime =
56
+ typeof process !== "undefined" &&
57
+ !!(process.versions?.bun || process.versions?.node);
58
+ if (isServerRuntime) {
59
+ await taffy.loadTaffy();
60
+ taffyReady = true;
61
+ return;
62
+ }
58
63
  // Bundlers (esp. Bun's browser bundler) sometimes inline a file:// URL
59
64
  // for import.meta.url inside taffy_wasm.js, which the browser blocks
60
65
  // ("Not allowed to load local resource"). The package's own `loadTaffy`
@@ -62,26 +67,40 @@ export async function initTaffyLayout(): Promise<boolean> {
62
67
  // raw `__wbg_init` (default export of the `taffy-layout/wasm` subpath)
63
68
  // directly with an explicit Response.
64
69
  let usedExplicit = false;
65
- if (typeof window !== "undefined" && typeof fetch === "function") {
66
- const candidates = [
67
- new URL("/taffy_wasm_bg.wasm", window.location.origin),
68
- new URL("https://cdn.jsdelivr.net/npm/taffy-layout@2.0.3/pkg/taffy_wasm_bg.wasm"),
69
- new URL("https://unpkg.com/taffy-layout@2.0.3/pkg/taffy_wasm_bg.wasm"),
70
- ];
71
- const rawWasm = await import(
72
- /* @vite-ignore */ "taffy-layout/wasm" as string
73
- );
74
- const wbgInit = rawWasm.default as (input?: any) => Promise<unknown>;
75
- for (const url of candidates) {
76
- try {
77
- const res = await fetch(url);
78
- if (!res.ok) continue;
79
- await wbgInit({ module_or_path: res });
80
- usedExplicit = true;
81
- break;
82
- } catch {
83
- // Try next candidate
70
+ // Only take the fetch path in a real browser. Test environments (and
71
+ // anything else that installs a fake `window` global) must fall through
72
+ // to `loadTaffy()`, which resolves the WASM from the package on disk —
73
+ // a fake window without `location.origin` used to throw here and
74
+ // silently disable Taffy for the rest of the process.
75
+ const isRealBrowser =
76
+ typeof window !== "undefined" &&
77
+ typeof fetch === "function" &&
78
+ typeof window.location?.origin === "string" &&
79
+ window.document?.defaultView === window;
80
+ if (isRealBrowser) {
81
+ try {
82
+ const candidates = [
83
+ new URL("/taffy_wasm_bg.wasm", window.location.origin),
84
+ new URL("https://cdn.jsdelivr.net/npm/taffy-layout@2.0.3/pkg/taffy_wasm_bg.wasm"),
85
+ new URL("https://unpkg.com/taffy-layout@2.0.3/pkg/taffy_wasm_bg.wasm"),
86
+ ];
87
+ const rawWasm = await import(
88
+ /* @vite-ignore */ "taffy-layout/wasm" as string
89
+ );
90
+ const wbgInit = rawWasm.default as (input?: any) => Promise<unknown>;
91
+ for (const url of candidates) {
92
+ try {
93
+ const res = await fetch(url);
94
+ if (!res.ok) continue;
95
+ await wbgInit({ module_or_path: res });
96
+ usedExplicit = true;
97
+ break;
98
+ } catch {
99
+ // Try next candidate
100
+ }
84
101
  }
102
+ } catch {
103
+ // Fall through to loadTaffy()
85
104
  }
86
105
  }
87
106
  if (!usedExplicit) {
@@ -180,22 +199,9 @@ function buildTaffyStyle(
180
199
  // `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
181
200
  // — `flex-direction: column` is its default). Match that here so feeds
182
201
  // like Notifications stack their rows vertically instead of flowing
183
- // sideways. The legacy `direction` prop can flip it to row, and so can
184
- // a Tailwind `flex-row` class which lands on `props.flexDirection`
185
- // (without this, top-tab strips built as `List.flex-row` stacked
186
- // vertically because the List default won the tie-break).
187
- const isListColumn =
188
- type === "list" &&
189
- props.direction !== "horizontal" &&
190
- props.flexDirection !== "row";
191
- // Button defaults to `flex-direction: column` to match the DOM renderer
192
- // (see dom/components/button.ts). With Taffy's default `align-items:
193
- // stretch`, treating Button as a row caused inner `Column { Icon, Text }`
194
- // children to collapse onto each other (icon overlapping label). Only
195
- // apply the column default when no explicit `flexDirection` is set so a
196
- // Tailwind `flex-row` on a button still wins.
197
- const isButtonColumn = type === "button" && props.flexDirection === undefined;
198
- const isColumn = !isStack && (type === "column" || isListColumn || isButtonColumn || props.flexDirection === "column");
202
+ // sideways. The `direction` prop can still flip it to row.
203
+ const isListColumn = type === "list" && props.direction !== "horizontal";
204
+ const isColumn = !isStack && (type === "column" || isListColumn || props.flexDirection === "column");
199
205
  const isGrid = !isStack && (type === "grid" || props.display === "grid");
200
206
 
201
207
  if (isStack) {
@@ -386,7 +392,7 @@ function buildTaffyStyle(
386
392
  // Taffy defaults to `box-sizing: border-box`, so we add the
387
393
  // padding/border ourselves into `size.height`.
388
394
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
389
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.5;
395
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.5;
390
396
  const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
391
397
  const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
392
398
  const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
@@ -408,30 +414,7 @@ function buildTaffyStyle(
408
414
  (parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
409
415
  const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
410
416
  const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
411
-
412
- // Mirror the DOM rule `[data-hypen-type="row"]:has(> [data-hypen-flex])
413
- // { width: 100% }` (and the symmetric column case). When a flex
414
- // container has no explicit main-axis size and any child opts into
415
- // weighted growth (`flex` / `flexGrow > 0`), Taffy would otherwise
416
- // size the container to its min-content — so a Row of `flex-1`
417
- // segmented-control buttons stayed pill-sized instead of stretching
418
- // across the parent. Only do this when at least one child explicitly
419
- // asks for flex, matching the DOM `:has(> [data-hypen-flex])` guard.
420
- const isRowContainer = !isStack && !isGrid && !isColumn;
421
- const isColumnContainer = !isStack && !isGrid && isColumn;
422
- const childAsksFlex = node.children.some((c) => {
423
- const fg = parseFloat(c.props.flexGrow);
424
- if (Number.isFinite(fg) && fg > 0) return true;
425
- const f = parseFloat(c.props.flex);
426
- return Number.isFinite(f) && f > 0;
427
- });
428
- let resolvedWidth = toDimension(widthIn);
429
- let resolvedHeight = toDimension(heightIn);
430
- if (childAsksFlex) {
431
- if (isRowContainer && widthIn === undefined) resolvedWidth = "100%";
432
- if (isColumnContainer && heightIn === undefined) resolvedHeight = "100%";
433
- }
434
- style.size = { width: resolvedWidth, height: resolvedHeight };
417
+ style.size = { width: toDimension(widthIn), height: toDimension(heightIn) };
435
418
  }
436
419
 
437
420
  // --- Min / Max constraints -------------------------------------------------
@@ -806,17 +789,12 @@ function buildTree(
806
789
  // container with size 0×0, and downstream siblings were placed on top of
807
790
  // each other ("1,431 likes" overlapping the caption row was the visible
808
791
  // symptom).
809
- // `props[0] != null` not `props[0]` truthy. The number `0`, the empty
810
- // string, and `false` are valid text content; falsy guards here let
811
- // those cases skip the measurement leaf and inherit a 0-height container,
812
- // which made siblings stack on top of each other (a bound `0` overlapping
813
- // the next line was the visible symptom in calorie-counter's hero).
814
- if (node.type.toLowerCase() === "text" && props[0] != null && node.children.length === 0) {
815
- const text = String(props[0]);
792
+ if (node.type.toLowerCase() === "text" && props[0] && node.children.length === 0) {
793
+ const text = String(props[0] || "");
816
794
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
817
795
  const fontWeight = props.fontWeight || "normal";
818
796
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
819
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
797
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
820
798
 
821
799
  const p = parseSpacing(props.padding || 0);
822
800
  if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
@@ -1130,30 +1108,18 @@ export function computeLayout(
1130
1108
  x: number = 0,
1131
1109
  y: number = 0,
1132
1110
  ): void {
1133
- // Publish the active viewport so `cssLengthToPx` can resolve `vw` / `vh`
1134
- // against the actual canvas dimensions during this layout pass. Without
1135
- // this, `vh` falls back to `window.innerHeight` (wrong inside an iframe
1136
- // or scaled preview pane) or, worse, to a unitless pixel value that
1137
- // collapses entire layouts. Layout is synchronous, so a try/finally
1138
- // restoring the previous slot is safe under nested or re-entrant calls.
1139
- const prevViewport = getCurrentViewport();
1140
- setCurrentViewport({ width: availableWidth, height: availableHeight });
1141
- try {
1142
- if (taffyReady && taffy) {
1111
+ if (taffyReady && taffy) {
1112
+ computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
1113
+ // After the first pass, Taffy's grid auto-row sizing for aspect-ratio
1114
+ // image leaves can collapse rows (the leaf's max-content height is 0
1115
+ // before column widths are known). Detect that pattern and re-run
1116
+ // with explicit `gridAutoRows` derived from the now-known column
1117
+ // width see `annotateCollapsedAspectGrids`.
1118
+ if (annotateCollapsedAspectGrids(node)) {
1143
1119
  computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
1144
- // After the first pass, Taffy's grid auto-row sizing for aspect-ratio
1145
- // image leaves can collapse rows (the leaf's max-content height is 0
1146
- // before column widths are known). Detect that pattern and re-run
1147
- // with explicit `gridAutoRows` derived from the now-known column
1148
- // width — see `annotateCollapsedAspectGrids`.
1149
- if (annotateCollapsedAspectGrids(node)) {
1150
- computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
1151
- }
1152
- } else {
1153
- computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
1154
1120
  }
1155
- } finally {
1156
- setCurrentViewport(prevViewport);
1121
+ } else {
1122
+ computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
1157
1123
  }
1158
1124
  }
1159
1125
 
@@ -1243,13 +1209,12 @@ function computeLayoutFallback(
1243
1209
  if (height === null) height = size;
1244
1210
  }
1245
1211
 
1246
- // Same `!= null` rule as above — `0` / `""` / `false` are valid text.
1247
- if (node.type.toLowerCase() === "text" && node.props[0] != null) {
1248
- const text = String(node.props[0]);
1212
+ if (node.type.toLowerCase() === "text" && node.props[0]) {
1213
+ const text = String(node.props[0] || "");
1249
1214
  const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1250
1215
  const fontWeight = props.fontWeight || "normal";
1251
1216
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
1252
- const lineHeight = resolveLineHeight(props.lineHeight, fontSize) ?? fontSize * 1.2;
1217
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
1253
1218
 
1254
1219
  const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
1255
1220
  const maxLinesRaw = props.maxLines;