@solidrt/core 0.0.59 → 0.0.61

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
@@ -111,7 +111,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
111
111
  ```
112
112
 
113
113
  Peer deps @solidjs/signals and @solidjs/universal must match (currently
114
- 2.0.0-rc.8); bun resolves them from peerDependencies. On a version bump,
114
+ 2.0.0-rc.9); bun resolves them from peerDependencies. On a version bump,
115
115
  `bun install` only warns ("incorrect peer dependency") and keeps the old
116
116
  ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
117
117
  not read the pin either: it adds all three to package.json at registry
@@ -157,7 +157,9 @@ that reads exactly like Solid fallout.
157
157
  - Paint color is the `color` prop (a CSS color string). There is NO `fill`,
158
158
  `stroke`, or `background` prop (some older doc examples are wrong about this).
159
159
  Outlines: `drawStyle="stroke"` (or "stroke-and-fill") plus `strokeWidth`.
160
- Corner radius on draw primitives: `radius` (number or [tl, tr, br, bl]).
160
+ Corner radius on draw primitives: `radius` (number or [tl, tr, br, bl]);
161
+ on `texture` too, drawn as a rounded image in one draw - use it instead of
162
+ a rounded `clipRadius` around an image, which tiled GPUs pay dearly for.
161
163
 
162
164
  - Registered JSX intrinsics: `window`, `view`, `text`, `span`, `rect`, `oval`,
163
165
  `line`, `path`, `texture`, `audio`, plus the `d-` variants `d-view`, `d-rect`,
@@ -252,6 +254,16 @@ that reads exactly like Solid fallout.
252
254
  call `flush()` after the write so it lands in this frame's display list.
253
255
  See examples/on-layout-connect.tsx.
254
256
 
257
+ - A flex item never shrinks below its content on its own (CSS `min-height:
258
+ auto`, the same here): in a column, a content-sized child - a paragraph, a
259
+ list, a multiline field - keeps its content height and overflows a parent
260
+ that is too short, whatever `flexGrow`/`flexShrink` say, because the
261
+ automatic minimum size pins it. Give the child that should fit
262
+ `minHeight={0}` (`minWidth={0}` in a row), or make it a scroll container
263
+ (`overflow="hidden"` or `"scroll"`, whose automatic minimum is 0). A
264
+ `flexGrow: 1` child that ends up TALLER than its parent is this trap, not
265
+ a broken grow.
266
+
255
267
  - Text `lineHeight` is a MULTIPLIER of fontSize (1.3-1.6 is typical), not
256
268
  pixels. A CSS-reflex value like 22 makes each line box 22x the font size:
257
269
  the text becomes blank space and the parent balloons.
@@ -319,10 +331,34 @@ that reads exactly like Solid fallout.
319
331
  finger degrades to a plain pan, and `pointers` is how a consumer gives one-
320
332
  and two-finger translation different meanings - dx/dy alone cannot tell
321
333
  them apart).
334
+ Both ends carry the lift velocity (`onPanEnd(velocity)`,
335
+ `onTransformEnd(velocity)`: parent-frame px/s from `createVelocityTracker`,
336
+ a least-squares fit over the last 100 ms of positions, never the last
337
+ two samples - the final sample before a lift is often stationary and
338
+ frame batching makes the last delta a frame old; zero after a 50 ms
339
+ rest or under `FLING_MIN_VELOCITY`), the fling fact for whoever animates
340
+ on. The discrete recognizers: `createSwipe({ directions?, onSwipe(direction,
341
+ velocity), onSwipeMove?, onSwipeEnd? })` (a pan classified at the lift:
342
+ 24 px of travel at 300 px/s within 30 degrees of an axis, direction by
343
+ the velocity; `directions` sets the pan's axis, so a horizontal swipe
344
+ inside a vertical ScrollView takes only horizontal drags), `createLongPress({
345
+ ms?, onLongPress(at), onLongPressMove?, onLongPressEnd? })` (500 ms
346
+ within 10 px; arms silently, steals at the timer - a press under it
347
+ retracts, a pan that won first keeps the finger - then streams moves:
348
+ drag-after-hold), `createDoubleTap({ onDoubleTap(at) })` (second down
349
+ within 300 ms and 100 px of the first tap, at least 40 ms after it;
350
+ fires on that down). `at` is a `PointerPoint`, the down's coordinates
351
+ in every frame, copied - the event object is rewritten as it bubbles.
322
352
  Spread the returned `.handlers` onto the receiving element. They
323
353
  arbitrate through the exported `arena` (ONE per app): a press claims its
324
354
  pointer provisionally, movement evidence steals and resolves it, the loser's
325
- `cancel()` retracts its feedback. Custom recognizers should join the arena
355
+ `cancel()` retracts its feedback. One relation beyond claims: a
356
+ double-tap `arena.pend`s a decision on its first tap's pointer and
357
+ `decide`s it later, and a press that resolved at the lift
358
+ `arena.defer`s its firing on that (run when the decision is lost,
359
+ dropped when won; false and fire now when nothing is pending) - so a
360
+ single tap fires late only on a node that also has a double-tap.
361
+ Custom recognizers should join the arena
326
362
  rather than track pointers ad hoc, or they will double-handle against
327
363
  scrollers and pressables.
328
364
 
@@ -377,12 +413,26 @@ that reads exactly like Solid fallout.
377
413
  `drag("Shift+Middle")`; bare means Left), a discriminator rather than
378
414
  a modifier: a right drag feeds only Right specs. Touch carries no
379
415
  modifiers or buttons: a chord is the desktop path, the bare binding
380
- stays for fingers. A
416
+ stays for fingers. The discrete gestures are button sources that pulse
417
+ (pressed for one task, so onPress fires once): `pointer.swipe("Left")`
418
+ (the direction is the trailing word, as a drag's button is;
419
+ `swipe("Ctrl+Right")`), `pointer.longPress`, `pointer.doubleTap`, each
420
+ chordable; ids `pointer:swipe:Left`, `pointer:longPress:Ctrl`. They
421
+ run on the feed's own events, no second arena claimant. A drag's `end`
422
+ carries the release velocity in element heights per second when the
423
+ finger lifted at speed (`GestureListener.end(velocity)`,
424
+ `AxesHooks.onEnd(name, velocity)`, `input.end(action, velocity?)` by
425
+ name), which is how the cameras fling. A
381
426
  detached d-* leaf has no layout box: give the feed `{ layout }` or it
382
427
  throws at the first press. `invert(source)` and `scale(source, k)` are
383
- the two processors: keys and sticks move the CAMERA where a drag moves
428
+ the value processors: keys and sticks move the CAMERA where a drag moves
384
429
  the content, so a preset binds arrows or a stick to `pan`/`rotate`
385
- through invert() and the action keeps one meaning.
430
+ through invert() and the action keeps one meaning. `hold(source, ms)`,
431
+ `tap(source, ms)`, `doubleTap(source, gapMs, tapMs)` and
432
+ `chord(...sources)` are the interactions (Unity's Hold/Tap/MultiTap):
433
+ button sources made from button sources, so a charged attack is
434
+ `hold(pad.button("west"))` on one action next to the plain press on
435
+ another; a tap reads pressed for one task, enough for onPress.
386
436
  Consumers with the axes contract (`createAxes({ rotate: "vec2", zoom:
387
437
  "axis" }, { onNudge, onBegin, onEnd })`, what every camera control
388
438
  exposes as `.axes`) are connected by name with `input.drive(control.axes,
@@ -392,7 +442,23 @@ that reads exactly like Solid fallout.
392
442
  name is the wire for scripts, debug commands, peers and replays:
393
443
  `input.set("move", [0, -1])` holds a rate, `press`/`release` a button,
394
444
  `nudge`/`begin`/`end` feed the delta channel. `bindings()` lists what is
395
- bound (source labels are display strings), `unbind` removes one, and a
445
+ bound, `unbind` removes one. Every source has a `label` (display) and
446
+ an `id` (serializable, no device instance: `keyboard:key:Space`,
447
+ `gamepad:button:south`, `pointer:drag:Ctrl+Right`,
448
+ `invert(gamepad:leftStick)`, `hold(400,keyboard:key:Space)`) plus its
449
+ `device`. A settings screen is `input.save()` (ids per action, JSON it)
450
+ and `input.load(saved, { keyboard, gamepad, pointer })` (resolves
451
+ through the devices given, all-or-nothing, throws on an unknown id),
452
+ `input.rebind(action, devices, { signal?, replace?, part? })` (a
453
+ promise of the next fitting input bound: a key down captured through
454
+ the map's handlers, a pad button/trigger/stick, a pointer gesture's
455
+ first movement; `replace` (default) swaps that device's bindings on
456
+ the action; `part: "up"` etc. swaps one key of the bound keyboard
457
+ composite on a vec2/axis action; `signal` is an AbortSignal), and
458
+ `input.device()` (reactive: the device that last moved anything bound,
459
+ the button-prompt switch - show `bindings(action)` where
460
+ `source.device === input.device()`; glyph images are the app's, keyed
461
+ by id). A
396
462
  preset (`orbitBindings`, `firstPersonBindings` in @solidrt/3d,
397
463
  `camera2dBindings` in @solidrt/2d) is a plain bindings list the app
398
464
  applies and edits - nothing binds unless the app says so (the one
@@ -408,14 +474,28 @@ that reads exactly like Solid fallout.
408
474
  effects under it and the feed's recognizer registers its cleanup there. The vocabulary the
409
475
  controls share, one kind and unit per word: `pan` vec2 (view heights),
410
476
  `zoom` axis (octaves, positive in), `rotate` vec2 (orbit turns), `roll`
411
- axis (turns about the view axis), `look` vec2 (turns), `move` vec2
412
- ([right, forward], forward = -y), `rise` axis (up). The runtime-free
477
+ axis (turns about the view axis), `focus` axis (a nudge at a focal: a
478
+ double tap's point), `look` vec2 (turns), `move` vec2 ([right,
479
+ forward], forward = -y), `rise` axis (up). The runtime-free
413
480
  half (`createInputMap`, `createAxes`, `keyboard`, the processors) is
414
481
  importable as `@solidrt/core/input` for headless checks;
415
482
  checks/input-map-check.ts runs it on the bare flux binary, and
416
483
  checks/input-gamepad-check.ts drives the gamepad device and the join
417
484
  order over a signal of pad snapshots (input-gamepad-device.ts is
418
- runtime-free; input-gamepad.ts hands it gamepads()).
485
+ runtime-free; input-gamepad.ts hands it gamepads()). The pointer
486
+ feed's discrete pulses (`doubleTap`, `longPress`) bound to an AXIS
487
+ action nudge 1 with the gesture's focal (where the tap landed) and
488
+ contribute NO rate (a one-task press is not a rate to integrate),
489
+ which is how the orbit camera's `focus` learns its point and the 2d
490
+ camera's double tap zooms one octave. The camera
491
+ controls' shared math - the ease, the framing of a followed point
492
+ (dead zone, hard limits, per-axis damping, lookahead) and the lanes
493
+ (offset, shakes), the activity gate every control's `active()` runs
494
+ on, and the shot blender (`createShotBlend`: several cameras, a
495
+ priority, a timed blend, over the packages' `createShots`) - is
496
+ `@solidrt/core/camera-control`, pure, pinned by
497
+ checks/camera-control-check.ts; okf/design/camera-controls.md is the
498
+ pipeline the 2d and 3d controls run over it.
419
499
 
420
500
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
421
501
  is as you expect, but `createEffect` takes the 2.0 two-function shape: a
@@ -480,12 +560,16 @@ that reads exactly like Solid fallout.
480
560
  replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
481
561
  then bump `epoch`). See examples/stagger.tsx. The companion to the exit
