@solidrt/core 0.0.50 → 0.0.52

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 (42) hide show
  1. package/AGENTS.md +102 -21
  2. package/README.md +1 -1
  3. package/agents/painting.md +61 -0
  4. package/agents/performance.md +216 -0
  5. package/docs/index.md +154 -0
  6. package/docs/reference/detached.md +85 -0
  7. package/docs/reference/drawing.md +95 -0
  8. package/docs/reference/elements.md +56 -0
  9. package/docs/reference/gpu.md +204 -0
  10. package/docs/reference/index.md +50 -0
  11. package/docs/reference/input.md +58 -0
  12. package/docs/reference/layout.md +44 -0
  13. package/docs/reference/shaders.md +46 -0
  14. package/docs/reference/text.md +46 -0
  15. package/docs/reference/transforms.md +35 -0
  16. package/docs/reference/types.md +34 -0
  17. package/examples/README.md +7 -5
  18. package/examples/{sound.tsx → audio.tsx} +1 -1
  19. package/examples/gpu-pipeline.tsx +2 -2
  20. package/examples/gpu-sprites.tsx +102 -0
  21. package/examples/line-points.tsx +145 -0
  22. package/examples/parse-svg.tsx +6 -6
  23. package/examples/responsive-grid.tsx +1 -1
  24. package/examples/scroll.tsx +2 -2
  25. package/examples/snapshot-texture.tsx +72 -0
  26. package/examples/{view-viewbox.tsx → view-design-size.tsx} +33 -15
  27. package/jsx-runtime.d.ts +15 -14
  28. package/package.json +11 -9
  29. package/src/{sound.ts → audio.ts} +68 -15
  30. package/src/color.ts +17 -18
  31. package/src/core.ts +40 -3
  32. package/src/data.ts +99 -0
  33. package/src/gpu.ts +88 -34
  34. package/src/index.ts +9 -3
  35. package/src/logo.tsx +92 -0
  36. package/src/renderer.ts +219 -53
  37. package/src/runtime-modules.d.ts +7 -2
  38. package/src/scroll.ts +51 -15
  39. package/src/svg.ts +1 -1
  40. package/src/text-input.ts +297 -61
  41. package/src/types.d.ts +291 -31
  42. package/src/window.ts +110 -14
package/src/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /// <reference path="./runtime-modules.d.ts" />
3
3
 
4
4
  import type { Gradient } from "./color"
5
- import type { ProgramId, TextureId } from "flux:gpu"
5
+ import type { ProgramId, TextureBindings, TextureId } from "flux:gpu"
6
6
  import type { TextInputHints } from "flux:rendertree"
7
7
  import type { Element } from "solid-js"
8
8
 
