@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
@@ -0,0 +1,254 @@
1
+ # Canvas Renderer — DOM Parity Gaps
2
+
3
+ Tracked list of gaps between `CanvasRenderer` and `DOMRenderer` when driven by
4
+ the real WASM engine (the social example is the reference workload).
5
+
6
+ The renderer currently handles the basic cases its unit tests cover (simple
7
+ column/row flex, flat prop names), but has not been exercised end-to-end
8
+ against engine output. Recent fixes landed the obvious bridging layer —
9
+ applicator-namespaced props, rem/em length parsing, `scrollable`↔`overflow`,
10
+ `aspectRatio` — and an additional batch (Stack overlay, image intrinsic
11
+ aspect, per-axis scroll, hit-test scroll offset, intrinsic-size flex-shrink)
12
+ landed alongside the `tests/canvas-parity.test.ts` fixture suite. The
13
+ remaining items below still block full parity.
14
+
15
+ ## Known gaps
16
+
17
+ ### Text
18
+
19
+ - **Line wrapping inside flex rows.** Text that wraps across lines in the
20
+ DOM currently either overflows horizontally or reports the wrong measured
21
+ height back to Taffy. Columns below it then collapse because subsequent
22
+ children layout against stale content height.
23
+ - **Measurement before first layout pass.** Text nodes report width based on
24
+ `ctx.measureText` at the moment the Taffy tree is built; when the Taffy
25
+ WASM layer initialises async, early frames get the JS-flexbox fallback and
26
+ measurements can disagree between the two paths.
27
+
28
+ ### Layout
29
+
30
+ - **`Stack` absolute positioning.** Fixed — Stack now uses Taffy Grid with a
31
+ single 1×1 cell and every child placed at `gridColumn: 1 / 2; gridRow: 1
32
+ / 2`, mirroring the DOM `grid-template-areas: "stack"` strategy.
33
+ `horizontalAlignment` / `verticalAlignment` map to `justifyItems` /
34
+ `alignItems` so the badge can pin to any corner.
35
+ - **`Image` natural-size fallback.** Fixed — when an Image declares only one
36
+ of `width`/`height` and its intrinsic size is known (loaded image, or
37
+ seeded via `setImageNaturalSize` for tests), the other dimension is
38
+ derived from `naturalWidth / naturalHeight` and pinned explicitly so the
39
+ flex parent's default `align-items: stretch` can't override it.
40
+ - **Per-axis scroll.** Fixed — `getScrollAxes(node)` returns `{ x, y }` and
41
+ the wheel/drag/clamp paths in `ScrollManager` honour each axis
42
+ independently. A horizontal strip with `scrollable: "horizontal"` no
43
+ longer accumulates Y scroll range from tall children, and the layout
44
+ builder additionally drops `flex-shrink` to 0 on direct children of a
45
+ main-axis-scrollable parent so items overflow instead of collapsing.
46
+ - **Scrolled hit-testing / event routing.** Fixed — `getScrollAwareBounds`
47
+ no longer double-walks the parent chain to add `contentX/Y` (the layout
48
+ pass already writes absolute coordinates), and only subtracts each
49
+ scrollable ancestor's scroll offset. `getAbsoluteBounds` simplified to
50
+ match.
51
+
52
+ ### Painters
53
+
54
+ - **Button transparent background.** Fixed — explicit backgrounds now win
55
+ over the default-blue hover swap. Mentioned here as the canary for
56
+ per-component painters that still assume flat-prop defaults; audit the
57
+ rest of `paint.ts` against engine output.
58
+ - **Icon sizing inside buttons.** Fixed — intrinsic-sized components (icon,
59
+ avatar, badge, switch, checkbox, radio, spinner) now default to
60
+ `flex-shrink: 0`. They keep their default size in tight cross-axis
61
+ containers instead of collapsing to 0×0.
62
+ - **Border-radius clamping.** Fixed — Tailwind's `rounded-full` lands as
63
+ `border-radius: 9999px`. Without clamping, `drawRoundedRect`'s arcs
64
+ wrapped outside the box and a 20×20 blue badge painted as a 470×250
65
+ rectangle, swamping the Stories row. `drawRoundedRect` and
66
+ `isPointInRoundedRect` now clamp `radius` to `min(width, height) / 2`.
67
+
68
+ ### Hosting
69
+
70
+ - **Taffy WASM 404 in the social example.** Fixed in
71
+ `examples/social/typescript/web_canvas/serve.ts` — the bundled canvas
72
+ references `taffy_wasm_bg.wasm` at the page origin, so the dev server
73
+ needs to serve it. Without this route the renderer silently falls back
74
+ to the JS flexbox path, which drops most of the parity work above.
75
+
76
+ ### Engine prop shape
77
+
78
+ - **Element type case sensitivity.** Fixed — engine emits types
79
+ capitalised (`"Text"`, `"Button"`, `"Input"`, …). The Taffy `buildTree`
80
+ text-leaf check used `node.type === "text"` and missed; every Text
81
+ became an empty container with size 0×0 and downstream siblings landed
82
+ on top of each other (the visible symptom: "1,431 likes" overlapped the
83
+ caption row). The renderer's `clickable`/`focusable` flags had the same
84
+ bug — Buttons weren't flagged clickable, so cursor barely fired.
85
+ All per-type checks now lowercase the type before comparing.
86
+
87
+ ### Painters
88
+
89
+ - **Image circular clipping.** Fixed — `paintImage` clips to the rounded
90
+ box before drawing, so `rounded-full` avatars actually paint as circles
91
+ (story rings + post-header avatars). Also added `objectFit: cover`
92
+ semantics — the source is centered and cropped instead of stretched.
93
+
94
+ ### Scrolling
95
+
96
+ - **Root inflated by overflowing children.** Fixed — when the root has no
97
+ explicit width/height, `computeLayoutTaffy` now pins it to the canvas
98
+ dimensions. Previously a horizontally-scrollable Stories row with 5+
99
+ w-24 items inflated the entire feed to 607px on a 470px canvas and
100
+ pushed the BottomNav off-screen.
101
+ - **Scroll containers contribute their min-content to parent layout.**
102
+ Fixed — scrollable nodes set `style.overflow = Scroll` on the relevant
103
+ axis. Children still flow at intrinsic positions (so
104
+ `ScrollManager.updateScrollBounds` sees the correct scrollHeight) but
105
+ the container itself stops bubbling its min-content sideways.
106
+
107
+ ### Events
108
+
109
+ - **Cursor flash on hover over Buttons.** Fixed — the hit test returns
110
+ the deepest visible node, which for a Button-with-Icon is the Icon
111
+ (not clickable). `events.ts` now lifts the hit to the nearest
112
+ clickable ancestor for hover/cursor and click/mousedown/mouseup
113
+ dispatch, so the cursor stays a pointer over the whole Button surface.
114
+ - **Clicks silently dropped after `mouseup`.** Fixed — `onMouseUp`
115
+ cleared `mouseDownNode = null`, but the browser fires `click` AFTER
116
+ `mouseup`, and `onClick`'s press+release sameness check then always
117
+ failed (`null !== <Button>`). The clear is now done in `onClick`.
118
+
119
+ ### Text rendering
120
+
121
+ - **`View all X comments` wraps to 2 lines.** Fixed — pretext returns a
122
+ fractional pixel width (e.g. `141.71875` for "View all 87 comments"),
123
+ Taffy gives the Button exactly that, but at paint time `renderText`
124
+ re-measures with a maxWidth of the integer-rounded box width and
125
+ pretext wraps because `141.71 > 141`. The Taffy measure callback now
126
+ returns `Math.ceil(metrics.width)` so the box reserves the next whole
127
+ pixel up.
128
+
129
+ ### Components
130
+
131
+ - **`List` rendered as a row.** Fixed — `List` is the DSL's vertical-
132
+ iteration container (DOM uses `flex-direction: column`). The canvas
133
+ buildTaffyStyle now treats `type === "list"` as Column unless the
134
+ `direction` prop says `"horizontal"`. Notifications stack vertically
135
+ again.
136
+ - **`Grid(...).gridColumns(N)` rendered as 1 column.** Fixed — the
137
+ canvas was reading `gridTemplateColumns`/`columns` only, but the
138
+ engine emits the applicator name (`gridColumns`) on props. Also,
139
+ `parseGridTemplate(N)` now expands a bare number to N equal `1fr`
140
+ tracks (matching `dom/applicators/advanced-layout.ts`).
141
+ - **Grid items with `width: "100%"` collapsed track sizing.** Fixed —
142
+ on a Grid child, `width: "100%"` creates a circular dependency with
143
+ `1fr` track sizing (the cell wants to be `1fr` of the grid; the item
144
+ wants to be `100%` of the cell). Children of a Grid container now
145
+ ignore `width: "100%"` / `height: "100%"` (use `auto`) so Taffy's
146
+ default grid stretch fills the cell.
147
+ - **Grid items with `aspect-ratio: 1` swallowed siblings.** Fixed —
148
+ default `min-width: auto` (= min-content) on grid items collapsed
149
+ tracks. Grid children now default to `min-width: 0; min-height: 0`,
150
+ the standard CSS workaround.
151
+
152
+ ### Flex defaults
153
+
154
+ - **`flex: 1` shorthand left basis at `auto`.** Fixed — `flex: 1` in
155
+ CSS expands to `flex: 1 1 0%`, but the canvas was only reading the
156
+ grow value. Without the `0%` basis, an overflowing flex item started
157
+ at its intrinsic content size and Taffy redistributed from there,
158
+ squeezing the BottomNav off-screen on long routes (the home feed
159
+ with N posts; the search grid with 100s of items).
160
+
161
+ ### Z-order
162
+
163
+ - **Absolute-positioned overlays painted under in-flow siblings.**
164
+ Fixed — Story declares its close-button header BEFORE the full-screen
165
+ Image; CSS `z-index` puts the header on top, but Canvas paints in
166
+ tree order, so the Image covered the header (and the Hover/click
167
+ for the close button hit the Image instead). `paintNode` now paints
168
+ flow children first, then absolute children on top; `events.ts`
169
+ hit-tests absolutes first (front-to-back). Story's ✕ navigates
170
+ back to `/` again.
171
+
172
+ ### Form controls
173
+
174
+ - **Input / Textarea collapse to padding-only height.** Fixed —
175
+ `Input`, `Textarea`, and `Select` paint via the `InputOverlay` HTML
176
+ element, so the canvas layout doesn't see their text content as
177
+ children. The default fall-through gave them `size.auto`, and Taffy
178
+ (border-box by default) collapsed the box to padding only — the
179
+ social Search bar rendered as a 4-tall pill instead of the ~36 the
180
+ DOM produces, with the `Search` placeholder peeking out of a sliver
181
+ that looked nothing like the iOS / Android equivalent. Each control
182
+ now sets `size.height = lineHeight × rows + padding + border` so the
183
+ outer box matches the DOM, with Textarea defaulting to `rows: 3`.
184
+
185
+ ### Grid auto rows
186
+
187
+ - **Aspect-ratio image grid collapses to gap-tall rows.** Fixed —
188
+ Taffy's grid auto-row sizing queries each leaf's max-content height
189
+ before column widths are resolved. For an Image leaf with no fixed
190
+ height, max-content = 0, so a `Grid.gridColumns(3)` of `aspect-square`
191
+ images sized the implicit rows to gap-only and items overlapped by
192
+ ~150px each (the Search route's explore grid was a single squashed
193
+ band of every image). `computeLayout` now runs a second Taffy pass
194
+ when `annotateCollapsedAspectGrids` finds a Grid where the next-row
195
+ child sits closer than `firstChild.width / aspectRatio` — the second
196
+ pass reads a private `__autoRowsPx` hint set on the grid (column
197
+ width / aspectRatio) and pins implicit rows to that height. Two-pass
198
+ cost is bounded: only fires when an aspect-ratio overflow is observed,
199
+ not on every layout.
200
+
201
+ ### HiDPI
202
+
203
+ - **Hit tests offset by `dpr` on Mac retina screens.** Fixed —
204
+ `events.ts` `getCanvasCoordinates` (and the same helpers in `scroll.ts`
205
+ / `selection.ts` / `input.ts`) used to multiply client coords by
206
+ `canvas.width / rect.width`, which equals `devicePixelRatio` because
207
+ the renderer scales the ctx by dpr at setup. Layout (and therefore
208
+ the hit-test bounds) live in CSS pixels — multiplying again landed
209
+ every click at 2× the bounds. Visible symptoms on a Mac (dpr=2):
210
+ the cursor turned to a pointer ABOVE the actual story avatar instead
211
+ of on it, and bottom-nav clicks/hovers fell off the bottom of the
212
+ canvas entirely (y=850 logical → y=1700 canvas-pixel, past the 900
213
+ canvas height). Drag-scroll deltas and Input/Textarea overlay
214
+ positioning had the same family of bug. All four now use logical
215
+ CSS pixels directly.
216
+
217
+ ### Action wire format
218
+
219
+ - **`@router.push` dispatched verbatim.** Fixed — DOM strips the `@`
220
+ prefix and the `actions.` namespace before sending to the engine,
221
+ but the canvas was sending `@router.push` literal. The engine
222
+ silently dropped it, so every nav click was a no-op. `props.ts`
223
+ `resolveEventAction` now mirrors `dom/applicators/events.ts`
224
+ `extractActionDetails`.
225
+
226
+ ### Engine prop shape (continued)
227
+
228
+ - **Click was always `null !== <Button>`.** Fixed — `onMouseUp`
229
+ cleared `this.mouseDownNode = null`, but the browser fires `click`
230
+ AFTER `mouseup`. The `node === mouseDownNode` press+release check
231
+ in `onClick` always saw `null`. The clear is now done at the end
232
+ of `onClick` instead.
233
+
234
+ ## Not yet assessed
235
+
236
+ - Input / Textarea focus + caret positioning against the InputOverlay DOM
237
+ inputs.
238
+ - Select / Switch / Slider under engine-driven props.
239
+ - Router attach/detach patches (subtree caching) — the canvas renderer
240
+ implements `detach` / `attach` but hasn't been tested against the
241
+ ManagedRouter persist-cache path.
242
+
243
+ ## How to make progress
244
+
245
+ The shape of each of these is the same: write a small fixture in
246
+ `tests/canvas-*.test.ts` that reproduces the engine-output shape (use the
247
+ patches captured in `ws://localhost:3000` as the input), assert the expected
248
+ layout / paint output, then fix the canvas code until the test passes. The
249
+ screenshot-driven loop we've been in is too expensive; the real investment
250
+ is in fixture tests that pin behaviour.
251
+
252
+ The social example under `examples/social/typescript/web_canvas/` is the
253
+ end-to-end integration check, not a regression target — carry a visible
254
+ "partial" banner until parity is reached.
@@ -7,6 +7,7 @@
7
7
  import type { VirtualNode, Point } from "./types.js";
