@solidrt/core 0.0.46 → 0.0.48

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.
package/AGENTS.md CHANGED
@@ -174,6 +174,22 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
174
174
  app-global shortcut point; `stopPropagation()` ends the walk. `focusable`
175
175
  declares focus-navigation candidacy (enumerate via getFocusables()).
176
176
 
177
+ - Gesture recognizers, shared by every package: `createPan` (single-pointer
178
+ drag, axis-aware slop, per-event dx/dy) and `createTransform` (merged
179
+ pan + pinch + rotate over the whole pointer set, Flutter-Scale style: streams
180
+ `{ dx, dy, scale, rotation, x, y, pointers }` once per FRAME - positions
181
+ update per event, but the cross-pointer measure waits for the
182
+ `pointerFrame` batch terminator, when every pointer is the same age; one
183
+ finger degrades to a plain pan, and `pointers` is how a consumer gives one-
184
+ and two-finger translation different meanings - dx/dy alone cannot tell
185
+ them apart).
186
+ Spread the returned `.handlers` onto the receiving element. They
187
+ arbitrate through the exported `arena` (ONE per app): a press claims its
188
+ pointer provisionally, movement evidence steals and resolves it, the loser's
189
+ `cancel()` retracts its feedback. Custom recognizers should join the arena
190
+ rather than track pointers ad hoc, or they will double-handle against
191
+ scrollers and pressables.
192
+
177
193
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
178
194
  is as you expect, but `createEffect` takes the 2.0 two-function shape: a
179
195
  TRACKED compute that reads signals and returns a value, then an UNTRACKED
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.46"
30
+ "@solidrt/flux-types": "0.0.48"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-beta.31",
package/src/arena.ts ADDED
@@ -0,0 +1,60 @@
1
+ // The gesture arena: per-pointer ownership arbitration between recognizers.
2
+ // Raw pointer events keep bubbling along the frozen down path regardless; the
3
+ // arena only decides which recognizer's gesture a pointer belongs to. It lives
4
+ // in core beside that routing (window.ts) because arbitration only works with
5
+ // exactly ONE claims map per app: recognizers from every package (components'
6
+ // press and pan, 3d's orbit transform, future runtime-level recognizers) must
7
+ // see each other's claims, and two arenas cannot arbitrate against each other.
8
+ //
9
+ // Two claim strengths mirror how gestures resolve. A press claims its pointer
10
+ // provisionally on the down: it is the presumed winner (innermost-wins falls
11
+ // out of leaf-to-root dispatch, first claim sticks) but a later recognizer
12
+ // with positive evidence of a different gesture (a pan crossing its movement
13
+ // slop) may steal the pointer, cancelling the press. A steal resolves the
14
+ // arena: the pointer is won outright and cannot be stolen again, so e.g. the
15
+ // outer axis of two nested scrollers cannot take a drag the inner one already
16
+ // owns. Plain state on purpose: claims must be visible across recognizers
17
+ // within one synchronous bubble dispatch, before any signal flush.
18
+
19
+ export type ArenaOwner = {
20
+ /** Retract the gesture without firing; invoked when the pointer is stolen. */
21
+ cancel(): void
22
+ }
23
+
24
+ type Claim = { owner: ArenaOwner; resolved: boolean }
25
+
26
+ let claims = new Map<number, Claim>()
27
+
28
+ export let arena = {
29
+ /**
30
+ * Provisionally claim an unowned pointer. Returns false (claim refused) when
31
+ * any recognizer already owns it. The claim is stealable until the owner
32
+ * releases it or a steal resolves the arena.
33
+ */
34
+ claim(pointerId: number, owner: ArenaOwner): boolean {
35
+ if (claims.has(pointerId)) return false
36
+ claims.set(pointerId, { owner, resolved: false })
37
+ return true
38
+ },
39
+
40
+ /**
41
+ * Take the pointer on positive evidence of a gesture, cancelling the current
42
+ * provisional owner (if any), and resolve the arena: the resulting claim
43
+ * cannot be stolen. Returns false when the arena is already resolved, in
44
+ * which case the caller lost and must stand down.
45
+ */
46
+ steal(pointerId: number, owner: ArenaOwner): boolean {
47
+ let current = claims.get(pointerId)
48
+ if (current) {
49
+ if (current.resolved) return false
50
+ current.owner.cancel()
51
+ }
52
+ claims.set(pointerId, { owner, resolved: true })
53
+ return true
54
+ },
55
+
56
+ /** Release the claim on a pointer, if `owner` still holds it. */
57
+ release(pointerId: number, owner: ArenaOwner): void {
58
+ if (claims.get(pointerId)?.owner === owner) claims.delete(pointerId)
59
+ },
60
+ }
package/src/color.ts CHANGED
@@ -7,10 +7,13 @@ extend([namesPlugin, mixPlugin])
7
7
  * Parses a CSS color string (named, hex, `rgb()`, `hsl()`, ...) into a packed