482
562
  pop-out is `layout` on the same spec: an element declaring it slides
483
- from where it was painted to the box a layout gives it (a row removed
484
- above it, one inserted, a `<For>` reorder) instead of jumping, retargets
485
- if the layout changes again mid-slide, and is hit-tested where it is
486
- drawn. It is parent-relative, so declare it on every level that should
487
- glide; `layout: true` borrows the `all` motion, and `all` alone never
488
- slides. Position only today, a size change snaps. See
563
+ from the box it was painted at to the box a layout gives it (a row
564
+ removed above it, one inserted, a `<For>` reorder, a card growing to fit
565
+ more content), position and size, instead of jumping; it retargets if
566
+ the layout changes again mid-slide, and is painted, clipped, hit-tested
567
+ and backdrop-filtered at the box it is drawn at. While its size is in
568
+ flight its children are laid out against that box every frame (text
569
+ re-wraps, rows re-flow, like a CSS width transition), and a child's own
570
+ `layout` slide yields to the parent's motion. It is parent-relative, so
571
+ declare it on every level that should glide; `layout: true` borrows the
572
+ `all` motion, and `all` alone never slides. See
489
573
  examples/layout-slide.tsx.
490
574
  Per-frame work is for genuinely procedural motion, and a game or a
491
575
  simulation is that in full: its natural shape is ONE `onFrame` doing one