8
8
  import { isPointInRoundedRect } from "./utils.js";
9
9
  import { getScrollAwareBounds } from "./scroll.js";
10
+ import { resolveEventAction } from "./props.js";
10
11
 
11
12
  // Interface for the engine that CanvasEventManager needs
12
13
  interface IEngine {
@@ -23,6 +24,9 @@ export class CanvasEventManager {
23
24
  private hoveredNode: VirtualNode | null = null;
24
25
  private focusedNode: VirtualNode | null = null;
25
26
  private mouseDownNode: VirtualNode | null = null;
27
+ private focusChangeHandler:
28
+ | ((next: VirtualNode | null, prev: VirtualNode | null) => void)
29
+ | null = null;
26
30
 
27
31
  // Bound handler references for cleanup
28
32
  private boundOnMouseMove!: (e: MouseEvent) => void;
@@ -47,6 +51,19 @@ export class CanvasEventManager {
47
51
  this.rootNode = node;
48
52
  }
49
53
 
54
+ /**
55
+ * Subscribe to focus changes. Used by the renderer to mount the
56
+ * `InputOverlay` HTML element when an Input/Textarea gains focus and
57
+ * unmount it when focus moves away. Without this hookup the canvas
58
+ * Input painted as a pretty pill but a click on it did nothing — the
59
+ * overlay was constructed but never invoked.
60
+ */
61
+ setFocusChangeHandler(
62
+ fn: ((next: VirtualNode | null, prev: VirtualNode | null) => void) | null,
63
+ ): void {
64
+ this.focusChangeHandler = fn;
65
+ }
66
+
50
67
  /**
51
68
  * Setup canvas event listeners
52
69
  */
@@ -71,16 +88,20 @@ export class CanvasEventManager {
71
88
  }
72
89
 
73
90
  /**
74
- * Get canvas coordinates from mouse event
91
+ * Get canvas coordinates from mouse event.
92
+ *
93
+ * Returns LOGICAL pixels (CSS pixels), matching the layout coordinate
94
+ * space. Layout is computed in logical units because the renderer calls
95
+ * `ctx.scale(dpr, dpr)` once at setup; do NOT multiply by `canvas.width /
96
+ * rect.width` here — that would land hit tests in canvas-pixel space (2x
97
+ * on a HiDPI Mac), with the practical effect that the cursor changes to a
98
+ * pointer above the actual element instead of on it.
75
99
  */
76
100
  private getCanvasCoordinates(e: MouseEvent): Point {
77
101
  const rect = this.canvas.getBoundingClientRect();
78
- const scaleX = this.canvas.width / rect.width;
79
- const scaleY = this.canvas.height / rect.height;
80
-
81
102
  return {
82
- x: (e.clientX - rect.left) * scaleX,
83
- y: (e.clientY - rect.top) * scaleY,
103
+ x: e.clientX - rect.left,
104
+ y: e.clientY - rect.top,
84
105
  };
85
106
  }
86
107
 
@@ -93,7 +114,10 @@ export class CanvasEventManager {
93
114
  }
94
115
 
95
116
  /**
96
- * Recursively test node and children
117
+ * Recursively test node and children. Absolute-positioned siblings are
118
+ * checked first because they paint on top (see `paint.ts` overlay
119
+ * ordering) — without this, a click on the Story's close-button overlay
120
+ * lands on the underlying Image instead.
97
121
  */
98
122
  private hitTestNode(node: VirtualNode, point: Point): VirtualNode | null {
99
123
  if (!node.visible || !node.layout) return null;
@@ -101,9 +125,17 @@ export class CanvasEventManager {
101
125
  const bounds = getScrollAwareBounds(node);
102
126
  if (!bounds) return null;
103
127
 
104
- // Test children first (front to back)
128
+ // Front-to-back order: absolute overlays (newest in paint stack)
129
+ // first, then flow children in reverse paint order.
130
+ for (let i = node.children.length - 1; i >= 0; i--) {
131
+ const child = node.children[i];
132
+ if (child.props.position !== "absolute") continue;
133
+ const hit = this.hitTestNode(child, point);
134
+ if (hit) return hit;
135
+ }
105
136
  for (let i = node.children.length - 1; i >= 0; i--) {
106
137
  const child = node.children[i];
138
+ if (child.props.position === "absolute") continue;
107
139
  const hit = this.hitTestNode(child, point);
108
140
  if (hit) return hit;
109
141
  }
@@ -117,12 +149,31 @@ export class CanvasEventManager {
117
149
  return null;
118
150
  }
119
151
 
152
+ /**
153
+ * Walk up from a node to find the nearest clickable ancestor (or the node
154
+ * itself if it's clickable). Returns null if none. Used so that hovering
155
+ * a non-clickable child of a Button (e.g. the Icon inside a transparent
156
+ * nav button) still resolves to the Button for cursor + click purposes.
157
+ */
158
+ private findClickableAncestor(node: VirtualNode | null): VirtualNode | null {
159
+ let current = node;
160
+ while (current) {
161
+ if (current.clickable) return current;
162
+ current = current.parent;
163
+ }
164
+ return null;
165
+ }
166
+
120
167
  /**
121
168
  * Handle mouse move
122
169
  */
123
170
  private onMouseMove(e: MouseEvent): void {
124
171
  const point = this.getCanvasCoordinates(e);
125
- const node = this.hitTest(point);
172
+ const hit = this.hitTest(point);
173
+ // Resolve to the clickable ancestor so the cursor stays a pointer over
174
+ // the whole Button surface (not flashing back to default whenever the
175
+ // mouse passes over the Button's non-clickable Icon child).
176
+ const node = this.findClickableAncestor(hit) ?? hit;
126
177
 
127
178
  // Update hover state
128
179
  if (node !== this.hoveredNode) {
@@ -152,7 +203,10 @@ export class CanvasEventManager {
152
203
  */
153
204
  private onMouseDown(e: MouseEvent): void {
154
205
  const point = this.getCanvasCoordinates(e);
155
- const node = this.hitTest(point);
206
+ // Same lift-to-clickable as hover so a click on a Button's Icon child
207
+ // dispatches against the Button (where `onClick` actually lives).
208
+ const hit = this.hitTest(point);
209
+ const node = this.findClickableAncestor(hit) ?? hit;
156
210
 
157
211
  this.mouseDownNode = node;
158
212
 
@@ -177,7 +231,8 @@ export class CanvasEventManager {
177
231
  */
178
232
  private onMouseUp(e: MouseEvent): void {
179
233
  const point = this.getCanvasCoordinates(e);
180
- const node = this.hitTest(point);
234
+ const hit = this.hitTest(point);
235
+ const node = this.findClickableAncestor(hit) ?? hit;
181
236
 
182
237
  if (node && node.clickable) {
183
238
  this.dispatchNodeEvent(node, "mouseup", {
@@ -187,7 +242,11 @@ export class CanvasEventManager {
187
242
  });
188
243
  }
189
244
 
190
- this.mouseDownNode = null;
245
+ // NOTE: do NOT clear `this.mouseDownNode` here — the browser fires
246
+ // `click` after `mouseup`, and onClick uses mouseDownNode to verify
247
+ // press+release happened on the same target. Clearing here meant the
248
+ // click handler always saw `null` and silently dropped every click.
249
+ // The click handler clears mouseDownNode itself.
191
250
  }
192
251
 
193
252
  /**
@@ -195,7 +254,8 @@ export class CanvasEventManager {
195
254
  */
196
255
  private onClick(e: MouseEvent): void {
197
256
  const point = this.getCanvasCoordinates(e);
198
- const node = this.hitTest(point);
257
+ const hit = this.hitTest(point);
258
+ const node = this.findClickableAncestor(hit) ?? hit;
199
259
 
200
260
  if (node && node.clickable && node === this.mouseDownNode) {
201
261
  this.dispatchNodeEvent(node, "click", {
@@ -204,6 +264,7 @@ export class CanvasEventManager {
204
264
  clientY: e.clientY,
205
265
  });
206
266
  }
267
+ this.mouseDownNode = null;
207
268
  }
208
269
 
209
270
  /**
@@ -276,9 +337,10 @@ export class CanvasEventManager {
276
337
  private setFocus(node: VirtualNode | null): void {
277
338
  if (node === this.focusedNode) return;
278
339
 
279
- if (this.focusedNode) {
280
- this.focusedNode.focused = false;
281
- this.dispatchNodeEvent(this.focusedNode, "blur", {});
340
+ const prev = this.focusedNode;
341
+ if (prev) {
342
+ prev.focused = false;
343
+ this.dispatchNodeEvent(prev, "blur", {});
282
344
  }
283
345
 
284
346
  this.focusedNode = node;
@@ -288,9 +350,16 @@ export class CanvasEventManager {
288
350
  this.dispatchNodeEvent(node, "focus", {});
289
351
  }
290
352
 
353
+ if (this.focusChangeHandler) this.focusChangeHandler(node, prev);
354
+
291
355
  this.requestRedraw();
292
356
  }
293
357
 
358
+ /** Public for the renderer's overlay-blur path. */
359
+ clearFocus(): void {
360
+ this.setFocus(null);
361
+ }
362
+
294
363
  /**
295
364
  * Update cursor based on node
296
365
  */
@@ -300,7 +369,13 @@ export class CanvasEventManager {
300
369
  return;
301
370
  }
302
371
 
303
- const cursor = node.props.cursor || (node.clickable ? "pointer" : "default");
372
+ let cursor: string | undefined = node.props.cursor;
373
+ if (!cursor) {
374
+ const t = node.type.toLowerCase();
375
+ if (t === "input" || t === "textarea") cursor = "text";
376
+ else if (node.clickable) cursor = "pointer";
377
+ else cursor = "default";
378
+ }
304
379
  this.canvas.style.cursor = cursor;
305
380
  }
306
381
 
@@ -308,23 +383,29 @@ export class CanvasEventManager {
308
383
  * Dispatch event to engine
309
384
  */
310
385
  private dispatchNodeEvent(node: VirtualNode, eventType: string, data: any): void {
311
- // Check if node has action handler for this event
312
- let actionName = node.props[`on${eventType}`] || node.props[eventType];
313
-
314
- // Actionable components: fall back to "action" prop for click events
315
- if (!actionName && eventType === "click" && node.props.action) {
316
- actionName = node.props.action;
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];
393
+
394
+ // Actionable components fall back to the bare `action` prop on click.
395
+ if (spec == null && eventType === "click") {
396
+ spec = node.props.action;
317
397
  }
318
398
 
319
- if (actionName && typeof actionName === "string") {
320
- // Dispatch to engine
321
- this.engine.dispatchAction(actionName, {
322
- type: eventType,
323
- nodeId: node.id,
324
- timestamp: Date.now(),
325
- ...data,
326
- });
327
- }
399
+ const resolved = resolveEventAction(spec);
400
+ if (!resolved) return;
401
+
402
+ this.engine.dispatchAction(resolved.actionName, {
403
+ type: eventType,
404
+ nodeId: node.id,
405
+ timestamp: Date.now(),
406
+ ...resolved.payload,
407
+ ...data,
408
+ });
328
409
  }
329
410
 
330
411
  /**
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { VirtualNode, Rectangle } from "./types.js";
8
- import { getAbsoluteBounds } from "./utils.js";
8
+ import { getAbsoluteBounds, cssLengthToPx } from "./utils.js";
9
9
 
10
10
  /** Minimal engine interface for dispatching bind actions */
11
11
  interface BindEngine {
@@ -28,7 +28,12 @@ export class InputOverlay {
28
28
  }
29
29
 
30
30
  /**
31
- * Show input overlay for a node
31
+ * Show input overlay for a node.
32
+ *
33
+ * Type comparison is case-insensitive — the engine emits `"Input"` /
34
+ * `"Textarea"`, but the overlay branch was looking for lowercase
35
+ * `"textarea"` and always created an `<input>` (so multi-line typing
36
+ * couldn't enter newlines and the visible caret was the wrong height).
32
37
  */
33
38
  showInput(
34
39
  node: VirtualNode,
@@ -48,7 +53,7 @@ export class InputOverlay {
48
53
  const bounds = getAbsoluteBounds(node);
49
54
  if (!bounds) return;
50
55
 
51
- const isMultiline = node.type === "textarea";
56
+ const isMultiline = node.type.toLowerCase() === "textarea";
52
57
  this.overlay = isMultiline
53
58
  ? document.createElement("textarea")
54
59
  : document.createElement("input");
@@ -66,8 +71,10 @@ export class InputOverlay {
66
71
  this.overlay.addEventListener("blur", this.onBlur.bind(this));
67
72
  this.overlay.addEventListener("keydown", this.onKeyDown.bind(this) as EventListener);
68
73
 
69
- // Add to DOM and focus
70
- this.container.appendChild(this.overlay);
74
+ // Mount on document.body so absolute positioning is relative to the
75
+ // viewport (`canvasBounds.left/top` are page coords too) — no
76
+ // dependency on the canvas's wrapper having `position: relative`.
77
+ document.body.appendChild(this.overlay);
71
78
  this.overlay.focus();
72
79
 
73
80
  this.focusedNode = node;
@@ -114,24 +121,35 @@ export class InputOverlay {
114
121
  // Position
115
122
  this.positionOverlay(bounds, canvasBounds);
116
123
 
117
- // Font and text styling
118
- const fontSize = parseFloat(props.fontSize) || 16;
124
+ // Font and text styling — go through `cssLengthToPx` so values that
125
+ // arrive in `rem` ("0.875rem" for `text-sm`) round to real pixels
126
+ // instead of `parseFloat`'s bare 0.875 (which made the caret disappear).
127
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
119
128
  const fontWeight = props.fontWeight || "normal";
120
129
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
121
130
  const color = props.color || "#000000";
122
131
 
132
+ const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
133
+ const padRight = cssLengthToPx(props.paddingRight ?? props.padding) ?? 0;
134
+ const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
135
+ const padLeft = cssLengthToPx(props.paddingLeft ?? props.padding) ?? 0;
136
+ const borderRadius = cssLengthToPx(props.borderRadius) ?? 4;
137
+
123
138
  Object.assign(this.overlay.style, {
124
139
  fontSize: `${fontSize}px`,
125
- fontWeight: fontWeight,
140
+ fontWeight: String(fontWeight),
126
141
  fontFamily: fontFamily,
127
142
  color: color,
128
143
  border: "none",
129
- outline: "2px solid #007bff",
144
+ outline: "none",
130
145
  backgroundColor: props.backgroundColor || "#ffffff",
131
- padding: `${props.padding || 8}px`,
132
- borderRadius: `${props.borderRadius || 4}px`,
146
+ padding: `${padTop}px ${padRight}px ${padBottom}px ${padLeft}px`,
147
+ borderRadius: `${borderRadius}px`,
133
148
  boxSizing: "border-box",
134
149
  resize: "none",
150
+ // Keep the overlay above the canvas; the Bun-served stage has no
151
+ // stacking context of its own so a plain z-index works.
152
+ zIndex: "1000",
135
153
  });
136
154
 
137
155
  // Placeholder
@@ -146,27 +164,28 @@ export class InputOverlay {
146
164
  }
147
165
 
148
166
  /**
149
- * Position overlay over canvas node
167
+ * Position overlay over canvas node.
168
+ *
169
+ * `bounds` is in canvas-local CSS pixels (the layout coordinate space
170
+ * the renderer paints into after `ctx.scale(dpr, dpr)`). The overlay is
171
+ * mounted on `document.body`, so we add `canvasBounds.left/top`
172
+ * (page coords from `getBoundingClientRect`) to land at the right
173
+ * absolute position regardless of where the canvas sits on the page.
174
+ * Adding `window.scrollX/Y` keeps the overlay pinned to the canvas
175
+ * even when the page scrolls between focus and reposition.
150
176
  */
151
177
  private positionOverlay(bounds: Rectangle, canvasBounds: DOMRect): void {
152
178
  if (!this.overlay) return;
153
179
 
154
- // Calculate scale factor between canvas and display
155
- const canvas = this.container.querySelector("canvas") as HTMLCanvasElement;
156
- const scaleX = canvasBounds.width / canvas.width;
157
- const scaleY = canvasBounds.height / canvas.height;
158
-
159
- const left = bounds.x * scaleX;
160
- const top = bounds.y * scaleY;
161
- const width = bounds.width * scaleX;
162
- const height = bounds.height * scaleY;
180
+ const sx = typeof window !== "undefined" ? window.scrollX : 0;
181
+ const sy = typeof window !== "undefined" ? window.scrollY : 0;
163
182
 
164
183
  Object.assign(this.overlay.style, {
165
184
  position: "absolute",
166
- left: `${left}px`,
167
- top: `${top}px`,
168
- width: `${width}px`,
169
- height: `${height}px`,
185
+ left: `${canvasBounds.left + sx + bounds.x}px`,
186
+ top: `${canvasBounds.top + sy + bounds.y}px`,
187
+ width: `${bounds.width}px`,
188
+ height: `${bounds.height}px`,
170
189
  });
171
190
  }
172
191