8
8
  * `0xRRGGBBAA` u32: red in the high byte, alpha in the low byte. Alpha is scaled
9
9
  * from colord's 0..1 to 0..255. This is the wire format the runtime expects for
10
- * the `color` property.
10
+ * the `color` property. Throws on a string that is not a valid CSS color, so a
11
+ * typo fails on the line that wrote it instead of silently painting black.
11
12
  */
12
13
  export function parseColor(color: string): number {
13
- let { r, g, b, a } = colord(color).toRgb()
14
+ let c = colord(color)
15
+ if (!c.isValid()) throw new Error(`Invalid color "${color}"`)
16
+ let { r, g, b, a } = c.toRgb()
14
17
  return (((r & 0xFF) << 24) | ((g & 0xFF) << 16) | ((b & 0xFF) << 8) | ((a * 255) & 0xFF)) >>> 0
15
18
  }
16
19
 
package/src/gpu.ts CHANGED
@@ -178,24 +178,22 @@ export {
178
178
  */
179
179
  export let glsl = String.raw
180
180
 
181
- // captureSnapshot renders a node to a texture and readTexture reads any
182
- // texture's bytes back. A laid-out node captures its layout box; a `d-*` node
183
- // captures its painted box - its own w/h when set, else the nearest laid-out
184
- // ancestor's box, its x/y offset mapped to the texture origin. Re-exported raw
185
- // (no reactive auto-cleanup wrapper):
186
- // captureSnapshot resolves asynchronously, by which point the reactive owner is
187
- // no longer current, so the caller owns the returned id and frees it with
188
- // destroyTexture (as with any texture created after an await).
181
+ // captureSnapshot renders a node to pixels and readTexture reads any
182
+ // texture's bytes back; both resolve the same { width, height, data } shape.
183
+ // A laid-out node captures its layout box; a `d-*` node captures its painted
184
+ // box - its own w/h when set, else the nearest laid-out ancestor's box, its
185
+ // x/y offset mapped to the texture origin. A capture creates no texture and
186
+ // nothing needs freeing; to display or sample the result, upload it with
187
+ // createTexture.
189
188
  //
190
- // Together they are the one-shot bake path: draw something only the engine can
191
- // produce (shaped text, an SVG, a themed view), capture it, read the pixels and
192
- // process them on the CPU - baking a glyph atlas is the worked example. Not a
193
- // rendering path: a capture rasterizes the subtree offscreen, reads it back to
194
- // the CPU and re-uploads it, costing a full GPU -> CPU -> GPU round trip and a
195
- // paint pass of latency every call. Batch captures (one paint pass services
196
- // many), never run them per frame, and do not use them to feed live screen
197
- // content into a shader - for that the source has to update in place (another
198
- // pipeline's target, a camera texture).
189
+ // This is the one-shot bake path: draw something only the engine can produce
190
+ // (shaped text, an SVG, a themed view), capture it and process the pixels on
191
+ // the CPU - baking a glyph atlas is the worked example. Not a rendering path:
192
+ // a capture rasterizes the subtree offscreen and reads it back to the CPU,
193
+ // costing a readback stall and a paint pass of latency every call. Batch
194
+ // captures (one paint pass services many), never run them per frame, and do
195
+ // not use them to feed live screen content into a shader - for that the
196
+ // source has to update in place (another pipeline's target, a camera texture).
199
197
  export { captureSnapshot, readTexture } from "flux:gpu"
200
198
 
201
199
  /**
package/src/index.ts CHANGED
@@ -20,7 +20,14 @@ export { parseSvg, svg } from "./svg"
20
20
  export type { SvgDocument, SvgDraw } from "./svg"
21
21
  export { createScroll } from "./scroll"
22
22
  export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
23
+ export { arena } from "./arena"
24
+ export type { ArenaOwner } from "./arena"
25
+ export { createPan } from "./pan"
26
+ export type { PanAxis, PanOptions } from "./pan"
27
+ export { createTransform } from "./transform"
28
+ export type { TransformDelta, TransformOptions } from "./transform"
23
29
  export type {
30
+ Element,
24
31
  LayoutProps,
25
32
  TransformProps,
26
33
  PointerProps,
@@ -77,6 +84,13 @@ export {
77
84
  } from "@solidjs/signals"
78
85
  export type { Accessor, Setter, Signal, Store, StoreSetter } from "@solidjs/signals"
79
86
 
87
+ // Owner and lifecycle (from @solidjs/signals). `createRoot` above is the fourth
88
+ // member of this group - it creates an owned scope, these reach the owner it
89
+ // established. Capture an owner to restore it across an async boundary
90
+ // (`runWithOwner(owner, ...)`), or pass null to detach.
91
+ export { getOwner, runWithOwner, createOwner, isDisposed } from "@solidjs/signals"
92
+ export type { Owner } from "@solidjs/signals"
93
+
80
94
  // Control flow, components, and context (from solid-js).
81
95
  export {
82
96
  For,
package/src/pan.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { onSettled } from "@solidjs/signals"
2
+ import type { PointerEvent } from "./types"
3
+ import { arena } from "./arena"
4
+
5
+ // Movement in logical pixels before a pan activates. Below this a drag still
6
+ // reads as a press (tap wiggle); crossing it is the positive evidence that the
7
+ // gesture is a pan, at which point the pan steals the pointer in the arena.
8
+ const PAN_SLOP = 8
9
+
10
+ export type PanAxis = "vertical" | "horizontal" | "both"
11
+
12
+ export interface PanOptions {
13
+ /**
14
+ * Which movement direction activates the pan. Slop is axis-aware: a
15
+ * "vertical" pan only activates once vertical travel crosses the threshold,
16
+ * so nested cross-axis scrollers each take only drags along their own axis.
17
+ * Default "both" (straight-line distance).
18
+ */
19
+ axis?: PanAxis
20
+ onPanStart?: () => void
21
+ /** Pointer movement since the previous event; positive dx is rightward, positive dy downward. */
22
+ onPanMove?: (dx: number, dy: number) => void
23
+ onPanEnd?: () => void
24
+ }
25
+
26
+ // The pan recognizer: turns a drag into a movement-delta stream. On a down it
27
+ // arms and starts measuring; when travel from the down point crosses the slop
28
+ // along the enabled axis it activates, stealing the pointer in the arena (a
29
+ // press that provisionally owned it is cancelled - its feedback retracts) and
30
+ // resolving it so no other recognizer can take the drag over. If the arena is
31
+ // already resolved (an inner pan won first) the recognizer disarms and stays
32
+ // out. The slop distance itself is swallowed: deltas stream from the
33
+ // activation point on. Moves and the up arrive on the frozen down path, so an
34
+ // active pan keeps streaming when the pointer leaves the node or the window.
35
+ // cancel() is the external-cancel hook; it ends an active pan without
36
+ // onPanEnd. Options are read at event time. Single-pointer by design; for
37
+ // multi-pointer pinch/rotate compose createTransform instead.
38
+ export function createPan(options: PanOptions) {
39
+ // Down position while armed; last delivered position while active.
40
+ let origin: { x: number; y: number } | null = null
41
+ let active: number | null = null
42
+ let armed: number | null = null
43
+
44
+ let past = (e: PointerEvent) => {
45
+ if (!origin) return false
46
+ let dx = Math.abs(e.clientX - origin.x)
47
+ let dy = Math.abs(e.clientY - origin.y)
48
+ let axis = options.axis ?? "both"
49
+ if (axis === "vertical") return dy >= PAN_SLOP
50
+ if (axis === "horizontal") return dx >= PAN_SLOP
51
+ return dx * dx + dy * dy >= PAN_SLOP * PAN_SLOP
52
+ }
53
+
54
+ let reset = () => {
55
+ if (active != null) {
56
+ arena.release(active, owner)
57
+ active = null
58
+ }
59
+ armed = null
60
+ origin = null
61
+ }
62
+ let cancel = reset
63
+ let owner = { cancel }
64
+
65
+ // An unmount mid-drag must not leave a resolved claim behind.
66
+ onSettled(() => reset)
67
+
68
+ let handlers = {
69
+ onPointerDown: (e: PointerEvent) => {
70
+ if (e.button != null && e.button !== 0) return
71
+ if (armed == null && active == null) {
72
+ armed = e.pointerId
73
+ origin = { x: e.clientX, y: e.clientY }
74
+ }
75
+ },
76
+ onPointerMove: (e: PointerEvent) => {
77
+ if (armed === e.pointerId && past(e)) {
78
+ if (arena.steal(e.pointerId, owner)) {
79
+ active = e.pointerId
80
+ armed = null
81
+ origin = { x: e.clientX, y: e.clientY }
82
+ options.onPanStart?.()
83
+ } else {
84
+ // The arena is resolved against us; the drag belongs elsewhere.
85
+ reset()
86
+ }
87
+ return
88
+ }
89
+ if (active === e.pointerId && origin) {
90
+ options.onPanMove?.(e.clientX - origin.x, e.clientY - origin.y)
91
+ origin = { x: e.clientX, y: e.clientY }
92
+ }
93
+ },
94
+ onPointerUp: (e: PointerEvent) => {
95
+ if (active === e.pointerId) {
96
+ reset()
97
+ options.onPanEnd?.()
98
+ } else if (armed === e.pointerId) {
99
+ reset()
100
+ }
101
+ },
102
+ }
103
+
104
+ return { handlers, cancel }
105
+ }
package/src/renderer.ts CHANGED
@@ -148,8 +148,11 @@ function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
148
148
  try {
149
149
  tree.setProperty(node.id, name, value)
150
150
  } catch (e) {
151
+ // Name-level rejections (the exact prefixes apply_jsx in flux emits) are
152
+ // warn-and-continue so a stale prop does not kill the app; a bad VALUE for
153
+ // a known property rethrows, per the throw-in-dev validation policy.
151
154
  let message = String(e)
152
- if (!message.includes("unknown property") && !message.includes("detached-only")) throw e
155
+ if (!message.includes("Unknown property") && !message.includes("Detached-only")) throw e
153
156
  let key = node.elementType + "." + name
154
157
  if (warnedRejectedProps.has(key)) return
155
158
  warnedRejectedProps.add(key)
@@ -49,7 +49,11 @@ declare module "*.ogg" {
49
49
  }
50
50
 
51
51
  // UI event bus (lattice), provided by the runtime as a builtin module.
52
- // on/once return an unsubscribe function.
52
+ // on/once return an unsubscribe function. Notable events: the routed pointer
53
+ // stream ("pointerMove"/"pointerDown"/... consumed by window.ts),
54
+ // "pointerFrame" (the move-batch terminator: fires after all of a frame's
55
+ // pointer moves have dispatched, every pointer the same age - multi-pointer
56
+ // recognizers measure there), and "render" (the per-frame signal).
53
57
  declare module "srt:events" {
54
58
  export function on(event: string, callback: (data: any) => void): () => void
55
59
  export function once(event: string, callback: (data: any) => void): () => void
@@ -0,0 +1,300 @@
1
+ import { onSettled } from "@solidjs/signals"
2
+ import { on } from "srt:events"
3
+ import type { PointerEvent } from "./types"
4
+ import { arena, type ArenaOwner } from "./arena"
5
+
6
+ // Focal travel or span change in logical pixels before the transform
7
+ // activates; the same threshold createPan uses, so the two race fairly.
8
+ const SLOP = 8
9
+ // Per-frame EMA weight for the span that feeds `scale` (~50ms time constant
10
+ // at a 65Hz frame rate - see the zoom-noise note below). The pre-batching
11
+ // weight was 0.15 stepped per event at ~130Hz combined; steps now come once
12
+ // per frame, so the weight compounds: 1 - (1 - 0.15)^2.
13
+ const SPAN_SMOOTH = 0.28
14
+ // Pinch-vs-hold discrimination on the span RATE, an EMA (QUIET_SMOOTH
15
+ // weight) of the smoothed span's per-frame change. Engaging needs both a
16
+ // SLOP excursion and rate >= ENGAGE (~40px/s at 65Hz); an engaged pinch
17
+ // re-locks when rate falls under QUIET (~20px/s). Creep sits near 0.14
18
+ // px/frame, deliberate pinches at 1.5+ - the 2x gap between the two
19
+ // thresholds is the hysteresis that keeps the gate from chattering.
20
+ // All three step-based constants assume a ~65Hz frame rate (the captured
21
+ // tablet); a 120Hz panel halves per-step deltas. If that ever bites,
22
+ // convert the thresholds to px/s with performance.now() read at the
23
+ // pointerFrame terminator.
24
+ const QUIET = 0.3
25
+ const ENGAGE = 0.6
26
+ const QUIET_SMOOTH = 0.19
27
+
28
+ export type TransformDelta = {
29
+ /** Focal-point movement since the previous frame, logical px. */
30
+ dx: number
31
+ dy: number
32
+ /** Multiplicative span change since the previous frame (1 = unchanged, >1 = fingers spreading). */
33
+ scale: number
34
+ /** Rotation of the pointer pair since the previous frame, radians (0 with a single pointer). */
35
+ rotation: number
36
+ /** Current focal point in window coordinates (the clientX/clientY frame) - the zoom-about anchor. */
37
+ x: number
38
+ y: number
39
+ /** How many pointers are down right now. Consumers that give one- and
40
+ * two-finger translation different meanings (rotate vs pan) route on this;
41
+ * dx/dy alone cannot tell them apart, both are focal movement. */
42
+ pointers: number
43
+ }
44
+
45
+ export interface TransformOptions {
46
+ onTransformStart?: () => void
47
+ /** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
48
+ onTransformMove?: (t: TransformDelta) => void
49
+ onTransformEnd?: () => void
50
+ }
51
+
52
+ // The merged transform recognizer: pan + pinch + rotate as ONE gesture over
53
+ // the set of pointers down on the node (Flutter's Scale model). Splitting
54
+ // them into separate recognizers would make them fight in the arena over the
55
+ // same fingers, so the genuinely-simultaneous case (drag while pinching a
56
+ // photo, one-finger orbit vs two-finger zoom) is a single recognizer that
57
+ // streams focal-point translation, span scale, and pair rotation together;
58
+ // consumers use the components they care about (an orbit camera reads dx/dy
59
+ // and scale, ignores rotation).
60
+ //
61
+ // Pointers arm silently on their down (no arena claim); when focal travel or
62
+ // span change from the armed configuration crosses the slop, that is positive
63
+ // evidence and the recognizer steals EVERY tracked pointer, all or nothing:
64
+ // if any is already resolved elsewhere (an inner pan won that finger) the
65
+ // whole gesture stands down. The slop is swallowed - deltas stream from the
66
+ // activation configuration on. With one finger it degrades to a plain pan
67
+ // (scale 1, rotation 0), so a drag on the node gets arena arbitration too.
68
+ //
69
+ // Measurement is per FRAME, not per event. Move events update pointer
70
+ // positions and arena state as they bubble (claims must stay visible
71
+ // synchronously within the dispatch walk), but the cross-pointer measure -
72
+ // centroid, span, angle - waits for the "pointerFrame" terminator the
73
+ // runtime emits after all of a frame's moves have dispatched. At that point
74
+ // every pointer is the same age, so the per-event scissor is gone by
75
+ // construction (measuring on each event paired one fresh position with the
76
+ // other pointers' frame-stale ones, and the span oscillated around its true
77
+ // value under perfectly smooth motion), and one delta emits per frame - the
78
+ // cadence anything painting consumes anyway. Anchors defer to the same
79
+ // terminator for the same reason: a mid-batch anchor would bake one
80
+ // mixed-age jolt into the first delta after it.
81
+ //
82
+ // Residual span noise survives batching, tuned against a captured tablet
83
+ // stream (sensor dither +-1-3px/event - and a resting pair's span WANDERS
84
+ // 28-76px over seconds, human fingers cannot hold separation, so no
85
+ // threshold alone can stay closed):
86
+ // - a slop gate: scale is 1 until the span leaves its gesture baseline by
87
+ // SLOP, so a pan or hold that never really pinches never zooms;
88
+ // - a low-pass: once engaged, scale comes from an EMA of the span
89
+ // (SPAN_SMOOTH). Zero-mean dither averages out; deliberate pinching - a
90
+ // sustained signed drift - passes with ~50ms lag. With the scissor gone
91
+ // this is belt-and-braces; a retirement candidate after an on-device
92
+ // A/B against the raw batched span.
93
+ // - a re-lock: pressed fingertips that HOLD still slowly flatten and roll
94
+ // toward each other, which the sensor reports as a genuine ~9px/s span
95
+ // shrink - a held pinch would zoom out forever. Deliberate pinches
96
+ // measured 100-160px/s, an order of magnitude above creep, so when the
97
+ // smoothed span rate stays under QUIET the gate re-locks and rebases;
98
+ // crossing SLOP from the new base re-engages instantly. No amount of
99
+ // batching removes creep: it is real reported motion.
100
+ // dx/dy have no gate or filter: their noise term is a fraction of a pixel.
101
+ //
102
+ // Set changes rebase: a finger joining (stolen outright - the gesture is
103
+ // already established) or lifting mid-gesture re-anchors the reference
104
+ // configuration, so the change itself never emits a jump delta. The gesture
105
+ // ends when the last finger lifts. Moves and ups arrive on the frozen down
106
+ // path, so an active transform survives leaving the node or the window.
107
+ // cancel() is the external-cancel hook; it ends an active gesture without
108
+ // onTransformEnd. Options are read at event time.
109
+ export function createTransform(options: TransformOptions) {
110
+ // All tracked pointers, in down order (the first two define the rotation pair).
111
+ let pointers = new Map<number, { x: number; y: number }>()
112
+ let active = false
113
+ // Slop baseline while armed; last delivered configuration while active.
114
+ let ref: { x: number; y: number; span: number; angle: number } | null = null
115
+ // The zoom gate and filter (see the zoom-noise note above): scale stays 1
116
+ // until the SMOOTHED span leaves spanBase by SLOP, then streams smoothed
117
+ // ratios for the rest of the gesture.
118
+ let pinch = false
119
+ let spanBase = 0
120
+ let smoothSpan = 0
121
+ // Warm-started at each engage so a fresh pinch cannot instantly re-lock.
122
+ let spanRate = 0
123
+ // Motion arrived this frame; measured and emitted at the terminator.
124
+ let dirty = false
125
+ // The anchor must be retaken at the terminator (activation and set-change
126
+ // rebases): mid-batch the pointer map is mixed-age, so anchoring
127
+ // immediately would bake a jolt into the next delta. A rebase frame
128
+ // emits nothing.
129
+ let rebase = false
130
+
131
+ let measure = () => {
132
+ let n = pointers.size
133
+ let x = 0
134
+ let y = 0
135
+ for (let p of pointers.values()) {
136
+ x += p.x
137
+ y += p.y
138
+ }
139
+ x /= n
140
+ y /= n
141
+ let span = 0
142
+ for (let p of pointers.values()) span += Math.hypot(p.x - x, p.y - y)
143
+ span /= n
144
+ let angle = 0
145
+ if (n >= 2) {
146
+ let pair = pointers.values()
147
+ let a = pair.next().value!
148
+ let b = pair.next().value!
149
+ angle = Math.atan2(b.y - a.y, b.x - a.x)
150
+ }
151
+ return { x, y, span, angle }
152
+ }
153
+
154
+ let reset = () => {
155
+ if (active) for (let id of pointers.keys()) arena.release(id, owner)
156
+ pointers.clear()
157
+ active = false
158
+ ref = null
159
+ pinch = false
160
+ dirty = false
161
+ rebase = false
162
+ }
163
+ let cancel = reset
164
+ let owner: ArenaOwner = { cancel }
165
+
166
+ // The per-frame measure point: runs at the pointerFrame terminator, when
167
+ // every tracked pointer's position is the same age.
168
+ let flush = () => {
169
+ if (!active) return
170
+ if (rebase) {
171
+ // Anchor from same-age positions; emits nothing - an activation or
172
+ // set change must not produce a jump delta. Motion that arrived in
173
+ // the same batch folds into the anchor.
174
+ ref = measure()
175
+ spanBase = ref.span
176
+ smoothSpan = ref.span
177
+ rebase = false
178
+ dirty = false
179
+ return
180
+ }
181
+ if (!dirty || !ref) return
182
+ dirty = false
183
+ let m = measure()
184
+ let prevSpan = smoothSpan
185
+ smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
186
+ spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
187
+ if (!pinch && Math.abs(smoothSpan - spanBase) >= SLOP) {
188
+ // A slop excursion at speed is a pinch; at creep speed the base
189
+ // just follows, so drift can wander forever without engaging.
190
+ if (spanRate >= ENGAGE) pinch = true
191
+ else spanBase = smoothSpan
192
+ }
193
+ let scale = pinch && prevSpan > 0 && smoothSpan > 0 ? smoothSpan / prevSpan : 1
194
+ if (pinch && spanRate < QUIET) {
195
+ // Holding, not pinching (see the re-lock note above).
196
+ pinch = false
197
+ spanBase = smoothSpan
198
+ }
199
+ let rotation = 0
200
+ if (pointers.size >= 2) {
201
+ rotation = m.angle - ref.angle
202
+ if (rotation > Math.PI) rotation -= 2 * Math.PI
203
+ else if (rotation < -Math.PI) rotation += 2 * Math.PI
204
+ }
205
+ let dx = m.x - ref.x
206
+ let dy = m.y - ref.y
207
+ ref = m
208
+ options.onTransformMove?.({ dx, dy, scale, rotation, x: m.x, y: m.y, pointers: pointers.size })
209
+ }
210
+
211
+ // An unmount mid-gesture must not leave resolved claims behind (or a live
212
+ // terminator subscription).
213
+ onSettled(() => {
214
+ let unsub = on("pointerFrame", flush)
215
+ return () => {
216
+ unsub()
217
+ reset()
218
+ }
219
+ })
220
+
221
+ let handlers = {
222
+ onPointerDown: (e: PointerEvent) => {
223
+ if (e.button != null && e.button !== 0) return
224
+ if (pointers.has(e.pointerId)) return
225
+ if (active) {
226
+ // A finger joining an established gesture belongs to it outright; if
227
+ // the arena refuses (resolved elsewhere) the finger stays out.
228
+ if (!arena.steal(e.pointerId, owner)) return
229
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
230
+ rebase = true
231
+ return
232
+ }
233
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
234
+ ref = measure()
235
+ },
236
+ onPointerMove: (e: PointerEvent) => {
237
+ let p = pointers.get(e.pointerId)
238
+ if (!p || !ref) return
239
+ p.x = e.clientX
240
+ p.y = e.clientY
241
+ if (!active) {
242
+ // Arming runs per event: the slop test tolerates a mixed-age
243
+ // measure (8px against <=1 frame of staleness), and the arena
244
+ // steal must happen synchronously inside the dispatch walk.
245
+ let m = measure()
246
+ let travel = Math.hypot(m.x - ref.x, m.y - ref.y)
247
+ if (travel < SLOP && Math.abs(m.span - ref.span) < SLOP) return
248
+ let taken: number[] = []
249
+ let refused = false
250
+ for (let id of pointers.keys()) {
251
+ if (arena.steal(id, owner)) taken.push(id)
252
+ else {
253
+ refused = true
254
+ break
255
+ }
256
+ }
257
+ if (refused) {
258
+ // The gesture belongs elsewhere; hand back what we took and disarm.
259
+ for (let id of taken) arena.release(id, owner)
260
+ pointers.clear()
261
+ ref = null
262
+ return
263
+ }
264
+ active = true
265
+ // A span-driven activation IS a deliberate pinch - engaging the
266
+ // zoom gate now avoids demanding a second slop-crossing from it.
267
+ pinch = Math.abs(m.span - ref.span) >= SLOP
268
+ spanRate = pinch ? 1 : 0
269
+ rebase = true
270
+ options.onTransformStart?.()
271
+ return
272
+ }
273
+ dirty = true
274
+ },
275
+ onPointerUp: (e: PointerEvent) => {
276
+ if (!pointers.has(e.pointerId)) return
277
+ if (active) {
278
+ arena.release(e.pointerId, owner)
279
+ pointers.delete(e.pointerId)
280
+ if (pointers.size === 0) {
281
+ active = false
282
+ ref = null
283
+ pinch = false
284
+ dirty = false
285
+ rebase = false
286
+ options.onTransformEnd?.()
287
+ } else {
288
+ rebase = true
289
+ pinch = pinch && pointers.size >= 2
290
+ spanRate = 1
291
+ }
292
+ return
293
+ }
294
+ pointers.delete(e.pointerId)
295
+ ref = pointers.size > 0 ? measure() : null
296
+ },
297
+ }
298
+
299
+ return { handlers, cancel }
300
+ }
package/src/types.d.ts CHANGED
@@ -415,8 +415,10 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
415
415
  * transform - it never sizes the element, so give the box its size with
416
416
  * layout props. Composed innermost: the transform props still operate in
417
417
  * box space, and pointer events on children arrive in design coordinates.
418
- * The natural wrapper for parseSvg draws, or any d-* subtree authored in
419
- * fixed design units.
418
+ * The overflow clip and scrollX/scrollY stay box properties too: the clip
419
+ * rect is the layout box and scroll offsets are box pixels, regardless of
420
+ * fit scale. The natural wrapper for parseSvg draws, or any d-* subtree
421
+ * authored in fixed design units.
420
422
  */
421
423
  viewBox?: [number, number]
422
424
  /**
@@ -582,11 +584,13 @@ export interface TextureProps extends PaintProps, PointerProps {
582
584
  srcY?: number
583
585
  srcW?: number
584
586
  srcH?: number
585
- // Shader uniform values, when src names a shader texture. Applied at the
586
- // next repaint (not synchronously), so a fast-changing signal stays paced
587
- // to real frames rather than triggering a GL render pass per write. A
588
- // number drives a scalar (`float`/`int`); a flat number array drives the
589
- // declared GLSL type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major)
590
- // for `mat4`.
587
+ // Shader uniform values, when src names a render target: the same channel
588
+ // setTargetParams drives, written through it directly - prop and
589
+ // imperative writes validate and error identically (an unknown name
590
+ // throws, and set src before params: a write with no src to route to
591
+ // throws). However often a signal writes, the target renders once per
592
+ // frame at the raster flush. A number drives a scalar (`float`/`int`); a
593
+ // flat number array drives the declared GLSL type: 2/3/4 for
594
+ // `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
591
595
  params?: Record<string, number | number[]>
592
596
  }
package/src/window.ts CHANGED
@@ -38,26 +38,42 @@ 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. For raw, continuous wall-clock time (e.g. measuring code), use
42
- * performance.now() instead.
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().
43
44
  * Returns a cleanup function; also auto-cleans within a reactive scope.
44
45
  */
45
46
  export function onFrame(fn: (tick: number, frame: number, rate: number) => void) {
46
47
  let frameId: number = null!
48
+ // Cancellation is a flag, not map membership: while a frame runs the whole
49
+ // callback map is swapped out, so a cleanup() called from another onFrame
50
+ // callback in the same tick could not reach this entry through the map.
51
+ let cancelled = false
47
52
 
48
53
  let extendedFn = (tick: number, frame: number, rate: number) => {
49
- fn(tick, frame, rate)
54
+ if (cancelled) return
55
+ // Re-register BEFORE running fn, so a throwing callback stays subscribed
56
+ // (event-listener semantics: the error is reported, the subscription
57
+ // lives) and a cleanup() from inside fn sees the current frameId. A
58
+ // pending onFrame callback is a standing request for the next frame.
50
59
  frameId = nextFrameId++
51
60
  animationFrames.set(frameId, extendedFn)
52
- // A pending onFrame callback is a standing request for the next frame.
53
61
  requestFrame()
62
+ try {
63
+ fn(tick, frame, rate)
64
+ } catch (err) {
65
+ console.error("Error in onFrame callback:", err)
66
+ }
54
67
  }
55
68
 
56
69
  frameId = nextFrameId++
57
70
  animationFrames.set(frameId, extendedFn)
58
71
  requestFrame()
59
72
 
60
- let cleanup = () => animationFrames.delete(frameId)
73
+ let cleanup = () => {
74
+ cancelled = true
75
+ animationFrames.delete(frameId)
76
+ }
61
77
  onCleanup(cleanup)
62
78
  return cleanup
63
79
  }
@@ -253,7 +269,13 @@ export function attachWindow(nodeId: number) {
253
269
  animationFrames = new Map()
254
270
  for (let fn of frames.values()) fn(t, frame, refreshRate)
255
271
  }
256
- flush()
272
+ try {
273
+ // A throwing effect must not skip renderFrame: the frame still paints
274
+ // whatever state committed before the throw.
275
+ flush()
276
+ } catch (err) {
277
+ console.error("Error in reactive flush:", err)
278
+ }
257
279
  scanForOrphans(t)
258
280
  renderFrame()
259
281
  }
@@ -298,7 +320,13 @@ export function attachWindow(nodeId: number) {
298
320
  e.localY = localY[i]!
299
321
  e.parentX = parentX[i]!
300
322
  e.parentY = parentY[i]!
301
- getEventHandler(targets[i]!, handler)?.(e)
323
+ try {
324
+ getEventHandler(targets[i]!, handler)?.(e)
325
+ } catch (err) {
326
+ // One throwing handler must not suppress delivery to the rest of
327
+ // the path (or the focus/blur step that follows the loop).
328
+ console.error(`Error in ${handler} handler:`, err)
329
+ }
302
330
  if (stopped) break
303
331
  }
304
332
  }