@@ -104,7 +104,11 @@ Rules, in order of leverage:
104
104
  `opacity` on a view is NOT cheap: it wraps the subtree in a compositing
105
105
  layer (save_layer) for as long as it is below 1. To fade a single
106
106
  primitive, put the alpha in its `color` (`rgba(...)`) - paint alpha is
107
- free; reserve view `opacity` for fading a genuine group as a whole.
107
+ free; reserve view `opacity` for fading a genuine group as a whole. A
108
+ `backdropFilter` panel inside a fading group is re-filtered against the
109
+ window ahead of the group each frame of the fade (the glass keeps its
110
+ blur; get_stats' backdropsPrepainted counts them), one extra backdrop
111
+ capture per panel while the opacity is below 1.
108
112
  Placement rule for animation-heavy screens: a boundary around a node
109
113
  that animates its own paint (a moving d-*, a changing color) is useless
110
114
  - its interior is damaged every frame, so the cache never survives. The
@@ -21,9 +21,10 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
21
21
  - `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
22
22
  - `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
23
23
  - `stagger.tsx` - enter/exit/stagger with no per-frame JS: `from` and `exit` per property on each row's own `transition`, `stagger` on the ANCESTOR cascading them in occurrence order, and the replay idiom - a keyed `<Show>` remounts the subtree when its `when` value changes, so bumping an epoch replays the whole cascade. Exiting rows stay painted until they settle.
24
- - `layout-slide.tsx` - the `layout` transition, the companion to exit: rows declaring it slide from the box they had to the box a layout gives them instead of jumping - tap a row to remove it (its exit plays where it was while the rows below slide up), the header to shuffle the order (a `<For>` reorder), the footer to add one back. No per-frame JS, no hand-animated `y`.
24
+ - `layout-slide.tsx` - the `layout` transition, the companion to exit: rows declaring it slide from the box they had to the box a layout gives them, position and size, instead of jumping - tap a row to expand it (it grows to fit its detail text while the rows below slide down), its "remove" mark to remove it (its exit plays where it was while the rows below slide up), the header to shuffle the order (a `<For>` reorder), the footer to add one back. No per-frame JS, no hand-animated `y` or `height`.
25
25
 
26
26
  ## Pointer input
27
+ - `gestures.tsx` - the discrete recognizers test bed: a swipe card (`createSwipe`, follows the finger, reports the direction and speed, snaps back when too slow), a long-press tile (`createLongPress`, arms on a hold, then drags: the reorder idiom), a double-tap target beside a plain tap (the single tap fires late only where a double-tap is registered: the arena's pend/defer relation), a pan target printing the release velocity, and a pointer feed binding `swipe("Left")`, `longPress` and `doubleTap` on an input map. Every state is on screen and in the log, so `/input` sequences verify it from the tree.
27
28
  - `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
28
29
 
29
30
  ## Gamepads
@@ -0,0 +1,197 @@
1
+ // Discrete gestures test bed: the recognizer family beyond press and pan,
2
+ // each on its own target with its state on screen (and in the log):
3
+ // - swipe: a card that follows the finger and reports the direction it
4
+ // left in (createSwipe), snapping back when the drag was too slow;
5
+ // - long-press: a tile that arms on a hold and can then be dragged
6
+ // (createLongPress, the reorder idiom), while a plain drag moves it
7
+ // not at all;
8
+ // - double-tap beside a single tap: the tap fires late only where a
9
+ // double-tap is registered (the arena's pend/defer relation), at once
10
+ // on the plain target;
11
+ // - the pointer feed: an input map binding `dodge` to swipe("Left"),
12
+ // `charge` to longPress and `zoomIn` to doubleTap on one viewport, so
13
+ // the same gestures reach a game through the map.
14
+ // A pan target prints the release velocity core measures at every lift.
15
+ import { arena, createDoubleTap, createInputMap, createLongPress, createPan, createPointerFeed, createSignal, createSwipe, render } from "@solidrt/core"
16
+ import type { PointerEvent, PointerPoint } from "@solidrt/core"
17
+
18
+ const TILE = 120
19
+ const CARD_WIDTH = 240
20
+ const CARD_HEIGHT = 64
21
+
22
+ function Swipeable() {
23
+ let [x, setX] = createSignal(0)
24
+ let [dragging, setDragging] = createSignal(false)
25
+ let [status, setStatus] = createSignal("swipe me")
26
+ let swipe = createSwipe({
27
+ directions: ["Left", "Right"],
28
+ onSwipeStart: () => setDragging(true),
29
+ onSwipeMove: dx => setX(x() + dx),
30
+ onSwipe: (direction, v) => {
31
+ setStatus(`swiped ${direction} at ${Math.round(v.vx)} px/s`)
32
+ console.log(`swipe ${direction} ${Math.round(v.vx)}`)
33
+ },
34
+ onSwipeEnd: () => {
35
+ setDragging(false)
36
+ setX(0)
37
+ },
38
+ })
39
+ return (
40
+ <view width={CARD_WIDTH} height={CARD_HEIGHT} overflow="hidden" {...swipe.handlers}>
41
+ <d-rect color="#243044" radius={8} />
42
+ <view width={CARD_WIDTH} height={CARD_HEIGHT} x={x()} transition={dragging() ? null : { x: { duration: 250 } }} alignItems="center" justifyContent="center">
43
+ <d-rect color="#3b5f8a" radius={8} />
44
+ <d-text color="#ffffff">{status()}</d-text>
45
+ </view>
46
+ </view>
47
+ )
48
+ }
49
+
50
+ function LongPressable() {
51
+ let [pos, setPos] = createSignal({ x: 0, y: 0 })
52
+ let [held, setHeld] = createSignal(false)
53
+ let [status, setStatus] = createSignal("hold me")
54
+ let lp = createLongPress({
55
+ onLongPress: () => {
56
+ setHeld(true)
57
+ setStatus("held: now drag")
58
+ console.log("long-press")
59
+ },
60
+ onLongPressMove: (dx, dy) => setPos({ x: pos().x + dx, y: pos().y + dy }),
61
+ onLongPressEnd: () => {
62
+ setHeld(false)
63
+ setStatus(`dropped at ${Math.round(pos().x)},${Math.round(pos().y)}`)
64
+ console.log(`long-press end ${Math.round(pos().x)},${Math.round(pos().y)}`)
65
+ },
66
+ })
67
+ return (
68
+ <view width={TILE} height={TILE} x={pos().x} y={pos().y} alignItems="center" justifyContent="center" {...lp.handlers}>
69
+ <d-rect color={held() ? "#d9822b" : "#8a5f3b"} radius={12} />
70
+ <d-text color="#ffffff">{status()}</d-text>
71
+ </view>
72
+ )
73
+ }
74
+
75
+ // A press the way components' createPress does it: claim on the down,
76
+ // fire on the up, deferred while a double-tap is pending on the pointer.
77
+ function pressable(onPress: () => void) {
78
+ let active: number | null = null
79
+ let owner = {
80
+ cancel() {
81
+ active = null
82
+ },
83
+ }
84
+ return {
85
+ onPointerDown(e: PointerEvent) {
86
+ if (arena.claim(e.pointerId, owner)) active = e.pointerId
87
+ },
88
+ onPointerUp(e: PointerEvent) {
89
+ if (active !== e.pointerId) return
90
+ active = null
91
+ arena.release(e.pointerId, owner)
92
+ if (!arena.defer(e.pointerId, onPress)) onPress()
93
+ },
94
+ }
95
+ }
96
+
97
+ function Tappable(props: { double: boolean }) {
98
+ let [status, setStatus] = createSignal(props.double ? "tap or double-tap" : "tap")
99
+ let taps = 0
100
+ let press = pressable(() => {
101
+ taps++
102
+ setStatus(`tap ${taps}`)
103
+ console.log(`${props.double ? "double-target" : "plain-target"} tap ${taps}`)
104
+ })
105
+ let dt = createDoubleTap({
106
+ onDoubleTap: (at: PointerPoint) => {
107
+ setStatus(`double-tap at ${Math.round(at.localX)},${Math.round(at.localY)}`)
108
+ console.log("double-tap")
109
+ },
110
+ })
111
+ let down = (e: PointerEvent) => {
112
+ if (props.double) dt.handlers.onPointerDown(e)
113
+ press.onPointerDown(e)
114
+ }
115
+ let up = (e: PointerEvent) => {
116
+ if (props.double) dt.handlers.onPointerUp(e)
117
+ press.onPointerUp(e)
118
+ }
119
+ return (
120
+ <view width={TILE} height={TILE} alignItems="center" justifyContent="center" onPointerDown={down} onPointerMove={props.double ? dt.handlers.onPointerMove : undefined} onPointerUp={up}>
121
+ <d-rect color={props.double ? "#3b8a5f" : "#5f8a3b"} radius={12} />
122
+ <d-text color="#ffffff">{status()}</d-text>
123
+ </view>
124
+ )
125
+ }
126
+
127
+ function Pannable() {
128
+ let [status, setStatus] = createSignal("drag and let go")
129
+ let pan = createPan({
130
+ onPanEnd: v => {
131
+ setStatus(`lift at ${Math.round(v.vx)},${Math.round(v.vy)} px/s`)
132
+ console.log(`pan end ${Math.round(v.vx)},${Math.round(v.vy)}`)
133
+ },
134
+ })
135
+ return (
136
+ <view width={CARD_WIDTH} height={CARD_HEIGHT} alignItems="center" justifyContent="center" {...pan.handlers}>
137
+ <d-rect color="#44304a" radius={8} />
138
+ <d-text color="#ffffff">{status()}</d-text>
139
+ </view>
140
+ )
141
+ }
142
+
143
+ function Viewport() {
144
+ let pointer = createPointerFeed()
145
+ let input = createInputMap({ dodge: "button", charge: "button", zoomIn: "button", look: "vec2" })
146
+ input.bind("dodge", pointer.swipe("Left"))
147
+ input.bind("charge", pointer.longPress)
148
+ input.bind("zoomIn", pointer.doubleTap)
149
+ input.bind("look", pointer.drag)
150
+ let [status, setStatus] = createSignal("feed: swipe left, hold, double-tap")
151
+ input.onPress("dodge", () => {
152
+ setStatus("feed: dodge")
153
+ console.log("feed dodge")
154
+ })
155
+ input.onPress("charge", () => {
156
+ setStatus("feed: charge")
157
+ console.log("feed charge")
158
+ })
159
+ input.onPress("zoomIn", () => {
160
+ setStatus("feed: zoomIn")
161
+ console.log("feed zoomIn")
162
+ })
163
+ input.onGesture("look", {
164
+ end: v => {
165
+ if (v) {
166
+ setStatus(`feed: look end ${v[0].toFixed(2)},${v[1].toFixed(2)} heights/s`)
167
+ console.log(`feed look end ${v[0].toFixed(2)},${v[1].toFixed(2)}`)
168
+ }
169
+ },
170
+ })
171
+ return (
172
+ <view width={CARD_WIDTH} height={TILE} alignItems="center" justifyContent="center" {...pointer.handlers}>
173
+ <d-rect color="#2b4a5a" radius={12} />
174
+ <d-text color="#ffffff">{status()}</d-text>
175
+ </view>
176
+ )
177
+ }
178
+
179
+ function App() {
180
+ return (
181
+ <window flexDirection="column" gap={24} padding={24}>
182
+ <d-rect color="#101418" />
183
+ <view flexDirection="row" gap={24}>
184
+ <Swipeable />
185
+ <Pannable />
186
+ </view>
187
+ <view flexDirection="row" gap={24}>
188
+ <LongPressable />
189
+ <Tappable double={true} />
190
+ <Tappable double={false} />
191
+ </view>
192
+ <Viewport />
193
+ </window>
194
+ )
195
+ }
196
+
197
+ render(() => <App />)
@@ -1,22 +1,29 @@
1
1
  // The `layout` transition, the companion to `exit`: a row declaring it
2
- // slides from the box it had to the box the next layout gives it, so the
3
- // list never jumps when a row leaves, arrives or moves. Nothing here
4
- // animates `y` by hand and there is no per-frame JS: the rows keep their
5
- // flex layout, and the runtime slides each one from where it was painted.
6
- // Tap a row to remove it - its exit plays where it was (the pop-out) while
7
- // the rows below slide up past it. Tap the header to shuffle: a `<For>`
2
+ // slides from the box it had to the box the next layout gives it, position
3
+ // and size, so the list never jumps when a row leaves, arrives, moves or
4
+ // grows. Nothing here animates `y` or `height` by hand and there is no
5
+ // per-frame JS: the rows keep their flex layout, and the runtime slides
6
+ // each one from the box it was painted at. Tap a row to expand it: its box
7
+ // grows on the spring, its detail text is laid out against the growing box
8
+ // every frame, and the rows below slide down with its bottom edge. Tap a
9
+ // row's "remove" mark: its exit plays where it was (the pop-out) while the
10
+ // rows below slide up past it. Tap the header to shuffle: a `<For>`
8
11
  // reorder moves the surviving nodes, so every displaced row slides. Tap
9
12
  // the footer to add a row back: the rows below its slot slide apart and
10
13
  // the new row enters through its `from`. A reflow that lands mid-slide
11
- // retargets, and a spring keeps its momentum. `layout` is parent-relative
12
- // (declare it on every level that should glide) and position only: a size
13
- // change snaps.
14
- import { render, createSignal, For } from "@solidrt/core"
14
+ // retargets, and a spring keeps its momentum. `layout` is parent-relative:
15
+ // declare it on every level that should glide.
16
+ import { render, createSignal, For, Show } from "@solidrt/core"
15
17
  import type { Transition } from "@solidrt/core"
16
18
 
17
19
  const ALL_ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries", "Resources"]
18
20
  // Rows the list starts with; the rest come back through the footer.
19
21
  const INITIAL_ROWS = 4
22
+ // A row's collapsed height, and the detail text it grows to fit.
23
+ const ROW_HEIGHT = 48
24
+ const DETAIL =
25
+ "A reactive primitive of Solid 2.0: the runtime tracks its reads and re-runs the computations that depend on it when it changes."
26
+
20
27
  // Every reflow slides on this spring.
21
28
  const SLIDE = { duration: 400, bounce: 0.15 } satisfies Transition
22
29
  // Enter from the left, leave to the right, each direction on its own curve.
@@ -42,6 +49,7 @@ function shuffled(items: string[]): string[] {
42
49
 
43
50
  function App() {
44
51
  let [rows, setRows] = createSignal(ALL_ROWS.slice(0, INITIAL_ROWS))
52
+ let [expanded, setExpanded] = createSignal<string | null>(null)
45
53
  let addRow = () => {
46
54
  let next = ALL_ROWS.find((label) => !rows().includes(label))
47
55
  if (next) setRows([...rows(), next])
@@ -50,7 +58,7 @@ function App() {
50
58
  return (
51
59
  <window padding={40} gap={12}>
52
60
  <view height={40} justifyContent="center" onPointerDown={() => setRows(shuffled(rows()))}>
53
- <text color="#8899aa" fontSize={14}>Tap here to shuffle, a row to remove it</text>
61
+ <text color="#8899aa" fontSize={14}>Tap here to shuffle, a row to expand it</text>
54
62
  </view>
55
63
  <For each={rows()}>
56
64
  {(label) => (
@@ -58,13 +66,34 @@ function App() {
58
66
  x={0}
59
67
  opacity={1}
60
68
  transition={{ layout: SLIDE, x: ARRIVE, opacity: FADE }}
61
- height={48}
69
+ minHeight={ROW_HEIGHT}
70
+ flexDirection="column"
62
71
  justifyContent="center"
63
- paddingLeft={16}
64
- onPointerDown={() => setRows(rows().filter((row) => row !== label))}
72
+ padding={16}
73
+ paddingTop={12}
74
+ paddingBottom={12}
75
+ gap={8}
76
+ overflow="hidden"
77
+ clipRadius={10}
78
+ onPointerDown={() => setExpanded(expanded() === label ? null : label)}
65
79
  >
66
80
  <d-rect color="#2a3a55" radius={10} />
67
- <text color="#e8eef6" fontSize={18}>{label}</text>
81
+ <view flexDirection="row" justifyContent="space-between">
82
+ <text color="#e8eef6" fontSize={18}>{label}</text>
83
+ <text
84
+ color="#8899aa"
85
+ fontSize={14}
86
+ onPointerDown={(e) => {
87
+ e.stopPropagation()
88
+ setRows(rows().filter((row) => row !== label))
89
+ }}
90
+ >
91
+ remove
92
+ </text>
93
+ </view>
94
+ <Show when={expanded() === label}>
95
+ <text color="#b8c4d4" fontSize={14}>{DETAIL}</text>
96
+ </Show>
68
97
  </view>
69
98
  )}
70
99
  </For>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -9,6 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./src/index.ts",
11
11
  "./camera": "./src/camera.ts",
12
+ "./camera-control": "./src/camera-control.ts",
12
13
  "./color": "./src/color.ts",
13
14
  "./data": "./src/data.ts",
14
15
  "./gpu": "./src/gpu.ts",
@@ -31,11 +32,11 @@
31
32
  "AGENTS.md"
32
33
  ],
33
34
  "devDependencies": {
34
- "@solidrt/flux-types": "0.0.59"
35
+ "@solidrt/flux-types": "0.0.61"
35
36
  },
36
37
  "peerDependencies": {
37
- "@solidjs/signals": "2.0.0-rc.8",
38
- "@solidjs/universal": "2.0.0-rc.8",
39
- "solid-js": "2.0.0-rc.8"
38
+ "@solidjs/signals": "2.0.0-rc.9",
39
+ "@solidjs/universal": "2.0.0-rc.9",
40
+ "solid-js": "2.0.0-rc.9"
40
41
  }
41
42
  }
package/src/arena.ts CHANGED
@@ -15,6 +15,15 @@
15
15
  // outer axis of two nested scrollers cannot take a drag the inner one already
16
16
  // owns. Plain state on purpose: claims must be visible across recognizers
17
17
  // within one synchronous bubble dispatch, before any signal flush.
18
+ //
19
+ // One relation beyond claims: a recognizer that may still take a gesture
20
+ // AFTER the pointer lifts (a double-tap waiting for its second tap) pends
21
+ // a decision at the down, and a recognizer that resolved at the lift
22
+ // defers its firing on that decision (a press beside a double-tap must
23
+ // not fire on the first tap, or double-tap-to-zoom also single-taps).
24
+ // UIKit's require(toFail:), RNGH's waitFor, Flutter's delayed tap. A node
25
+ // with nothing pending fires at once: the wait costs nothing where no
26
+ // double-tap is registered.
18
27
 
19
28
  export type ArenaOwner = {
20
29
  /** Retract the gesture without firing; invoked when the pointer is stolen. */
@@ -22,8 +31,10 @@ export type ArenaOwner = {
22
31
  }
23
32
 
24
33
  type Claim = { owner: ArenaOwner; resolved: boolean }
34
+ type Pending = { owners: Set<ArenaOwner>; fires: (() => void)[] }
25
35
 
26
36
  let claims = new Map<number, Claim>()
37
+ let pending = new Map<number, Pending>()
27
38
 
28
39
  export let arena = {
29
40
  /**
@@ -57,4 +68,48 @@ export let arena = {
57
68
  release(pointerId: number, owner: ArenaOwner): void {
58
69
  if (claims.get(pointerId)?.owner === owner) claims.delete(pointerId)
59
70
  },
71
+
72
+ /**
73
+ * Register that `owner` may still take the pointer's gesture after its
74
+ * lift, so firings resolved at the lift wait (defer) until `decide`.
75
+ */
76
+ pend(pointerId: number, owner: ArenaOwner): void {
77
+ let p = pending.get(pointerId)
78
+ if (!p) {
79
+ p = { owners: new Set(), fires: [] }
80
+ pending.set(pointerId, p)
81
+ }
82
+ p.owners.add(owner)
83
+ },
84
+
85
+ /**
86
+ * Settle a pending decision: `won` drops every deferred firing (the
87
+ * gesture was the pending recognizer's), lost runs them once no other
88
+ * decision is pending on the pointer. Idempotent for an owner that is
89
+ * not pending.
90
+ */
91
+ decide(pointerId: number, owner: ArenaOwner, won: boolean): void {
92
+ let p = pending.get(pointerId)
93
+ if (!p || !p.owners.has(owner)) return
94
+ if (won) {
95
+ pending.delete(pointerId)
96
+ return
97
+ }
98
+ p.owners.delete(owner)
99
+ if (p.owners.size > 0) return
100
+ pending.delete(pointerId)
101
+ for (let fire of p.fires) fire()
102
+ },
103
+
104
+ /**
105
+ * Hold a firing until every decision pending on the pointer is settled:
106
+ * run when the last one is lost, dropped when one is won. Returns false,
107
+ * storing nothing, when no decision is pending: the caller fires now.
108
+ */
109
+ defer(pointerId: number, fire: () => void): boolean {
110
+ let p = pending.get(pointerId)
111
+ if (!p) return false
112
+ p.fires.push(fire)
113
+ return true
114
+ },
60
115
  }
package/src/audio.ts CHANGED
@@ -218,9 +218,10 @@ export function createPcmSound(
218
218
  * Streams a large track, decoding on demand instead of loading it into memory.
219
219
  * Single-voice: each play() restarts it. Pass a path (resolved like flux:fs,
220
220
  * relative to the process cwd) or a `file()` from flux:fs; a path is wrapped in
221
- * `file()` for you, so a dev-server-proxied file streams over the proxy. Owns
222
- * the stream's lifecycle: stopped and released when the reactive owner is
223
- * disposed. For imperative use, call stream()/play() from "flux:audio".
221
+ * `file()` for you. The track is read from local storage (the app's assets in
222
+ * a packed app), not over the network. Owns the stream's lifecycle: stopped
223
+ * and released when the reactive owner is disposed. For imperative use, call
224
+ * stream()/play() from "flux:audio".
224
225
  */
225
226
  export function createSoundStream(source: string | FluxFile, options: SoundStreamOptions = {}): Sound {
226
227
  let src = typeof source === "string" ? file(source) : source