@@ -155,6 +155,7 @@ export interface PaintProps {
155
155
  // A solid color, or a gradient from createLinearGradient/createRadialGradient.
156
156
  color?: Color | Gradient
157
157
  blendMode?: "clear" | "source" | "destination" | "source-over" | "destination-over" | "source-in" | "destination-in" | "source-out" | "destination-out" | "source-atop" | "destination-atop" | "xor" | "plus" | "modulate" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "multiply" | "hue" | "saturation" | "color" | "luminosity"
158
+ /** Default "fill"; "stroke" on line, whose segment has no interior (see LineProps). */
158
159
  drawStyle?: "fill" | "stroke" | "stroke-and-fill"
159
160
  strokeCap?: "butt" | "round" | "square"
160
161
  strokeJoin?: "miter" | "round" | "bevel"
@@ -236,6 +237,14 @@ export interface PointerEvent {
236
237
  */
237
238
  parentX: number
238
239
  parentY: number
240
+ /**
241
+ * Pointer movement since the previous move event, in logical pixels. Mouse
242
+ * reports hardware deltas (summed, never lost, and the only motion signal
243
+ * while the pointer is locked); touch reports position diffs. 0 on
244
+ * non-move events.
245
+ */
246
+ movementX: number
247
+ movementY: number
239
248
  /** Node id whose handler is currently running (bubbling changes it per call). */
240
249
  currentTarget: number
241
250
  /** Deepest node id of the event's path (the hit leaf). */
@@ -357,7 +366,12 @@ export interface TextGeometryProps extends PositionProps {
357
366
  h?: number
358
367
  }
359
368
 
360
- /** See {@link PositionProps}: detached-only, never affects layout. */
369
+ /**
370
+ * See {@link PositionProps}: detached-only, never affects layout. A line's
371
+ * reported bounds (getBoundingBox, the tree, a detached capture) are its
372
+ * painted box: the geometry's extent plus the stroke's reach, not the
373
+ * inherited box.
374
+ */
361
375
  export interface LineGeometryProps {
362
376
  /** Endpoints default to spanning the box: (0,0) to (box width, box height). */
363
377
  x1?: number
@@ -366,6 +380,167 @@ export interface LineGeometryProps {
366
380
  y2?: number
367
381
  }
368
382
 
383
+ // Native transitions (okf/done/native-transitions.md): declared once on
384
+ // the element, applied by the runtime to every later write of the covered
385
+ // properties. JS hands over targets; Rust interpolates every frame, so a
386
+ // running animation costs no JS per frame.
387
+
388
+ /** A cubic-bezier timing curve: a CSS name or [x1, y1, x2, y2] control values. */
389
+ export type TransitionCurve = "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | [number, number, number, number]
390
+
391
+ /**
392
+ * A perceptual spring - the default kind: a bare `{ duration }` is a
393
+ * critically damped spring. `duration` (ms) is the perceptual settling
394
+ * time, `bounce` in (-1, 1] the springiness - 0 (the default) settles
395
+ * without overshoot, positive values overshoot, negative values settle
396
+ * sluggishly. A new target while the spring runs keeps position and
397
+ * velocity, so the motion stays continuous - use springs for anything
398
+ * retargeted while moving.
399
+ */
400
+ export interface TransitionSpring {
401
+ duration: number
402
+ bounce?: number
403
+ /** Hold each write for this long (ms) before it applies; a newer write during the hold replaces it and restarts the delay. */
404
+ delay?: number
405
+ /**
406
+ * Mount-time enter animation: at the element's first attach the property
407
+ * snaps to this value and animates to the value it mounted with. Numbers
408
+ * for the scalar properties; the color property takes a CSS color string
409
+ * or packed number. Per-property entries only (not under `all`); a later
410
+ * move or reorder re-runs nothing.
411
+ */
412
+ from?: number | string
413
+ /**
414
+ * Removal exit animation: an unmounted element stays visible, animates
415
+ * the property to this value (honoring `delay`), and is freed when its
416
+ * exit animations settle. Same value forms as `from`, per-property only.
417
+ * A move never plays it, the exiting element is hit-test invisible, its
418
+ * whole subtree stays painted with it, and no onTransitionEnd fires (the
419
+ * component is already disposed). An attached element keeps its layout
420
+ * slot until the exit finishes.
421
+ */
422
+ exit?: number | string
423
+ }
424
+
425
+ /**
426
+ * A duration/curve tween, opted into by naming the curve (a tween is
427
+ * always a specific curve; without one the spec reads as a spring).
428
+ * Duration in ms. A new target while the tween runs restarts it from the
429
+ * current value with the full duration (CSS semantics) - designer-timed,
430
+ * one-shot motion.
431
+ */
432
+ export interface TransitionTween {
433
+ duration: number
434
+ curve: TransitionCurve
435
+ /** Hold each write for this long (ms) before it applies; a newer write during the hold replaces it and restarts the delay. */
436
+ delay?: number
437
+ /**
438
+ * Mount-time enter animation: at the element's first attach the property
439
+ * snaps to this value and animates to the value it mounted with. Numbers
440
+ * for the scalar properties; the color property takes a CSS color string
441
+ * or packed number. Per-property entries only (not under `all`); a later
442
+ * move or reorder re-runs nothing.
443
+ */
444
+ from?: number | string
445
+ /**
446
+ * Removal exit animation: an unmounted element stays visible, animates
447
+ * the property to this value (honoring `delay`), and is freed when its
448
+ * exit animations settle. Same value forms as `from`, per-property only.
449
+ * A move never plays it, the exiting element is hit-test invisible, its
450
+ * whole subtree stays painted with it, and no onTransitionEnd fires (the
451
+ * component is already disposed). An attached element keeps its layout
452
+ * slot until the exit finishes.
453
+ */
454
+ exit?: number | string
455
+ }
456
+
457
+ /**
458
+ * The shorthand string: `"<duration>ms [curve] [<delay>ms]"` - `"300ms"` is
459
+ * a bounce-0 spring, `"300ms ease-out"` a tween, `"300ms ease-out 100ms"`
460
+ * delayed (first time value the duration, second the delay; ms only).
461
+ * Bounce, bezier control values and `from` need the object form.
462
+ */
463
+ export type TransitionShorthand = string
464
+
465
+ export type Transition = TransitionSpring | TransitionTween | TransitionShorthand
466
+
467
+ /** The property names a transition can cover (numeric scalars). */
468
+ export type TransitionPropName =
469
+ | "x"
470
+ | "y"
471
+ | "w"
472
+ | "h"
473
+ | "x1"
474
+ | "y1"
475
+ | "x2"
476
+ | "y2"
477
+ | "scrollX"
478
+ | "scrollY"
479
+ | "opacity"
480
+ | "originX"
481
+ | "originY"
482
+ | "perspective"
483
+ | "clipRadius"
484
+ | "srcX"
485
+ | "srcY"
486
+ | "srcW"
487
+ | "srcH"
488
+ | "onLength"
489
+ | "offLength"
490
+ | "dashOffset"
491
+ | "rotate"
492
+ | "rotateX"
493
+ | "rotateY"
494
+ | "scale"
495
+ | "scaleX"
496
+ | "scaleY"
497
+ | "strokeWidth"
498
+ | "radius"
499
+ | "color"
500
+
501
+ /** Payload of onTransitionEnd: which animated property finished. */
502
+ export interface TransitionEndEvent {
503
+ property: TransitionPropName
504
+ }
505
+
506
+ export interface TransitionProps {
507
+ /**
508
+ * A runtime-side transition of one of this element's properties reached
509
+ * its target (natural settles only; a cancelled or retargeted animation
510
+ * does not fire until it finally settles). Delivered to this element
511
+ * only, no bubbling.
512
+ */
513
+ onTransitionEnd?: (event: TransitionEndEvent) => void
514
+ /**
515
+ * Animate later writes of the listed properties instead of snapping:
516
+ * `transition={{ x: { duration: 400, bounce: 0.2 }, opacity: "200ms ease-out" }}`.
517
+ * `all` covers every animatable property the element has, and a bare
518
+ * string is shorthand for it: `transition="300ms ease-out"`. Only
519
+ * properties the element carries animate (a d-rect has x, a view's x is
520
+ * its transform); the initial value never animates unless the entry sets
521
+ * `from` (an enter animation), and a non-numeric write (e.g. null)
522
+ * cancels the running animation and snaps. `null` clears the
523
+ * declaration; already-running animations finish.
524
+ */
525
+ transition?:
526
+ | ({
527
+ all?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand
528
+ /**
529
+ * Group stagger (ms): every descendant enter (`from`) or exit that
530
+ * begins in the same frame under this element gets `index * stagger`
531
+ * of extra delay, in occurrence order (enters and exits cascade
532
+ * separately). Nearest declaring ancestor wins; it orchestrates
533
+ * descendants only - ordinary writes and this element's own
534
+ * lifecycle are unaffected. Adds on top of a per-entry `delay`.
535
+ */
536
+ stagger?: number
537
+ } & {
538
+ [P in TransitionPropName]?: Transition
539
+ })
540
+ | TransitionShorthand
541
+ | null
542
+ }
543
+
369
544
  // Primitives
370
545
 
371
546
  export interface WindowProps extends LayoutProps, PointerProps {
@@ -402,8 +577,8 @@ export interface WindowShaderProps {
402
577
  * type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
403
578
  */
404
579
  params?: Record<string, number | number[]>
405
- /** Extra sampler2D inputs: uniform name to texture id. */
406
- textures?: Record<string, TextureId>
580
+ /** Extra sampler2D inputs: uniform name to texture id, or `{ id, filter?, wrap? }` for a per-binding sampling override. */
581
+ textures?: TextureBindings
407
582
  /** Vertices drawn (attributeless triangles). Default 3, the covering triangle. */
408
583
  vertexCount?: number
409
584
  /**
@@ -424,18 +599,28 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
424
599
  children?: Children
425
600
  trace?: boolean
426
601
  /**
427
- * Design-space size `[w, h]` for the children: content drawn in that
428
- * coordinate space is uniformly scaled to fit and centered in the element's
429
- * box (SVG's default preserveAspectRatio, generalized). A pure fit
430
- * transform - it never sizes the element, so give the box its size with
431
- * layout props. Composed innermost: the transform props still operate in
432
- * box space, and pointer events on children arrive in design coordinates.
433
- * The overflow clip and scrollX/scrollY stay box properties too: the clip
434
- * rect is the layout box and scroll offsets are box pixels, regardless of
435
- * fit scale. The natural wrapper for parseSvg draws, or any d-* subtree
436
- * authored in fixed design units.
602
+ * Design-space size `[w, h]` (both positive) for the children: everything
603
+ * under the view - layout, paint, input - happens in that coordinate space,
604
+ * which is uniformly scaled to fit and centered in the element's box (SVG's
605
+ * viewBox with its default preserveAspectRatio, generalized off the graphics
606
+ * format and onto a layout element; the fit is always "contain"). Laid-out
607
+ * children resolve flex, percentages and text wrapping against the design
608
+ * size, so a subtree scales into any box without reflowing. The view itself
609
+ * sizes like a replaced element: its intrinsic size is the design size, one
610
+ * sized axis derives the other from the design aspect, layout props
611
+ * override, and it always shrinks to fit (its min-content size is zero). As
612
+ * a flex item it still stretches like any other under the default alignment:
613
+ * a width-only design-size view in a row takes the line's height, not the
614
+ * design height, unless the row's alignItems or its own alignSelf is not
615
+ * "stretch" (CSS's rule for an <img> in a flex row). Composed innermost: the
616
+ * transform props still operate in box space, and pointer events on children
617
+ * arrive in design coordinates. The overflow clip and scrollX/scrollY stay
618
+ * box properties: the clip rect is the layout box and scroll offsets are box
619
+ * pixels, regardless of fit scale. The natural wrapper for parseSvg draws,
620
+ * any d-* subtree authored in fixed design units, or a whole panel that
621
+ * should scale rather than reflow.
437
622
  */
438
- viewBox?: [number, number]
623
+ designSize?: [number, number]
439
624
  /**
440
625
  * Corner radii for the clip applied when overflow is non-visible (hidden,
441
626
  * clip, scroll on both axes). A single number rounds all four corners; an
@@ -458,13 +643,17 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
458
643
  * (no multisampled scratch, one render pass), but vector content - svg
459
644
  * paths, rounded corners, rotated edges - comes out hard-edged. Text and
460
645
  * axis-aligned rects look identical, so prefer it for plain UI panels.
646
+ *
647
+ * A snapshot boundary's pixels are available to the GPU stack as a live
648
+ * texture id through `snapshotTexture(ref)`.
461
649
  */
462
650
  repaintBoundary?: boolean | "snapshot" | "snapshot-no-aa"
463
651
  /**
464
652
  * Run this view's rasterized subtree through a GPU program and composite
465
- * the result in its place. Requires repaintBoundary="snapshot" (the cost
466
- * is snapshot semantics, kept explicit; declared without it the shader is
467
- * ignored with a warning). The pass is region-sized and split from content
653
+ * the result in its place. Requires a snapshot boundary
654
+ * (repaintBoundary="snapshot" or "snapshot-no-aa"; the cost is snapshot
655
+ * semantics, kept explicit; declared without one the shader is ignored
656
+ * with a warning). The pass is region-sized and split from content
468
657
  * invalidation: a params-only change re-runs just the pass against the
469
658
  * cached snapshot, so animating an effect over a static subtree never
470
659
  * re-rasterizes it.
@@ -496,8 +685,8 @@ export interface ViewShaderProps {
496
685
  * type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
497
686
  */
498
687
  params?: Record<string, number | number[]>
499
- /** Extra sampler2D inputs: uniform name to texture id. */
500
- textures?: Record<string, TextureId>
688
+ /** Extra sampler2D inputs: uniform name to texture id, or `{ id, filter?, wrap? }` for a per-binding sampling override. */
689
+ textures?: TextureBindings
501
690
  /**
502
691
  * Transparent margin in logical px on every side of the layout box, for
503
692
  * the effect to write into - glow, drop shadow, blur that bleeds past the
@@ -541,21 +730,81 @@ export interface RectProps extends PaintProps, PointerProps {
541
730
  // Strokes paint inside the box, same as `RectProps`.
542
731
  export interface OvalProps extends PaintProps, PointerProps {}
543
732
 
544
- // A line's geometry is numbers, not a path string: the segment primitive to
545
- // reach for when endpoints move (each endpoint is one property write; a path
546
- // animates by rebuilding its `d` string). Endpoints (x1/y1/x2/y2) exist on
547
- // the detached `d-line` only. A laid-out `<line>` is practically a rule -
548
- // give it a thin box (length x strokeWidth); in general it draws its layout
549
- // box's top-left-to-bottom-right diagonal. For arbitrary angles and
550
- // connectors use `d-line`; for polylines and curves, a path.
551
- export interface LineProps extends PaintProps, PointerProps {
552
- /** Dash pattern in local units: the drawn segment length. Both onLength and offLength must be set to dash; with either unset the line is solid. */
733
+ /**
734
+ * A stroke's dash pattern, on `line` and `path`. Both lengths must be set
735
+ * to dash; with either unset, or a gap of 0, the stroke is solid.
736
+ */
737
+ export interface DashProps {
738
+ /**
739
+ * The drawn length, in local units. The pattern runs continuously along
740
+ * the geometry - through a polyline's vertices and along a path's curves,
741
+ * restarting at each subpath of a path; 0 draws a dot per period (given
742
+ * round or square caps).
743
+ */
553
744
  onLength?: number
554
- /** Dash pattern in local units: the gap length. Both onLength and offLength must be set to dash; with either unset the line is solid. */
745
+ /** The gap length, in local units. */
555
746
  offLength?: number
747
+ /**
748
+ * Distance into the dash pattern at which the stroke starts, in local
749
+ * units (SVG stroke-dashoffset). Wraps around the pattern's period;
750
+ * negative values allowed. Raising it marches the dashes toward the
751
+ * geometry's start: write it every frame for marching ants, or transition
752
+ * it for a one-shot slide. Default 0.
753
+ */
754
+ dashOffset?: number
755
+ /**
756
+ * What the geometry's length counts as, in the pattern's units (SVG
757
+ * pathLength): when set, `onLength`, `offLength` and `dashOffset` are
758
+ * scaled by the actual length over it. `pathLength={1}` makes them
759
+ * fractions: `onLength={0.77} offLength={1}` draws the first 77%, and
760
+ * transitioning `onLength` from 0 to 1 draws the geometry on. Must be
761
+ * positive; unset, the pattern is in local units.
762
+ */
763
+ pathLength?: number
556
764
  }
557
765
 
558
- export interface PathProps extends PaintProps, PointerProps {
766
+ // A line's geometry is numbers, not a path string: the primitive to reach
767
+ // for when the geometry moves (each endpoint is one property write, a
768
+ // polyline is one array write; a path animates by rebuilding its `d` string).
769
+ // Two forms: the segment, whose endpoints (x1/y1/x2/y2) exist on the
770
+ // detached `d-line` only, and the polyline (`points`), which exists on both.
771
+ // A laid-out `<line>` without points is practically a rule - give it a thin
772
+ // box (length x strokeWidth); in general it draws its layout box's
773
+ // top-left-to-bottom-right diagonal. For arbitrary angles and connectors use
774
+ // `d-line`; for curves, a path. A line's stroke is centered on its geometry,
775
+ // so it straddles the box (a rect's paints inside), and the bounds it
776
+ // reports are the painted box: geometry plus stroke, on both forms.
777
+ //
778
+ // The paint defaults to `drawStyle="stroke"` (the box primitives default to
779
+ // fill). On a polyline "fill" and "stroke-and-fill" fill the polygon
780
+ // (nonzero, implicitly closed) and hit-test its interior; on the two-point
781
+ // form fill has no effect, a segment has no interior.
782
+ export interface LineProps extends PaintProps, PointerProps, DashProps {
783
+ /**
784
+ * Polyline vertices as a flat [x0, y0, x1, y1, ...] in the element's local
785
+ * space (the space x1..y2 use). Takes precedence over the endpoints while
786
+ * set. Content, not box geometry: a laid-out <line points> measures its
787
+ * box from the points' extent, like a <path> from `d`, and draws them
788
+ * unscaled. Fewer than two points draws nothing; an odd count throws. Not
789
+ * covered by transitions: animate by writing a new array.
790
+ */
791
+ points?: number[] | Float32Array | Float64Array
792
+ /**
793
+ * Close the polyline's stroke: the segment back to the first point, joined
794
+ * there instead of capped. A fill always covers the polygon (closed
795
+ * implicitly), so this is a stroke distinction. Default false.
796
+ */
797
+ closed?: boolean
798
+ }
799
+
800
+ /**
801
+ * `d` is an SVG path string; the stroke is centered on the geometry. The
802
+ * bounds a path reports (getBoundingBox, the tree, a detached capture) are
803
+ * its painted box: the geometry's tight extent (curve extrema, not control
804
+ * points) plus the stroke's reach, at a `d-path`'s x/y - not its layout box
805
+ * or the inherited one.
806
+ */
807
+ export interface PathProps extends PaintProps, PointerProps, DashProps {
559
808
  d?: string
560
809
  fillRule?: "nonzero" | "evenodd"
561
810
  }
@@ -576,6 +825,17 @@ export interface TextRunProps {
576
825
  lineHeight?: number
577
826
  fontStyle?: "normal" | "italic"
578
827
  fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
828
+ /**
829
+ * Underline in the run's own color, drawn straight through descenders
830
+ * (no skip-ink). Position and thickness come from the font's own metrics
831
+ * unless overridden; a font Impeller resolves through the system fallback
832
+ * gets the shipped Noto values.
833
+ */
834
+ textDecoration?: "none" | "underline"
835
+ /** Pixels from the baseline to the top of the underline. */
836
+ textUnderlineOffset?: number
837
+ /** Underline thickness in pixels. */
838
+ textDecorationThickness?: number
579
839
  }
580
840
 
581
841
  /**
package/src/window.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSignal, onCleanup, onSettled, flush } from "@solidjs/signals"
2
- import { requestFrame } from "flux:rendertree"
2
+ import { requestFrame, setPointerLock } from "flux:rendertree"
3
3
  import { renderFrame } from "srt:render"
4
4
  import { on, once } from "srt:events"
5
5
  import { exit } from "srt:app"
@@ -38,9 +38,12 @@ let refreshRate = 60
38
38
  * the present count, and `rate` is the current refresh rate in Hz. `tick` is paced
39
39
  * by the runtime (one refresh period per present, slow-corrected toward the wall
40
40
  * clock) so animations driven off it stay smooth even when swap-return times
41
- * jitter. performance.now() and timers report/march on this same paced timeline
42
- * (so the whole time surface freezes together under the dev tools' clock
43
- * control); for real wall-clock time use Date.now().
41
+ * jitter, and it is continuous across hot reloads: every tick an instance
42
+ * sees is on one timebase, so dt is well-defined from the second call on.
43
+ * Timers freeze together with frame callbacks under the dev tools' clock
44
+ * control, but measure their delays against the wall clock, not this paced
45
+ * timeline. performance.now() is not on it either: it is real elapsed time
46
+ * for measuring work; Date.now() is calendar time.
44
47
  * Returns a cleanup function; also auto-cleans within a reactive scope.
45
48
  */
46
49
  export function onFrame(fn: (tick: number, frame: number, rate: number) => void) {
@@ -161,6 +164,33 @@ export function windowFocused(): boolean {
161
164
  return focusedAccessor()
162
165
  }
163
166
 
167
+ /**
168
+ * Lock the pointer to the window (relative mouse mode) or release it. While
169
+ * locked the cursor is hidden and confined, clientX/clientY freeze at the
170
+ * lock point, and mouse motion keeps reporting through the pointer events'
171
+ * movementX/movementY - the mouse-look primitive. Window-level, no
172
+ * permission dance: one app, one window. Observe the applied state through
173
+ * pointerLocked(); the platform can refuse (no relative mode) and the OS
174
+ * may drop the lock, e.g. on focus loss.
175
+ */
176
+ export function lockPointer(locked: boolean) {
177
+ if (typeof locked !== "boolean") throw new Error(`lockPointer: expected a boolean, got ${typeof locked}`)
178
+ setPointerLock(locked)
179
+ }
180
+
181
+ let pointerLockedAccessor: (() => boolean) | undefined
182
+
183
+ /** Whether the pointer is currently locked (relative mouse mode), as a reactive accessor. */
184
+ export function pointerLocked(): boolean {
185
+ if (!pointerLockedAccessor) {
186
+ let [locked, setLocked] = createSignal(false)
187
+ // Sticky event: a subscriber after the lock still observes the state.
188
+ on("pointerLock", ({ locked }: { locked: boolean }) => setLocked(locked))
189
+ pointerLockedAccessor = locked
190
+ }
191
+ return pointerLockedAccessor()
192
+ }
193
+
164
194
  let keyboardHeightAccessor: (() => number) | undefined
165
195
 
166
196
  /**
@@ -178,14 +208,49 @@ export function keyboardHeight(): number {
178
208
  return keyboardHeightAccessor()
179
209
  }
180
210
 
211
+ // Post-layout handlers run in registration order from one bus subscription,
212
+ // then pending reactive writes are flushed once. The postLayout emit is a JS
213
+ // entry inside the frame (layout has run, paint has not), and the runtime's
214
+ // microtask checkpoint only comes after the whole frame closure, so a signal
215
+ // write made by a handler would otherwise reach its node a frame late. The
216
+ // drain lives here, the way runFrame drains before renderFrame, so that no
217
+ // handler has to flush for itself. A throwing handler skips neither the
218
+ // handlers after it nor the flush.
219
+ let layoutHandlers: (() => void)[] = []
220
+ let layoutSubscribed = false
221
+
222
+ function runLayoutHandlers() {
223
+ for (let fn of [...layoutHandlers]) {
224
+ try {
225
+ fn()
226
+ } catch (err) {
227
+ console.error("Error in onLayout handler:", err)
228
+ }
229
+ }
230
+ try {
231
+ flush()
232
+ } catch (err) {
233
+ console.error("Error in reactive flush:", err)
234
+ }
235
+ }
236
+
181
237
  /**
182
238
  * Fires after layout has been computed for the current frame but before paint.
183
- * Setting properties that affect layout from this callback will be picked up
184
- * by a re-layout pass before painting (one extra pass; cascades beyond that
185
- * paint stale).
239
+ * Property writes made from this callback, direct or through signals (pending
240
+ * reactive writes are flushed once every handler has run), are picked up by a
241
+ * re-layout pass before painting (one extra pass; cascades beyond that paint
242
+ * stale).
186
243
  */
187
244
  export function onLayout(fn: () => void) {
188
- let unsubscribe = on("postLayout", fn)
245
+ if (!layoutSubscribed) {
246
+ layoutSubscribed = true
247
+ on("postLayout", runLayoutHandlers)
248
+ }
249
+ layoutHandlers.push(fn)
250
+ let unsubscribe = () => {
251
+ let i = layoutHandlers.indexOf(fn)
252
+ if (i >= 0) layoutHandlers.splice(i, 1)
253
+ }
189
254
  onCleanup(unsubscribe)
190
255
  return unsubscribe
191
256
  }
@@ -244,10 +309,20 @@ export function onBack(fn: (e: BackEvent) => void) {
244
309
 
245
310
  // ------ Window ----------------
246
311
 
247
- export function attachWindow(nodeId: number) {
312
+ // The window root's id: the key-routing fallback target and the pointer
313
+ // interest root. Set by attachWindow, moved by setWindowRoot when render()'s
314
+ // error boundary swaps the app's window for the error window and back.
315
+ let windowRootId = 0
316
+
317
+ export function setWindowRoot(nodeId: number) {
318
+ windowRootId = nodeId
248
319
  // The root carries the ambient move-interest bit for global onPointerMove
249
320
  // subscribers (it is on every hit path); see core.setInterestRoot.
250
321
  setInterestRoot(nodeId)
322
+ }
323
+
324
+ export function attachWindow(nodeId: number) {
325
+ setWindowRoot(nodeId)
251
326
  let unsubscribe: () => void = null!
252
327
  let unsubDown: () => void = null!
253
328
  let unsubUp: () => void = null!
@@ -255,6 +330,7 @@ export function attachWindow(nodeId: number) {
255
330
  let unsubEnter: () => void = null!
256
331
  let unsubLeave: () => void = null!
257
332
  let unsubWheel: () => void = null!
333
+ let unsubTransitionEnd: () => void = null!
258
334
  let unsubKeyDown: () => void = null!
259
335
  let unsubKeyUp: () => void = null!
260
336
  let unsubBack: () => void = null!
@@ -263,8 +339,16 @@ export function attachWindow(nodeId: number) {
263
339
  let unsubRefreshRate: () => void = null!
264
340
  let unsubFirstResize: (() => void) | null = null
265
341
 
266
- function runFrame(t: number, frame: number) {
267
- if (animationFrames.size > 0) {
342
+ // `bootstrap` marks the synthetic first frame (see the first-resize
343
+ // subscription below): it flushes and paints the freshly initialized graph
344
+ // but does not invoke onFrame callbacks, because its timestamp is not a
345
+ // reading of the frame timeline - handing apps a zero tick gave every
346
+ // reloaded instance one enormous dt on the next real frame
347
+ // (okf/done/onframe-tick-reset-on-reload.md). The callbacks stay
348
+ // registered and run on the first real render event, before the first
349
+ // paint with their writes applied.
350
+ function runFrame(t: number, frame: number, bootstrap = false) {
351
+ if (!bootstrap && animationFrames.size > 0) {
268
352
  let frames = animationFrames
269
353
  animationFrames = new Map()
270
354
  for (let fn of frames.values()) fn(t, frame, refreshRate)
@@ -368,6 +452,17 @@ export function attachWindow(nodeId: number) {
368
452
  bubble(raw, "onWheel")
369
453
  })
370
454
 
455
+ // A native transition finished (runtime-side animation; see the
456
+ // `transition` prop). Target-only delivery, no bubbling: the element
457
+ // that declared the transition is the one interested in its end.
458
+ unsubTransitionEnd = on("transitionEnd", (raw: { target: number; property: string }) => {
459
+ try {
460
+ getEventHandler(raw.target, "onTransitionEnd")?.({ property: raw.property })
461
+ } catch (err) {
462
+ console.error("Error in onTransitionEnd handler:", err)
463
+ }
464
+ })
465
+
371
466
  // Key events dispatch along the focused node's ancestor chain, leaf->root
372
467
  // (the pointer bubbling contract), so a container hears keys from focused
373
468
  // descendants and the window root hears everything: <window onKeyDown> is
@@ -376,13 +471,13 @@ export function attachWindow(nodeId: number) {
376
471
  // at dispatch time from current focus (nothing to freeze: keyup follows
377
472
  // focus, as in the DOM).
378
473
  let dispatchKey = (raw: any, handler: string) => {
379
- let target = focusedNode() ?? nodeId
474
+ let target = focusedNode() ?? windowRootId
380
475
  let stopped = false
381
476
  let e = { ...raw, target, stopPropagation: () => (stopped = true) }
382
477
  let path = getNodePath(target)
383
478
  // A focused node detached this tick has no chain to the root; the
384
479
  // window root must still hear the key.
385
- if (path[path.length - 1] !== nodeId) path.push(nodeId)
480
+ if (path[path.length - 1] !== windowRootId) path.push(windowRootId)
386
481
  for (let id of path) {
387
482
  e.currentTarget = id
388
483
  getEventHandler(id, handler)?.(e)
@@ -429,7 +524,7 @@ export function attachWindow(nodeId: number) {
429
524
  // where flush() is illegal (not reentrant). Defer runFrame to a microtask
430
525
  // so the first frame always runs after this callback returns.
431
526
  unsubFirstResize = once("resize", () => {
432
- queueMicrotask(() => runFrame(0, 0))
527
+ queueMicrotask(() => runFrame(0, 0, true))
433
528
  })
434
529
  })
435
530
 
@@ -442,6 +537,7 @@ export function attachWindow(nodeId: number) {
442
537
  if (unsubEnter) unsubEnter()
443
538
  if (unsubLeave) unsubLeave()
444
539
  if (unsubWheel) unsubWheel()
540
+ if (unsubTransitionEnd) unsubTransitionEnd()
445
541
  if (unsubKeyDown) unsubKeyDown()
446
542
  if (unsubKeyUp) unsubKeyUp()
447
543
  if (unsubBack) unsubBack()