@hypen-space/web 0.4.81 → 0.4.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/canvas/events.d.ts +30 -2
  2. package/dist/canvas/events.js +112 -30
  3. package/dist/canvas/events.js.map +1 -1
  4. package/dist/canvas/input.d.ts +15 -2
  5. package/dist/canvas/input.js +43 -24
  6. package/dist/canvas/input.js.map +1 -1
  7. package/dist/canvas/layout.js +479 -96
  8. package/dist/canvas/layout.js.map +1 -1
  9. package/dist/canvas/paint.d.ts +4 -0
  10. package/dist/canvas/paint.js +141 -29
  11. package/dist/canvas/paint.js.map +1 -1
  12. package/dist/canvas/props.d.ts +55 -0
  13. package/dist/canvas/props.js +121 -0
  14. package/dist/canvas/props.js.map +1 -0
  15. package/dist/canvas/renderer.d.ts +13 -0
  16. package/dist/canvas/renderer.js +109 -11
  17. package/dist/canvas/renderer.js.map +1 -1
  18. package/dist/canvas/scroll.d.ts +27 -3
  19. package/dist/canvas/scroll.js +66 -26
  20. package/dist/canvas/scroll.js.map +1 -1
  21. package/dist/canvas/selection.js +3 -4
  22. package/dist/canvas/selection.js.map +1 -1
  23. package/dist/canvas/utils.d.ts +23 -3
  24. package/dist/canvas/utils.js +90 -30
  25. package/dist/canvas/utils.js.map +1 -1
  26. package/dist/dom/applicators/border.js +9 -0
  27. package/dist/dom/applicators/border.js.map +1 -1
  28. package/dist/dom/applicators/font.js +18 -1
  29. package/dist/dom/applicators/font.js.map +1 -1
  30. package/dist/dom/applicators/layout.js +44 -8
  31. package/dist/dom/applicators/layout.js.map +1 -1
  32. package/dist/dom/applicators/margin.js +18 -53
  33. package/dist/dom/applicators/margin.js.map +1 -1
  34. package/dist/dom/applicators/padding.js +21 -33
  35. package/dist/dom/applicators/padding.js.map +1 -1
  36. package/dist/dom/applicators/typography.js +13 -1
  37. package/dist/dom/applicators/typography.js.map +1 -1
  38. package/dist/dom/components/column.d.ts +11 -2
  39. package/dist/dom/components/column.js +12 -4
  40. package/dist/dom/components/column.js.map +1 -1
  41. package/dist/dom/components/image.js +9 -3
  42. package/dist/dom/components/image.js.map +1 -1
  43. package/dist/dom/components/input.js +11 -0
  44. package/dist/dom/components/input.js.map +1 -1
  45. package/dist/dom/components/textarea.js +8 -0
  46. package/dist/dom/components/textarea.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +18 -0
  48. package/dist/dom/renderer.js +87 -3
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/canvas/PARITY.md +254 -0
  52. package/src/canvas/events.ts +113 -32
  53. package/src/canvas/input.ts +44 -25
  54. package/src/canvas/layout.ts +492 -91
  55. package/src/canvas/paint.ts +143 -29
  56. package/src/canvas/props.ts +117 -0
  57. package/src/canvas/renderer.ts +132 -11
  58. package/src/canvas/scroll.ts +62 -29
  59. package/src/canvas/selection.ts +3 -4
  60. package/src/canvas/utils.ts +83 -31
  61. package/src/dom/applicators/border.ts +9 -0
  62. package/src/dom/applicators/font.ts +16 -1
  63. package/src/dom/applicators/layout.ts +34 -7
  64. package/src/dom/applicators/margin.ts +16 -43
  65. package/src/dom/applicators/padding.ts +19 -27
  66. package/src/dom/applicators/typography.ts +11 -1
  67. package/src/dom/components/column.ts +12 -4
  68. package/src/dom/components/image.ts +8 -3
  69. package/src/dom/components/input.ts +11 -0
  70. package/src/dom/components/textarea.ts +8 -0
  71. package/src/dom/renderer.ts +92 -3
@@ -9,6 +9,7 @@ import { renderText } from "./text.js";
9
9
  import { ScrollManager, isScrollable } from "./scroll.js";
10
10
  import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
11
11
  import type { SelectionManager } from "./selection.js";
12
+ import { cssLengthToPx } from "./utils.js";
12
13
 
13
14
  /**
14
15
  * Module-level reference to the active SelectionManager so paintText
@@ -151,9 +152,20 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
151
152
  })
152
153
  : node.children;
153
154
 
155
+ // Paint flow children first, then absolute-positioned overlays on top.
156
+ // CSS uses `z-index` to order absolute siblings; Canvas just paints in
157
+ // tree order, so a `position: absolute` header declared FIRST in the
158
+ // tree (Story uses this for its close button) gets covered by the
159
+ // following in-flow Image. Push absolute kids to the end so they land
160
+ // on top — same effect as `z-index: auto` painting absolute after flow.
161
+ const flowKids: VirtualNode[] = [];
162
+ const overlayKids: VirtualNode[] = [];
154
163
  for (const child of childrenToPaint) {
155
- paintNode(ctx, child);
164
+ if (child.props.position === "absolute") overlayKids.push(child);
165
+ else flowKids.push(child);
156
166
  }
167
+ for (const child of flowKids) paintNode(ctx, child);
168
+ for (const child of overlayKids) paintNode(ctx, child);
157
169
 
158
170
  // Undo scroll translation before drawing scrollbars
159
171
  if ((node as any)._scrollRestore) {
@@ -261,14 +273,14 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
261
273
 
262
274
  let text = String(props[0] || props.text || "");
263
275
  const color = props.color || "#000000";
264
- const fontSize = parseFloat(props.fontSize) || 16;
276
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
265
277
  const fontWeight = props.fontWeight || "normal";
266
278
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
267
279
  const textAlign = props.textAlign || "left";
268
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
280
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
269
281
  const textDecoration = props.textDecoration || "none";
270
282
  const textTransform = props.textTransform || "none";
271
- const letterSpacing = parseFloat(props.letterSpacing) || 0;
283
+ const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
272
284
 
273
285
  // Apply text transform
274
286
  if (textTransform === "uppercase") {
@@ -366,13 +378,29 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
366
378
  applyShadow(ctx, shadow);
367
379
  }
368
380
 
369
- // Background color based on state
370
- let backgroundColor = props.backgroundColor || "#007bff";
381
+ // Background color based on state.
382
+ //
383
+ // Only the default-blue button derives a default hover/focus shade. When a
384
+ // caller supplies `backgroundColor` (including "transparent" for icon-style
385
+ // buttons), swap to hover/focus only if they _also_ supply an explicit
386
+ // hoverColor/focusColor — otherwise the caller's intent wins and we leave
387
+ // the base colour untouched. Without this, transparent nav buttons would
388
+ // flash a blue rectangle on hover.
389
+ const explicitBg = props.backgroundColor !== undefined;
390
+ let backgroundColor = explicitBg ? props.backgroundColor : "#007bff";
371
391
  if (node.hovered) {
372
- backgroundColor = props.hoverColor || "#0056b3";
392
+ if (props.hoverColor !== undefined) {
393
+ backgroundColor = props.hoverColor;
394
+ } else if (!explicitBg) {
395
+ backgroundColor = "#0056b3";
396
+ }
373
397
  }
374
398
  if (node.focused) {
375
- backgroundColor = props.focusColor || "#004085";
399
+ if (props.focusColor !== undefined) {
400
+ backgroundColor = props.focusColor;
401
+ } else if (!explicitBg) {
402
+ backgroundColor = "#004085";
403
+ }
376
404
  }
377
405
 
378
406
  // Support gradients for button background
@@ -441,10 +469,10 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
441
469
  const textColor = value ? (props.color || "#000000") : "#999999";
442
470
 
443
471
  if (text) {
444
- const fontSize = parseFloat(props.fontSize) || 16;
472
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
445
473
  const fontWeight = props.fontWeight || "normal";
446
474
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
447
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
475
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
448
476
 
449
477
  renderText(
450
478
  ctx,
@@ -478,6 +506,28 @@ const imageFailCache = new Map<string, { failedAt: number; attempts: number }>()
478
506
  const IMAGE_FAIL_COOLDOWN_MS = 30_000;
479
507
  const IMAGE_MAX_ATTEMPTS = 3;
480
508
 
509
+ /**
510
+ * Cached intrinsic aspect ratios (width / height) for decoded images. The
511
+ * layout engine reads this to give an Image with only one declared dimension
512
+ * the right size on the other axis (instead of stretching to Taffy auto).
513
+ *
514
+ * Populated when an Image's onload fires; tests can seed it via
515
+ * `setImageNaturalSize` to assert the layout path without a real DOM.
516
+ */
517
+ const imageNaturalAspect = new Map<string, number>();
518
+
519
+ /** Returns width/height of the cached image, or null if unknown. */
520
+ export function getImageNaturalAspect(src: string): number | null {
521
+ return imageNaturalAspect.get(src) ?? null;
522
+ }
523
+
524
+ /** Test helper: seed the intrinsic-size cache without loading a real image. */
525
+ export function setImageNaturalSize(src: string, width: number, height: number): void {
526
+ if (width > 0 && height > 0) {
527
+ imageNaturalAspect.set(src, width / height);
528
+ }
529
+ }
530
+
481
531
  /**
482
532
  * Evict oldest entries from a Map when it exceeds the given maximum size.
483
533
  */
@@ -520,6 +570,7 @@ export function clearImageCache(): void {
520
570
  imageCache.clear();
521
571
  imagePending.clear();
522
572
  imageFailCache.clear();
573
+ imageNaturalAspect.clear();
523
574
  }
524
575
 
525
576
  /**
@@ -536,11 +587,29 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
536
587
  const y = layout.y;
537
588
  const width = layout.width;
538
589
  const height = layout.height;
590
+ const radius = layout.border.radius;
591
+
592
+ // Clip to the rounded box so `rounded-full` avatars actually paint as
593
+ // circles. Without this, avatars and post-header photos paint as
594
+ // squares behind their (correctly-rounded) story-ring border.
595
+ const clipped = radius > 0 && width > 0 && height > 0;
596
+ if (clipped) {
597
+ ctx.save();
598
+ drawRoundedRect(ctx, x, y, width, height, radius);
599
+ ctx.clip();
600
+ }
539
601
 
540
602
  const cached = imageCache.get(src);
541
603
  if (cached && cached.complete && cached.naturalWidth > 0) {
542
- // Draw the loaded image
543
- ctx.drawImage(cached, x, y, width, height);
604
+ // `objectFit: cover` is the social-feed default — fill the box without
605
+ // squashing. Anything else (or unspecified) falls back to the prior
606
+ // stretch behaviour for back-compat.
607
+ if (props.objectFit === "cover") {
608
+ drawImageCover(ctx, cached, x, y, width, height);
609
+ } else {
610
+ ctx.drawImage(cached, x, y, width, height);
611
+ }
612
+ if (clipped) ctx.restore();
544
613
  return;
545
614
  }
546
615
 
@@ -562,11 +631,13 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
562
631
  if (failEntry) {
563
632
  if (failEntry.attempts >= IMAGE_MAX_ATTEMPTS || Date.now() - failEntry.failedAt < IMAGE_FAIL_COOLDOWN_MS) {
564
633
  ctx.fillText("Failed", x + width / 2, y + height / 2);
634
+ if (clipped) ctx.restore();
565
635
  return;
566
636
  }
567
637
  }
568
638
 
569
639
  ctx.fillText("Loading...", x + width / 2, y + height / 2);
640
+ if (clipped) ctx.restore();
570
641
 
571
642
  // Start loading if not already pending
572
643
  if (!imagePending.has(src)) {
@@ -578,6 +649,9 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
578
649
  img.onload = () => {
579
650
  imageInFlight.delete(src);
580
651
  imageCache.set(src, img);
652
+ if (img.naturalWidth > 0 && img.naturalHeight > 0) {
653
+ imageNaturalAspect.set(src, img.naturalWidth / img.naturalHeight);
654
+ }
581
655
  imagePending.delete(src);
582
656
  imageFailCache.delete(src);
583
657
  evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
@@ -599,7 +673,45 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
599
673
  }
600
674
 
601
675
  /**
602
- * Draw rounded rectangle path
676
+ * Draw an image with `object-fit: cover` semantics — fill the entire
677
+ * destination box without distortion, cropping the longer source axis.
678
+ */
679
+ function drawImageCover(
680
+ ctx: CanvasRenderingContext2D,
681
+ img: HTMLImageElement,
682
+ dx: number,
683
+ dy: number,
684
+ dw: number,
685
+ dh: number,
686
+ ): void {
687
+ const sw = img.naturalWidth;
688
+ const sh = img.naturalHeight;
689
+ if (sw <= 0 || sh <= 0) {
690
+ ctx.drawImage(img, dx, dy, dw, dh);
691
+ return;
692
+ }
693
+ const srcAspect = sw / sh;
694
+ const dstAspect = dw / dh;
695
+ let sx = 0, sy = 0, sWidth = sw, sHeight = sh;
696
+ if (srcAspect > dstAspect) {
697
+ // Source is wider — crop horizontally.
698
+ sWidth = sh * dstAspect;
699
+ sx = (sw - sWidth) / 2;
700
+ } else if (srcAspect < dstAspect) {
701
+ // Source is taller — crop vertically.
702
+ sHeight = sw / dstAspect;
703
+ sy = (sh - sHeight) / 2;
704
+ }
705
+ ctx.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dw, dh);
706
+ }
707
+
708
+ /**
709
+ * Draw rounded rectangle path.
710
+ *
711
+ * Clamps the radius to half the smallest side — Tailwind's `rounded-full`
712
+ * lands on the engine as `border-radius: 9999px`, and without clamping the
713
+ * arcs wrap outside the box and fill a giant area (a 20×20 badge with
714
+ * radius 9999 paints as ~470×250 of background colour, swamping the row).
603
715
  */
604
716
  function drawRoundedRect(
605
717
  ctx: CanvasRenderingContext2D,
@@ -609,21 +721,23 @@ function drawRoundedRect(
609
721
  height: number,
610
722
  radius: number
611
723
  ): void {
612
- if (radius <= 0) {
724
+ if (radius <= 0 || width <= 0 || height <= 0) {
613
725
  ctx.rect(x, y, width, height);
614
726
  return;
615
727
  }
616
728
 
729
+ const r = Math.min(radius, width / 2, height / 2);
730
+
617
731
  ctx.beginPath();
618
- ctx.moveTo(x + radius, y);
619
- ctx.lineTo(x + width - radius, y);
620
- ctx.arcTo(x + width, y, x + width, y + radius, radius);
621
- ctx.lineTo(x + width, y + height - radius);
622
- ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
623
- ctx.lineTo(x + radius, y + height);
624
- ctx.arcTo(x, y + height, x, y + height - radius, radius);
625
- ctx.lineTo(x, y + radius);
626
- ctx.arcTo(x, y, x + radius, y, radius);
732
+ ctx.moveTo(x + r, y);
733
+ ctx.lineTo(x + width - r, y);
734
+ ctx.arcTo(x + width, y, x + width, y + r, r);
735
+ ctx.lineTo(x + width, y + height - r);
736
+ ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
737
+ ctx.lineTo(x + r, y + height);
738
+ ctx.arcTo(x, y + height, x, y + height - r, r);
739
+ ctx.lineTo(x, y + r);
740
+ ctx.arcTo(x, y, x + r, y, r);
627
741
  ctx.closePath();
628
742
  }
629
743
 
@@ -847,7 +961,7 @@ function paintDivider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
847
961
 
848
962
  const orientation = props.orientation || "horizontal";
849
963
  const color = props.color || props.backgroundColor || "#e0e0e0";
850
- const thickness = parseFloat(props.thickness) || 1;
964
+ const thickness = cssLengthToPx(props.thickness) ?? 1;
851
965
 
852
966
  ctx.strokeStyle = color;
853
967
  ctx.lineWidth = thickness;
@@ -881,7 +995,7 @@ function paintCheckbox(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
881
995
  const checkedValue = props.checked !== undefined ? props.checked : props.value;
882
996
  const checked = checkedValue === true || checkedValue === "true" || (checkedValue !== false && checkedValue !== "false" && !!checkedValue);
883
997
 
884
- const radius = parseFloat(props.borderRadius) || 2;
998
+ const radius = cssLengthToPx(props.borderRadius) ?? 2;
885
999
 
886
1000
  // Background
887
1001
  const bgColor = checked ? (props.checkedColor || "#007bff") : (props.backgroundColor || "#ffffff");
@@ -1017,8 +1131,8 @@ function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1017
1131
  const value = parseFloat(props.value) || min;
1018
1132
  const percentage = (value - min) / (max - min);
1019
1133
 
1020
- const trackHeight = parseFloat(props.trackHeight) || 4;
1021
- const thumbSize = parseFloat(props.thumbSize) || 16;
1134
+ const trackHeight = cssLengthToPx(props.trackHeight) ?? 4;
1135
+ const thumbSize = cssLengthToPx(props.thumbSize) ?? 16;
1022
1136
  const trackY = y + (height - trackHeight) / 2;
1023
1137
 
1024
1138
  // Track background
@@ -1118,7 +1232,7 @@ function paintSpinner(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1118
1232
  const centerX = layout.x + size / 2;
1119
1233
  const centerY = layout.y + size / 2;
1120
1234
  const radius = size / 2 - 4;
1121
- const thickness = parseFloat(props.thickness) || 4;
1235
+ const thickness = cssLengthToPx(props.thickness) ?? 4;
1122
1236
  const color = props.color || "#007bff";
1123
1237
 
1124
1238
  // Use timestamp for animation if available
@@ -1387,7 +1501,7 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1387
1501
 
1388
1502
  const text = String(props[0] || props.text || "");
1389
1503
  const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
1390
- const fontSize = parseFloat(props.fontSize) || 16;
1504
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
1391
1505
  const fontWeight = props.fontWeight || "normal";
1392
1506
  const fontFamily = props.fontFamily || "system-ui, sans-serif";
1393
1507
  const textDecoration = props.textDecoration !== undefined ? props.textDecoration : "underline";
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Canvas applicator-prop normalisation.
3
+ *
4
+ * The engine emits props using the applicator-argument naming scheme:
5
+ * - single-positional: `flex.0`, `padding.0`, `width.0`
6
+ * - multi-argument: `onClick.0`, `onClick.to`, `onClick.postId`
7
+ *
8
+ * The DOM renderer groups these at apply time via ApplicatorRegistry so
9
+ * downstream code can read `style.flex`, handler payloads, etc. The canvas
10
+ * layout (`layout.ts`) and paint (`paint.ts`) expect the same flat / aggregate
11
+ * shape but don't run an applicator pipeline, so without normalisation every
12
+ * size/flex/padding arriving from the engine is silently ignored — nodes
13
+ * collapse to Taffy auto sizing and images paint at full canvas bounds.
14
+ *
15
+ * We mirror the DOM grouping here: keep the raw `"base.key"` entries on the
16
+ * node (so follow-up SetProp patches can re-derive the aggregate) and
17
+ * additionally populate the bare `base` key with either the scalar value
18
+ * (single `.0`) or an aggregate object (multiple args).
19
+ */
20
+
21
+ /** Return the applicator base name for a namespaced key, or null if it's flat. */
22
+ export function parseApplicatorBase(name: string): string | null {
23
+ const dot = name.indexOf(".");
24
+ if (dot <= 0) return null;
25
+ return name.slice(0, dot);
26
+ }
27
+
28
+ /**
29
+ * Rebuild the derived flat/aggregate entry for `baseName` from all
30
+ * `"baseName.*"` keys currently on `props`. Mutates `props` in place.
31
+ *
32
+ * - Zero matching dotted keys: delete `props[baseName]` (any previously
33
+ * derived value is now stale).
34
+ * - Exactly one key, numeric (`baseName.0`): flatten to the scalar value.
35
+ * - Anything else: store as an object keyed by the arg names.
36
+ */
37
+ export function refreshApplicator(props: Record<string, any>, baseName: string): void {
38
+ const prefix = baseName + ".";
39
+ const args: Record<string, any> = {};
40
+ let count = 0;
41
+ for (const key of Object.keys(props)) {
42
+ if (!key.startsWith(prefix)) continue;
43
+ args[key.slice(prefix.length)] = props[key];
44
+ count++;
45
+ }
46
+ if (count === 0) {
47
+ delete props[baseName];
48
+ return;
49
+ }
50
+ const keys = Object.keys(args);
51
+ if (keys.length === 1 && /^\d+$/.test(keys[0])) {
52
+ props[baseName] = args[keys[0]];
53
+ } else {
54
+ props[baseName] = args;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Walk every key in `props`, collect the set of applicator base names,
60
+ * and refresh each one. Used on the initial Create patch where the whole
61
+ * prop bag arrives at once.
62
+ */
63
+ export function normalizeAllApplicators(props: Record<string, any>): void {
64
+ const bases = new Set<string>();
65
+ for (const key of Object.keys(props)) {
66
+ const base = parseApplicatorBase(key);
67
+ if (base !== null) bases.add(base);
68
+ }
69
+ for (const base of bases) refreshApplicator(props, base);
70
+ }
71
+
72
+ /**
73
+ * Strip the engine's DSL prefixes from an action name. The Hypen DSL writes
74
+ * `@router.push` / `@actions.toggleLike`; the engine wants `router.push` /
75
+ * `toggleLike` on the wire. Mirrors the DOM renderer's
76
+ * `extractActionDetails` in `dom/applicators/events.ts`.
77
+ */
78
+ function stripActionPrefixes(raw: string): string {
79
+ if (!raw.startsWith("@")) return raw;
80
+ let name = raw.substring(1);
81
+ if (name.startsWith("actions.")) name = name.substring(8);
82
+ return name;
83
+ }
84
+
85
+ /**
86
+ * Resolve an event applicator value into `(actionName, payload)`.
87
+ *
88
+ * After normalisation, `onClick` is either:
89
+ * - a string: `"@router.push"` (single positional, no extra args)
90
+ * - an object: `{ "0": "@router.push", to: "/notifications" }`
91
+ *
92
+ * Strips the leading `@` and the `actions.` namespace prefix so the wire
93
+ * format matches what the DOM renderer sends — without this, `@router.push`
94
+ * went out verbatim and the engine ignored it because it expects the
95
+ * stripped form (`router.push`).
96
+ *
97
+ * Returns `null` if no dispatchable action name could be found.
98
+ */
99
+ export function resolveEventAction(
100
+ spec: unknown,
101
+ ): { actionName: string; payload: Record<string, any> } | null {
102
+ if (typeof spec === "string") {
103
+ if (!spec.startsWith("@")) return null;
104
+ return { actionName: stripActionPrefixes(spec), payload: {} };
105
+ }
106
+ if (spec && typeof spec === "object") {
107
+ const obj = spec as Record<string, any>;
108
+ const raw = obj["0"];
109
+ if (typeof raw !== "string" || !raw.startsWith("@")) return null;
110
+ const payload: Record<string, any> = {};
111
+ for (const [k, v] of Object.entries(obj)) {
112
+ if (k !== "0") payload[k] = v;
113
+ }
114
+ return { actionName: stripActionPrefixes(raw), payload };
115
+ }
116
+ return null;
117
+ }
@@ -30,6 +30,11 @@ import { ScrollManager } from "./scroll.js";
30
30
  import { SelectionManager } from "./selection.js";
31
31
  import { setSelectionManager } from "./paint.js";
32
32
  import { DirtyRectTracker } from "./dirty.js";
33
+ import {
34
+ normalizeAllApplicators,
35
+ refreshApplicator,
36
+ parseApplicatorBase,
37
+ } from "./props.js";
33
38
 
34
39
  const DEFAULT_OPTIONS: CanvasRendererOptions = {
35
40
  devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
@@ -104,6 +109,29 @@ export class CanvasRenderer implements Renderer {
104
109
  this.options.enableAccessibility || false
105
110
  );
106
111
 
112
+ // Bridge focus changes from the hit-tester to the HTML input overlay.
113
+ // Without this, `Input`/`Textarea` painted on the canvas but clicking
114
+ // one did nothing — the overlay never got mounted.
115
+ this.eventManager.setFocusChangeHandler((next) => {
116
+ const t = next ? next.type.toLowerCase() : null;
117
+ if (next && (t === "input" || t === "textarea")) {
118
+ const rect = this.canvas.getBoundingClientRect();
119
+ this.inputOverlay.showInput(
120
+ next,
121
+ rect,
122
+ (value) => {
123
+ // Local mirror so the next paint sees the typed text even
124
+ // before the engine echoes it back via SetProp.
125
+ next.props.value = value;
126
+ this.scheduleRedraw();
127
+ },
128
+ this.engine,
129
+ );
130
+ } else {
131
+ this.inputOverlay.hideInput();
132
+ }
133
+ });
134
+
107
135
  // Listen for redraw requests from event manager
108
136
  this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
109
137
 
@@ -203,6 +231,14 @@ export class CanvasRenderer implements Renderer {
203
231
  case "remove":
204
232
  this.onRemove(patch.id!);
205
233
  break;
234
+
235
+ case "detach":
236
+ this.onDetach(patch.id!);
237
+ break;
238
+
239
+ case "attach":
240
+ this.onInsert(patch.parentId!, patch.id!, patch.beforeId);
241
+ break;
206
242
  }
207
243
  }
208
244
 
@@ -210,17 +246,33 @@ export class CanvasRenderer implements Renderer {
210
246
  * Create new virtual node
211
247
  */
212
248
  private onCreate(id: string, elementType: string, props: Record<string, any>): void {
249
+ // Engine may send a Map (from WASM) or a plain object. Copy either way so
250
+ // we own the prop bag and can mutate it during applicator normalisation.
251
+ const rawProps: Record<string, any> =
252
+ props instanceof Map ? Object.fromEntries(props) : { ...props };
253
+ normalizeAllApplicators(rawProps);
254
+
255
+ // Engine emits the element type capitalised (`"Button"`, `"Text"`, …);
256
+ // every per-type comparison downstream lowercases it, so do the same
257
+ // for clickable/focusable. Without this, Buttons weren't flagged as
258
+ // clickable (so hover/cursor barely fired) and Inputs/Textareas
259
+ // weren't focusable (caret never landed).
260
+ const lowerType = elementType.toLowerCase();
213
261
  const node: VirtualNode = {
214
262
  id,
215
263
  type: elementType,
216
- props: props instanceof Map ? Object.fromEntries(props) : props,
264
+ props: rawProps,
217
265
  children: [],
218
266
  parent: null,
219
267
  visible: true,
220
- opacity: parseFloat(props.opacity) || 1,
221
- clickable: elementType === "button" || !!props.onclick || !!props.action,
268
+ opacity: parseFloat(rawProps.opacity) || 1,
269
+ clickable:
270
+ lowerType === "button" ||
271
+ rawProps.onClick != null ||
272
+ rawProps.onclick != null ||
273
+ rawProps.action != null,
222
274
  hoverable: true,
223
- focusable: elementType === "input" || elementType === "textarea" || elementType === "button",
275
+ focusable: lowerType === "input" || lowerType === "textarea" || lowerType === "button",
224
276
  focused: false,
225
277
  hovered: false,
226
278
  };
@@ -242,15 +294,30 @@ export class CanvasRenderer implements Renderer {
242
294
 
243
295
  node.props[name] = value;
244
296
 
245
- // Update computed properties
297
+ // If this is an applicator-namespaced key (e.g. `flex.0`, `onClick.to`),
298
+ // rebuild the derived flat/aggregate entry under the base name so layout,
299
+ // paint, and event dispatch see the updated value.
300
+ const base = parseApplicatorBase(name);
301
+ if (base !== null) {
302
+ refreshApplicator(node.props, base);
303
+ }
304
+
305
+ // Update computed properties — check both the direct key and the
306
+ // post-refresh derived key so setProp on `opacity.0` still updates opacity.
246
307
  if (name === "visible") {
247
308
  node.visible = !!value;
248
309
  }
249
- if (name === "opacity") {
250
- node.opacity = parseFloat(value) || 1;
310
+ if (name === "opacity" || base === "opacity") {
311
+ node.opacity = parseFloat(node.props.opacity) || 1;
251
312
  }
252
- if (name === "action" || name === "onclick") {
253
- node.clickable = !!value;
313
+ if (
314
+ name === "onClick" || name === "onclick" || name === "action" ||
315
+ base === "onClick" || base === "onclick" || base === "action"
316
+ ) {
317
+ node.clickable =
318
+ node.props.onClick != null ||
319
+ node.props.onclick != null ||
320
+ node.props.action != null;
254
321
  }
255
322
 
256
323
  // Mark dirty after prop change (new bounds will be captured after layout)
@@ -275,11 +342,25 @@ export class CanvasRenderer implements Renderer {
275
342
 
276
343
  delete node.props[name];
277
344
 
345
+ const base = parseApplicatorBase(name);
346
+ if (base !== null) {
347
+ refreshApplicator(node.props, base);
348
+ }
349
+
278
350
  if (name === "visible") {
279
351
  node.visible = true;
280
352
  }
281
- if (name === "opacity") {
282
- node.opacity = 1;
353
+ if (name === "opacity" || base === "opacity") {
354
+ node.opacity = parseFloat(node.props.opacity) || 1;
355
+ }
356
+ if (
357
+ name === "onClick" || name === "onclick" || name === "action" ||
358
+ base === "onClick" || base === "onclick" || base === "action"
359
+ ) {
360
+ node.clickable =
361
+ node.props.onClick != null ||
362
+ node.props.onclick != null ||
363
+ node.props.action != null;
283
364
  }
284
365
 
285
366
  if (this.options.enableDirtyRects) {
@@ -384,6 +465,46 @@ export class CanvasRenderer implements Renderer {
384
465
  this.onInsert(parentId, id, beforeId);
385
466
  }
386
467
 
468
+ /**
469
+ * Detach a subtree from its parent without destroying it.
470
+ *
471
+ * The VirtualNode and its descendants stay in `this.nodes`, just
472
+ * unlinked from the visible tree (removed from the parent's
473
+ * `children` array and `node.parent = null`). A subsequent
474
+ * `attach` patch reinserts it — scroll offsets, focus state,
475
+ * layout caches, and any other per-node derived state survive.
476
+ *
477
+ * Used by the engine's Router cache to keep off-screen route
478
+ * subtrees alive between navigations so re-entry is instant.
479
+ */
480
+ private onDetach(id: string): void {
481
+ const node = this.nodes.get(id);
482
+ if (!node) return;
483
+
484
+ // Mark dirty so the next paint doesn't leave stale pixels behind.
485
+ if (this.options.enableDirtyRects) {
486
+ this.dirtyTracker.markNodeDirty(node);
487
+ }
488
+
489
+ if (node.parent) {
490
+ const idx = node.parent.children.indexOf(node);
491
+ if (idx >= 0) {
492
+ node.parent.children.splice(idx, 1);
493
+ if (this.options.enableDirtyRects) {
494
+ this.dirtyTracker.markNodeDirty(node.parent);
495
+ }
496
+ }
497
+ node.parent = null;
498
+ }
499
+
500
+ if (this.rootNode === node) {
501
+ this.rootNode = null;
502
+ this.eventManager.setRootNode(null);
503
+ this.scrollManager.setRootNode(null);
504
+ this.selectionManager.setRootNode(null);
505
+ }
506
+ }
507
+
387
508
  /**
388
509
  * Remove node from tree
389
510
  */