@solidrt/core 0.0.58 → 0.0.60

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,12 +111,18 @@ 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.6); bun resolves them from peerDependencies. On a version bump,
114
+ 2.0.0-rc.8); 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
118
- latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
119
- `rm -rf bun.lock node_modules/solid-js node_modules/@solidjs && bun install`.
118
+ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works
119
+ deletes the installed copies and leaves the lockfile alone:
120
+ `rm -rf node_modules/solid-js node_modules/@solidjs && bun install`
121
+ (delete any nested `node_modules/@solidjs` too, or the old copy keeps
122
+ resolving). Removing the directories is what forces re-resolution. Do not
123
+ delete `bun.lock` to force it: that re-resolves every unrelated dependency
124
+ as well, and a package that floats elsewhere fails the typecheck in a way
125
+ that reads exactly like Solid fallout.
120
126
 
121
127
  ## Element model (the parts that are easy to get wrong)
122
128
 
@@ -455,13 +461,32 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
455
461
  write targets, and the runtime animates natively with no per-frame JS.
456
462
  Enter and exit are per-property entries on that same spec: `from` is the
457
463
  value the property animates in from at first attach, `exit` the value it
458
- animates to on removal (the node stays painted until it settles, then
459
- frees - no AnimatePresence equivalent needed). `stagger` (ms) goes on an
464
+ animates to on removal. An exit reaches the whole unmounted subtree: every
465
+ `exit` declared under the node that unmounts plays, and the node stays
466
+ painted until the last of them settles, then frees - a panel's contents
467
+ leave with `exit` beside their `from`, no closing signal, held mount or
468
+ settle cue (no AnimatePresence equivalent needed). One rule in both
469
+ trees: a node animating out is a ghost, painted and nothing else -
470
+ hit-test invisible here, and in the spatial arena (a @solidrt/2d sprite,
471
+ a @solidrt/3d node, whose `destroy` verbs carry the same `exit`)
472
+ invisible to every pick, raycast, overlap and sweep - while an entering
473
+ node is live from its first frame, whatever `from` it passes through.
474
+ Either endpoint takes
475
+ `{ value, curve?, duration?, bounce?, delay? }` to own its direction's
476
+ motion (an ease-out enter, an ease-in exit). `stagger` (ms) goes on an
460
477
  ANCESTOR, never on the animating elements: it delays each descendant's
461
478
  enter/exit by `index * stagger`, and cascades nothing unless the
462
479
  descendants declare `from`/`exit`. An enter plays once per mount; to
463
480
  replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
464
- then bump `epoch`). See examples/stagger.tsx.
481
+ then bump `epoch`). See examples/stagger.tsx. The companion to the exit
482
+ 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
489
+ examples/layout-slide.tsx.
465
490
  Per-frame work is for genuinely procedural motion, and a game or a
466
491
  simulation is that in full: its natural shape is ONE `onFrame` doing one
467
492
  sim step and N property (or sprite) writes, which is not a smell but the
package/docs/index.md CHANGED
@@ -146,6 +146,29 @@ let camera = createCamera()
146
146
  The same pattern covers `@solidrt/core/microphone`, `/sound`,
147
147
  `/speech-recognition`, `/text-input`, `/image`, `/color`, and `/gpu`.
148
148
 
149
+ ## Lifecycle
150
+
151
+ The runtime reports the platform's lifecycle facts and holds the platform
152
+ open while the app acts on them; what to do with them is the app's policy.
153
+ `onBack` is the user's back intent (prevent it for in-app navigation, or let
154
+ it fall through to the platform's default: `background()` on Android, where
155
+ the system keeps the app behind the others, `exit()` elsewhere). `onSuspend` fires when the app is being
156
+ suspended and may be killed without notice (switching away on Android or
157
+ iOS; never on desktop): persist session state there, async is fine, the
158
+ runtime waits for the returned promise up to its deadline. `onQuit` fires
159
+ when this app instance is ending (`exit()`, the window closing, the Android
160
+ activity finishing) and waits the same way, inside the platform's close
161
+ budget. `env.launch` says how this process came to run: "restored" when the
162
+ system recreated the app from a session it ended on its own, "fresh" for a
163
+ launch the user started. Whether to load what `onSuspend` saved is the app's
164
+ call. `env.visibility` is only whether anything is on screen, for pausing
165
+ animation and audio, not the persistence moment.
166
+
167
+ ```tsx
168
+ onSuspend(() => file("session.json").write(JSON.stringify(state)))
169
+ if (env.launch === "restored") state = JSON.parse(await file("session.json").text())
170
+ ```
171
+
149
172
  ## Reference
150
173
 
151
174
  The [reference](/core/reference/) covers the API by subject: the element
@@ -21,6 +21,7 @@ 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
25
 
25
26
  ## Pointer input
26
27
  - `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.
@@ -60,22 +60,22 @@ let FRAGMENT_PULSE = glsl`
60
60
  function App() {
61
61
  let triangle = createBuffer(new Float32Array([0, 0.6, -0.5, -0.4, 0.5, -0.4]), { label: "tri" })
62
62
  let vs = compileShader("vertex", VERTEX, { header: true })
63
- let attrs = [{ name: "aPos", format: "vec2" as const }]
63
+ let attrs = [{ name: "aPos", format: "float32x2" as const }]
64
64
  let warm = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_WARM, { header: true })), {
65
- attributes: attrs,
65
+ buffers: [{ attributes: attrs }],
66
66
  depth: true,
67
67
  })
68
68
  let cool = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_COOL, { header: true })), {
69
- attributes: attrs,
69
+ buffers: [{ attributes: attrs }],
70
70
  depth: true,
71
71
  })
72
72
  let pulse = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_PULSE, { header: true })), {
73
- attributes: attrs,
73
+ buffers: [{ attributes: attrs }],
74
74
  })
75
75
 
76
76
  let target = createDrawTarget(512, 512, null, { depth: true, clearColor: [0.04, 0.04, 0.08, 1], label: "orbits" })
77
- let warmDraw = addDraw(target, warm, { uAngle: 0 }, { buffer: triangle })
78
- let coolDraw = addDraw(target, cool, { uAngle: Math.PI }, { buffer: triangle })
77
+ let warmDraw = addDraw(target, warm, { uAngle: 0 }, { buffers: [triangle] })
78
+ let coolDraw = addDraw(target, cool, { uAngle: Math.PI }, { buffers: [triangle] })
79
79
 
80
80
  // A third entry blinks in and out every second: add/remove are ordinary
81
81
  // per-frame-affordable writes, and a removed DrawId simply retires. Its
@@ -91,7 +91,7 @@ function App() {
91
91
  setDrawParams(target, coolDraw, { uAngle: t + Math.PI })
92
92
  let wantPulse = Math.floor(t) % 2 === 0
93
93
  if (wantPulse && pulseDraw === null) {
94
- pulseDraw = addDraw(target, pulse, { uAngle: t * 0.3, uPhase: t * 5 }, { buffer: triangle, before: warmDraw })
94
+ pulseDraw = addDraw(target, pulse, { uAngle: t * 0.3, uPhase: t * 5 }, { buffers: [triangle], before: warmDraw })
95
95
  } else if (!wantPulse && pulseDraw !== null) {
96
96
  removeDraw(target, pulseDraw)
97
97
  pulseDraw = null
@@ -1,7 +1,7 @@
1
1
  // Instanced drawing with per-instance attributes: one 3-vertex triangle in
2
2
  // the vertex buffer, drawn hundreds of times, each copy reading its own
3
- // record from an instance buffer (instanceAttributes on the pipeline +
4
- // instanceBuffer on the target, vertex divisor 1 underneath). Here each
3
+ // record from an instance buffer (a `stepMode: "instance"` layout on the
4
+ // pipeline, vertex divisor 1 underneath). Here each
5
5
  // record is one petal of a phyllotaxis spiral - its angle, radius, size, and
6
6
  // tint, computed once in JS - so the vertex stage just reads state instead
7
7
  // of re-deriving it from gl_InstanceID every frame, and the geometry buffer
@@ -45,7 +45,7 @@ let FRAGMENT = glsl`
45
45
  }
46
46
  `
47
47
 
48
- // One record per petal (matching instanceAttributes: 6 floats): radius grows
48
+ // One record per petal (matching the instance layout: 6 floats): radius grows
49
49
  // with sqrt(index), the angle steps by the golden angle (~2.39996 rad) - the
50
50
  // sunflower layout - petals shrink toward the rim, and the tint walks a
51
51
  // cosine palette.
@@ -70,15 +70,19 @@ function App() {
70
70
  let instanceId = createBuffer(petalRecords(), { label: "petal-records" })
71
71
  let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0 }, {
72
72
  label: "petals",
73
- attributes: [{ name: "aPos", format: "vec2" }],
74
- buffer: bufferId,
75
- instanceAttributes: [
76
- { name: "iAngle", format: "f32" },
77
- { name: "iRadius", format: "f32" },
78
- { name: "iScale", format: "f32" },
79
- { name: "iTint", format: "vec3" },
73
+ buffers: [
74
+ { attributes: [{ name: "aPos", format: "float32x2" }], buffer: bufferId },
75
+ {
76
+ stepMode: "instance",
77
+ attributes: [
78
+ { name: "iAngle", format: "float32" },
79
+ { name: "iRadius", format: "float32" },
80
+ { name: "iScale", format: "float32" },
81
+ { name: "iTint", format: "float32x3" },
82
+ ],
83
+ buffer: instanceId,
84
+ },
80
85
  ],
81
- instanceBuffer: instanceId,
82
86
  instanceCount: 1,
83
87
  clearColor: [0.03, 0.03, 0.06, 1],
84
88
  })
@@ -74,11 +74,15 @@ function App() {
74
74
  let bufferId = createBuffer(particles(1500), { label: "particle-verts" })
75
75
  let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0, uTintA: [1.0, 0.45, 0.15], uTintB: [0.25, 0.5, 1.0] }, {
76
76
  label: "particles",
77
- attributes: [
78
- { name: "aPos", format: "vec3" },
79
- { name: "aSeed", format: "f32" },
77
+ buffers: [
78
+ {
79
+ attributes: [
80
+ { name: "aPos", format: "float32x3" },
81
+ { name: "aSeed", format: "float32" },
82
+ ],
83
+ buffer: bufferId,
84
+ },
80
85
  ],
81
- buffer: bufferId,
82
86
  topology: "points",
83
87
  blend: "add",
84
88
  clearColor: [0.02, 0.02, 0.05, 1],
@@ -87,11 +87,15 @@ function App() {
87
87
  let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
88
88
  let id = createPipelineTexture(VERTEX, FRAGMENT, 1024, 1024, { uTime: 0 }, {
89
89
  label: "cube",
90
- attributes: [
91
- { name: "aPos", format: "vec3" },
92
- { name: "aColor", format: "vec3" },
90
+ buffers: [
91
+ {
92
+ attributes: [
93
+ { name: "aPos", format: "float32x3" },
94
+ { name: "aColor", format: "float32x3" },
95
+ ],
96
+ buffer: bufferId,
97
+ },
93
98
  ],
94
- buffer: bufferId,
95
99
  indexBuffer: indexId,
96
100
  indexFormat: "uint16",
97
101
  depth: true,
@@ -65,12 +65,12 @@ let FRAGMENT_MAP = glsl`
65
65
  function App() {
66
66
  let quad = createBuffer(new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]), { label: "quad" })
67
67
  let vs = compileShader("vertex", VERTEX, { header: true })
68
- let attrs = [{ name: "aPos", format: "vec2" as const }]
68
+ let attrs = [{ name: "aPos", format: "float32x2" as const }]
69
69
  let tint = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_TINT, { header: true })), {
70
- attributes: attrs,
70
+ buffers: [{ attributes: attrs }],
71
71
  })
72
72
  let mapped = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_MAP, { header: true })), {
73
- attributes: attrs,
73
+ buffers: [{ attributes: attrs }],
74
74
  })
75
75
 
76
76
  // 2x2 patterns, nearest + repeat, so gl_FragCoord tiling shows hard cells.
@@ -99,12 +99,12 @@ function App() {
99
99
  if (i === 1 || i === 5) {
100
100
  // Patterned entries read the shared uMap; the one at i === 5 brings
101
101
  // its own binding and keeps its stripes through every shared swap.
102
- addDraw(target, mapped, { uCenter }, i === 5 ? { buffer: quad, textures: { uMap: stripes } } : { buffer: quad })
102
+ addDraw(target, mapped, { uCenter }, i === 5 ? { buffers: [quad], textures: { uMap: stripes } } : { buffers: [quad] })
103
103
  } else if (i === 3) {
104
104
  // The override quad: its own uTint beats the shared color cycle.
105
- addDraw(target, tint, { uCenter, uTint: [1, 0.62, 0.1, 1] }, { buffer: quad })
105
+ addDraw(target, tint, { uCenter, uTint: [1, 0.62, 0.1, 1] }, { buffers: [quad] })
106
106
  } else {
107
- addDraw(target, tint, { uCenter }, { buffer: quad })
107
+ addDraw(target, tint, { uCenter }, { buffers: [quad] })
108
108
  }
109
109
  }
110
110
 
@@ -60,14 +60,18 @@ function App() {
60
60
  label: "sprites",
61
61
  topology: "triangle-strip",
62
62
  vertexCount: 4,
63
- attributes: [{ name: "aPos", format: "vec2" }],
64
- buffer: quad,
65
- instanceAttributes: [
66
- { name: "iCenter", format: "vec2" },
67
- { name: "iSize", format: "f32" },
68
- { name: "iTint", format: "vec3" },
63
+ buffers: [
64
+ { attributes: [{ name: "aPos", format: "float32x2" }], buffer: quad },
65
+ {
66
+ stepMode: "instance",
67
+ attributes: [
68
+ { name: "iCenter", format: "float32x2" },
69
+ { name: "iSize", format: "float32" },
70
+ { name: "iTint", format: "float32x3" },
71
+ ],
72
+ buffer: records,
73
+ },
69
74
  ],
70
- instanceBuffer: records,
71
75
  instanceCount: MAX_SPRITES,
72
76
  clearColor: [0.03, 0.03, 0.06, 1],
73
77
  })
@@ -0,0 +1,78 @@
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>`
8
+ // reorder moves the surviving nodes, so every displaced row slides. Tap
9
+ // the footer to add a row back: the rows below its slot slide apart and
10
+ // 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"
15
+ import type { Transition } from "@solidrt/core"
16
+
17
+ const ALL_ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries", "Resources"]
18
+ // Rows the list starts with; the rest come back through the footer.
19
+ const INITIAL_ROWS = 4
20
+ // Every reflow slides on this spring.
21
+ const SLIDE = { duration: 400, bounce: 0.15 } satisfies Transition
22
+ // Enter from the left, leave to the right, each direction on its own curve.
23
+ const ARRIVE = {
24
+ duration: 300,
25
+ curve: "ease-out",
26
+ from: -80,
27
+ exit: { value: 80, curve: "ease-in", duration: 200 },
28
+ } satisfies Transition
29
+ const FADE = {
30
+ duration: 300,
31
+ curve: "ease-out",
32
+ from: 0,
33
+ exit: { value: 0, curve: "ease-in", duration: 200 },
34
+ } satisfies Transition
35
+
36
+ function shuffled(items: string[]): string[] {
37
+ let pool = items.slice()
38
+ let out: string[] = []
39
+ while (pool.length > 0) out.push(...pool.splice(Math.floor(Math.random() * pool.length), 1))
40
+ return out
41
+ }
42
+
43
+ function App() {
44
+ let [rows, setRows] = createSignal(ALL_ROWS.slice(0, INITIAL_ROWS))
45
+ let addRow = () => {
46
+ let next = ALL_ROWS.find((label) => !rows().includes(label))
47
+ if (next) setRows([...rows(), next])
48
+ }
49
+
50
+ return (
51
+ <window padding={40} gap={12}>
52
+ <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>
54
+ </view>
55
+ <For each={rows()}>
56
+ {(label) => (
57
+ <view
58
+ x={0}
59
+ opacity={1}
60
+ transition={{ layout: SLIDE, x: ARRIVE, opacity: FADE }}
61
+ height={48}
62
+ justifyContent="center"
63
+ paddingLeft={16}
64
+ onPointerDown={() => setRows(rows().filter((row) => row !== label))}
65
+ >
66
+ <d-rect color="#2a3a55" radius={10} />
67
+ <text color="#e8eef6" fontSize={18}>{label}</text>
68
+ </view>
69
+ )}
70
+ </For>
71
+ <view height={40} justifyContent="center" transition={{ layout: SLIDE }} onPointerDown={addRow}>
72
+ <text color="#8899aa" fontSize={14}>Tap here to add a row back</text>
73
+ </view>
74
+ </window>
75
+ )
76
+ }
77
+
78
+ render(() => <App />)
@@ -1,16 +1,24 @@
1
1
  // Enter, exit and stagger are declared on elements; the runtime plays them
2
2
  // with no per-frame JS. `from` is the value a property animates in from at
3
- // first attach, `exit` the value it animates to on removal - the node stays
4
- // painted until that settles, then frees. Both are per-property entries on
5
- // the element's own transition spec. `stagger` (ms) goes on an ANCESTOR,
6
- // never on the animating elements: every descendant enter or exit that
7
- // begins in the same frame gets index * stagger of extra delay, in
8
- // occurrence order. It cascades nothing on its own - the descendants must
9
- // declare from/exit.
3
+ // first attach, `exit` the value it animates to on removal. Both are
4
+ // per-property entries on the element's own transition spec. An exit
5
+ // reaches the whole removed subtree: every `exit` declared under the node
6
+ // that unmounts plays, and that node stays painted until the last of them
7
+ // settles, then frees. It leaves the layout at once, so a fresh panel
8
+ // mounted mid-exit takes its place while the old one fades over it. The
9
+ // rows below never unmount one by one - the panel around them does - and
10
+ // they still leave the way they arrived. Each direction owns its motion:
11
+ // `from`/`exit` take a bare value (the entry's curve, duration and delay)
12
+ // or `{ value, curve?, duration?, bounce?, delay? }`, so the fade below
13
+ // eases out on the way in and eases in, shorter, on the way out.
14
+ // `stagger` (ms) goes on an ANCESTOR, never on the animating elements: every
15
+ // descendant enter or exit that begins in the same frame gets index * stagger
16
+ // of extra delay, in tree order. It cascades nothing on its own - the
17
+ // descendants must declare from/exit.
10
18
  //
11
19
  // An enter plays once per mount. To replay it, remount the subtree: a keyed
12
20
  // <Show> re-creates its child whenever `when` changes value, so bumping an
13
- // epoch replays the whole cascade. Tap anywhere: odd taps unmount the list
21
+ // epoch replays the whole cascade. Tap anywhere: odd taps unmount the panel
14
22
  // (staggered exits), even taps mount a fresh one (staggered enters).
15
23
  import { render, createSignal, Show } from "@solidrt/core"
16
24
  import type { Transition } from "@solidrt/core"
@@ -18,25 +26,33 @@ import type { Transition } from "@solidrt/core"
18
26
  const ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries"]
19
27
  // Extra delay per row before its enter or exit starts.
20
28
  const STAGGER_MS = 70
21
- // Slide in from the left, out to the right; fade both ways.
29
+ // Slide in from the left, out to the right; fade both ways, the exit on
30
+ // its own curve and clock.
22
31
  const SLIDE = { duration: 500, bounce: 0.2, from: -80, exit: 80 } satisfies Transition
23
- const FADE = { duration: 350, curve: "ease-out", from: 0, exit: 0 } satisfies Transition
32
+ const FADE = {
33
+ duration: 350,
34
+ curve: "ease-out",
35
+ from: 0,
36
+ exit: { value: 0, curve: "ease-in", duration: 200 },
37
+ } satisfies Transition
24
38
 
25
39
  function App() {
26
40
  let [epoch, setEpoch] = createSignal(1)
27
41
 
28
42
  return (
29
- <window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)} transition={{ stagger: STAGGER_MS }}>
43
+ <window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)}>
30
44
  <text color="#8899aa" fontSize={14}>Tap to replay</text>
31
45
  <Show when={epoch() % 2 === 1 ? epoch() : 0} keyed>
32
- {(_epoch) =>
33
- ROWS.map((label) => (
34
- <view x={0} opacity={1} transition={{ x: SLIDE, opacity: FADE }} height={48} justifyContent="center" paddingLeft={16}>
35
- <d-rect color="#2a3a55" radius={10} />
36
- <text color="#e8eef6" fontSize={18}>{label}</text>
37
- </view>
38
- ))
39
- }
46
+ {(_epoch) => (
47
+ <view gap={12} transition={{ stagger: STAGGER_MS }}>
48
+ {ROWS.map((label) => (
49
+ <view x={0} opacity={1} transition={{ x: SLIDE, opacity: FADE }} height={48} justifyContent="center" paddingLeft={16}>
50
+ <d-rect color="#2a3a55" radius={10} />
51
+ <text color="#e8eef6" fontSize={18}>{label}</text>
52
+ </view>
53
+ ))}
54
+ </view>
55
+ )}
40
56
  </Show>
41
57
  </window>
42
58
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.58",
3
+ "version": "0.0.60",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -31,11 +31,11 @@
31
31
  "AGENTS.md"
32
32
  ],
33
33
  "devDependencies": {
34
- "@solidrt/flux-types": "0.0.58"
34
+ "@solidrt/flux-types": "0.0.60"
35
35
  },
36
36
  "peerDependencies": {
37
- "@solidjs/signals": "2.0.0-rc.6",
38
- "@solidjs/universal": "2.0.0-rc.6",
39
- "solid-js": "2.0.0-rc.6"
37
+ "@solidjs/signals": "2.0.0-rc.8",
38
+ "@solidjs/universal": "2.0.0-rc.8",
39
+ "solid-js": "2.0.0-rc.8"
40
40
  }
41
41
  }
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
@@ -37,6 +37,8 @@ export type SystemTheme = "dark" | "light" | "unknown"
37
37
 
38
38
  export type Visibility = "visible" | "hidden"
39
39
 
40
+ export type Launch = "fresh" | "restored"
41
+
40
42
  export type Orientation = "portrait" | "portraitFlipped" | "landscape" | "landscapeFlipped" | "unknown"
41
43
 
42
44
  let devicesAccessor: (() => InputDevices | undefined) | undefined
@@ -65,6 +67,22 @@ function ensureSystemThemeState() {
65
67
  })
66
68
  }
67
69
 
70
+ // Fixed for the process, so a plain value rather than a signal: the sticky
71
+ // `launch` event replays synchronously on subscribe, and the runner emits it
72
+ // before any app code runs.
73
+ let launchValue: Launch | undefined
74
+
75
+ function ensureLaunchState() {
76
+ if (launchValue) return
77
+ runWithOwner(null, () => {
78
+ on("launch", (e: { state?: Launch }) => {
79
+ launchValue = e.state === "restored" ? "restored" : "fresh"
80
+ })
81
+ })
82
+ // A runtime that reports no launch fact (desktop today) launches fresh.
83
+ launchValue ??= "fresh"
84
+ }
85
+
68
86
  let visibilityAccessor: (() => Visibility) | undefined
69
87
 
70
88
  function ensureVisibilityState() {
@@ -218,15 +236,27 @@ export let env = {
218
236
  * `visibilityState` vocabulary without the `document` machinery - react
219
237
  * to it in a tracked scope (JSX, memo, effect).
220
238
  *
221
- * This is the persistence moment: there is no close event on any
222
- * platform (Android gives no time, desktop window close never enters
223
- * JS), so save state when this goes "hidden". While hidden, timers keep
224
- * running but no frames are produced.
239
+ * A rendering and UI fact (nothing is on screen: stop animating, pause
240
+ * the audio), not the persistence moment - that is `onSuspend`, which the
241
+ * runtime waits for. While hidden no frames are produced.
225
242
  */
226
243
  get visibility(): Visibility {
227
244
  ensureVisibilityState()
228
245
  return visibilityAccessor!()
229
246
  },
247
+ /**
248
+ * How this process came to run. "restored": the system recreated the app
249
+ * from a session it ended on its own (a suspended app reclaimed in the
250
+ * background; Android's saved instance state). "fresh": launched anew, a
251
+ * first start or one after `exit()` or a close ended the previous
252
+ * instance, and always on desktop. Reported, not interpreted: whether to
253
+ * load what `onSuspend` saved is the app's decision. Fixed for the
254
+ * process, so it needs no tracked scope.
255
+ */
256
+ get launch(): Launch {
257
+ ensureLaunchState()
258
+ return launchValue!
259
+ },
230
260
  /** Orientation of the display the window is on. */
231
261
  get orientation(): Orientation {
232
262
  ensureOrientationState()
package/src/gpu.ts CHANGED
@@ -135,7 +135,7 @@ export {
135
135
  // updated draw range (vertexCount after its buffer gained or lost dynamic
136
136
  // geometry, firstVertex for a different window of a shared buffer,
137
137
  // instanceCount for an instanced population; absent keys keep their current
138
- // value, like params) and/or swapped buffers (instanceBuffer pointed at a
138
+ // value, like params) and/or swapped buffers (an instance buffer pointed at a
139
139
  // larger buffer once a population outgrows the old one - the growth
140
140
  // primitive; replace-only, the range is rechecked); destroyBuffer is the manual
141
141
  // cleanup path for buffers created outside a reactive scope. renderTarget is
@@ -147,7 +147,7 @@ export {
147
147
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
148
148
  // ping-pong buffer, reset state to a known image.
149
149
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
150
- export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
150
+ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceOrder, OrderUpdate, ShaderParams, Topology, VertexAttribute, VertexBufferLayout, VertexFormat } from "flux:gpu"
151
151
 
152
152
  // The draw-list verbs, re-exported raw: entries live and die with their draw
153
153
  // target (see createDrawTarget below), so there is no per-entry lifetime to
@@ -366,20 +366,18 @@ export function createShaderTexture(
366
366
  * texture id is, e.g. `<texture src>`; resize with `setTargetSize`, drive
367
367
  * uniforms with `<texture params>` or `setTargetParams`). Many targets may
368
368
  * share one pipeline, and creating a target compiles nothing. The target
369
- * brings the per-target half: size, the concrete vertex `buffer` the
370
- * pipeline's attribute layout describes, the `instanceBuffer` its
371
- * `instanceAttributes` describe (required exactly when it declares any),
372
- * the draw range (`vertexCount` defaults to the rest of the buffer from
373
- * `firstVertex` on, `instanceCount` repeats it as instances told apart by
374
- * `gl_InstanceID` and defaults to one per instance-buffer record; a
375
- * fullscreen pass over an attributeless pipeline is `{ vertexCount: 3 }`
376
- * with a covering-triangle vertex stage), uniforms, and
377
- * `clearColor`. An `indexBuffer` + `indexFormat` pair makes the draw indexed
378
- * (shared vertices stored once), with the range in `firstIndex`/`indexCount`
379
- * spelling - see IndexBinding/IndexRange. Draw state (`attributes`,
380
- * `instanceAttributes`, `topology`, `blend`, `cull`, `depth`, `depthWrite`)
381
- * lives on the pipeline
382
- * and throws here. Frees the target when the reactive owner is disposed (opt
369
+ * brings the per-target half: size, the concrete `buffers` the pipeline's
370
+ * layouts describe (one id per layout, in declaration order), the draw
371
+ * range (`vertexCount` defaults to the rest of the tightest vertex-step
372
+ * buffer from `firstVertex` on, `instanceCount` repeats it as instances
373
+ * told apart by `gl_InstanceID` and defaults to one per record of the
374
+ * tightest instance-step buffer; a fullscreen pass over an attributeless
375
+ * pipeline is `{ vertexCount: 3 }` with a covering-triangle vertex stage),
376
+ * uniforms, and `clearColor`. An `indexBuffer` + `indexFormat` pair makes
377
+ * the draw indexed (shared vertices stored once), with the range in
378
+ * `firstIndex`/`indexCount` spelling - see IndexBinding/IndexRange. Draw
379
+ * state (`topology`, `blend`, `cull`, `depth`, `depthWrite`) lives on the
380
+ * pipeline and throws here. Frees the target when the reactive owner is disposed (opt
383
381
  * out with `autoFree: false`); the pipeline is yours and outlives it.
384
382
  *
385
383
  * `render: "manual"` makes it a manual target: the runtime never renders it
@@ -404,11 +402,8 @@ export function createShaderTarget(
404
402
  params?: gpu.ShaderParams | null,
405
403
  opts?: {
406
404
  textures?: gpu.TextureBindings
407
- buffer?: gpu.BufferId
408
- instanceBuffer?: gpu.BufferId
409
- /** One buffer per instance slot of the pipeline (index = the
410
- * attributes' `slot`); pass this OR `instanceBuffer`, not both. */
411
- instanceBuffers?: gpu.BufferId[]
405
+ /** One buffer id per layout of the pipeline, in declaration order. */
406
+ buffers?: gpu.BufferId[]
412
407
  /** Draw the instance records in key order (see InstanceOrder). */
413
408
  instanceOrder?: gpu.InstanceOrder
414
409
  clearColor?: [number, number, number, number]
@@ -658,9 +653,9 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
658
653
  * texture, returning the texture id (usable anywhere a normal texture id is,
659
654
  * e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
660
655
  * back. Unlike `createShaderTexture` the vertex stage is yours:
661
- * declare `in` attributes matching `opts.attributes` (one interleaved vertex
662
- * in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
663
- * the fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
656
+ * declare `in` attributes matching `opts.buffers` (each layout with the
657
+ * {@link createBuffer} id it reads) and your own varyings toward the
658
+ * fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
664
659
  * row of the target and +1 the bottom, so camera-up geometry must negate y
665
660
  * (or fold the flip into its projection) to display up. Both sources may
666
661
  * reference `iResolution` and any uniform they declare (`float`/`int`
@@ -679,10 +674,10 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
679
674
  * see DrawRange) defaults to the whole buffer drawn once and can be changed
680
675
  * later with `setDraw`; `instanceCount` is the standard answer to particles
681
676
  * and repeated meshes, N copies of the range told apart by `gl_InstanceID`
682
- * in the vertex stage. `opts.instanceAttributes` + `opts.instanceBuffer`
683
- * (declare both or neither) give each instance its own interleaved record -
684
- * real per-instance state instead of `gl_InstanceID` arithmetic - and
685
- * `instanceCount` then defaults to one instance per record. An
677
+ * in the vertex stage. A layout with `stepMode: "instance"` gives each
678
+ * instance its own interleaved record - real per-instance state instead of
679
+ * `gl_InstanceID` arithmetic - and `instanceCount` then defaults to one
680
+ * instance per record. An
686
681
  * `indexBuffer` + `indexFormat` pair makes the draw
687
682
  * indexed (shared vertices stored once), with the range in
688
683
  * `firstIndex`/`indexCount` spelling; `opts.cull` discards one face set by
@@ -701,13 +696,8 @@ export function createPipelineTexture(
701
696
  params?: gpu.ShaderParams | null,
702
697
  opts?: {
703
698
  textures?: gpu.TextureBindings
704
- attributes?: gpu.VertexAttribute[]
705
- buffer?: gpu.BufferId
706
- instanceAttributes?: gpu.InstanceAttribute[]
707
- instanceBuffer?: gpu.BufferId
708
- /** One buffer per instance slot (index = the attributes' `slot`);
709
- * pass this OR `instanceBuffer`, not both. */
710
- instanceBuffers?: gpu.BufferId[]
699
+ /** The pipeline's layouts, each with the buffer it reads. */
700
+ buffers?: (gpu.VertexBufferLayout & { buffer: gpu.BufferId })[]
711
701
  /** Draw the instance records in key order (see InstanceOrder). */
712
702
  instanceOrder?: gpu.InstanceOrder
713
703
  topology?: gpu.Topology
package/src/index.ts CHANGED
@@ -3,11 +3,11 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
- export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
7
- export type { BackEvent } from "./window"
6
+ export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onSuspend, onQuit, exit, background } from "./window"
7
+ export type { BackEvent, LifecycleHandler } from "./window"
8
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
9
9
  export { env } from "./environment"
10
- export type { InputDevices, SystemTheme, Orientation, Visibility } from "./environment"
10
+ export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
11
11
  export { gamepads } from "./gamepad"
12
12
  export type { GamepadState } from "./gamepad"
13
13
  export { capabilities } from "./capabilities"
package/src/renderer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createRoot, onCleanup, NotReadyError } from "@solidjs/signals"
1
+ import { createRoot, onCleanup, NotReadyError, untrack } from "@solidjs/signals"
2
2
  import { createRenderer } from "@solidjs/universal"
3
3
  import { createErrorBoundary } from "solid-js"
4
4
  import type { Element } from "solid-js"
@@ -51,9 +51,13 @@ let destroyScheduled = false
51
51
  // before dropping handlers so onBlur still fires for a focused descendant.
52
52
  function destroyNode(node: ProxyNode): void {
53
53
  tree.destroyNode(node.id)
54
+ // Read once, untracked: destroyNode runs inside effect apply phases (the
55
+ // root boundary's swap, the end-of-tick sweep), where a per-node signal
56
+ // read is a STRICT_READ_UNTRACKED warning per node.
57
+ let focused = untrack(focusedNode)
54
58
  let cleanup = (n: ProxyNode) => {
55
59
  for (let child of n.children) if (child.parent === n) cleanup(child)
56
- if (n.id === focusedNode()) setFocus(null)
60
+ if (n.id === focused) setFocus(null)
57
61
  nodes.delete(n.id)
58
62
  cleanupNode(n.id)
59
63
  }
@@ -288,6 +292,17 @@ let renderer = createRenderer<ProxyNode>({
288
292
  if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
289
293
  else tree.insertNode(parent.id, node.id)
290
294
 
295
+ // A bare move - Solid reorders a <For> with insertBefore on a node
296
+ // that is still attached, no removeNode first - unlinks the node from
297
+ // wherever it was on the native side (a same-parent reorder included).
298
+ // The mirror must drop the old entry too, or it keeps both and
299
+ // getNextSibling hands Solid stale anchors from then on, so every
300
+ // later insert lands in the wrong place.
301
+ let previous = node.parent
302
+ if (previous) {
303
+ let at = previous.children.indexOf(node)
304
+ if (at !== -1) previous.children.splice(at, 1)
305
+ }
291
306
  node.parent = parent
292
307
 
293
308
  if (!anchor) {
@@ -66,12 +66,19 @@ declare module "srt:events" {
66
66
  // The running application's own surface (lattice), present in every build.
67
67
  declare module "srt:app" {
68
68
  /**
69
- * Leave the current app, unconditionally: back to the player in a dev
70
- * client, quit when standalone or at the player root (on Android the
71
- * client backgrounds instead of dying). The default action of an
72
- * unprevented `back` event; prefer the @solidrt/core re-export.
69
+ * End the current app now, without running the quit hooks: back to the
70
+ * player in a dev client, quit when standalone or at the player root (on
71
+ * Android the activity finishes). Prefer the @solidrt/core `exit`, which
72
+ * runs `onQuit` first.
73
73
  */
74
74
  export function exit(): void
75
+ /**
76
+ * Leave the current app without ending it: back to the player in a dev
77
+ * client; otherwise to the OS background (Android moveTaskToBack, a
78
+ * minimized window on desktop). Core's default action of an unprevented
79
+ * `back` on Android; exposed there as `background`.
80
+ */
81
+ export function background(): void
75
82
  }
76
83
 
77
84
  // Dev-server control surface (lattice). Present only in dev/go builds; in other
package/src/types.d.ts CHANGED
@@ -461,6 +461,23 @@ export interface LineGeometryProps extends PositionProps {
461
461
  /** A cubic-bezier timing curve: a CSS name or [x1, y1, x2, y2] control values. */
462
462
  export type TransitionCurve = "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | [number, number, number, number]
463
463
 
464
+ /**
465
+ * The object form of `from`/`exit`: the endpoint value plus that
466
+ * direction's own motion, so an ease-out enter pairs with an ease-in exit
467
+ * instead of playing the entry's curve backwards. Every field but `value`
468
+ * is optional and falls back to the entry's (`delay` included, so a
469
+ * staggered list needs no change), except that naming a `curve` or a
470
+ * `bounce` decides the kind outright: `{ value: 0, curve: "ease-in" }` on a
471
+ * spring entry is a tween of the entry's duration.
472
+ */
473
+ export interface TransitionEndpoint {
474
+ value: number | string
475
+ duration?: number
476
+ curve?: TransitionCurve
477
+ bounce?: number
478
+ delay?: number
479
+ }
480
+
464
481
  /**
465
482
  * A perceptual spring - the default kind: a bare `{ duration }` is a
466
483
  * critically damped spring. `duration` (ms) is the perceptual settling
@@ -479,20 +496,26 @@ export interface TransitionSpring {
479
496
  * Mount-time enter animation: at the element's first attach the property
480
497
  * snaps to this value and animates to the value it mounted with. Numbers
481
498
  * for the scalar properties; the color property takes a CSS color string
482
- * or packed number. Per-property entries only (not under `all`); a later
483
- * move or reorder re-runs nothing.
499
+ * or packed number, or a {@link TransitionEndpoint} giving the enter its
500
+ * own curve, duration, bounce or delay. Per-property entries only (not
501
+ * under `all`); a later move or reorder re-runs nothing.
484
502
  */
485
- from?: number | string
503
+ from?: number | string | TransitionEndpoint
486
504
  /**
487
- * Removal exit animation: an unmounted element stays visible, animates
488
- * the property to this value (honoring `delay`), and is freed when its
489
- * exit animations settle. Same value forms as `from`, per-property only.
490
- * A move never plays it, the exiting element is hit-test invisible, its
491
- * whole subtree stays painted with it, and no onTransitionEnd fires (the
492
- * component is already disposed). An attached element keeps its layout
493
- * slot until the exit finishes.
505
+ * Removal exit animation: when an element unmounts, every `exit`
506
+ * declared on it or under it animates its property to this value
507
+ * (honoring `delay`), and the unmounted element stays visible, subtree
508
+ * and all, until the last of them settles, then frees. Same value forms
509
+ * as `from`, per-property only. A move never plays it, the exiting
510
+ * subtree is hit-test invisible, and no onTransitionEnd fires (the
511
+ * components are already disposed). An exiting element leaves the layout
512
+ * flow at once: its siblings reflow as if it were gone, and it is painted
513
+ * at its last box, which follows its parent's moves but not the parent's
514
+ * own reflow. The {@link TransitionEndpoint} form gives the exit its own
515
+ * motion: an ease-out enter reads wrong played backwards, so pair it with
516
+ * `exit: { value, curve: "ease-in" }`.
494
517
  */
495
- exit?: number | string
518
+ exit?: number | string | TransitionEndpoint
496
519
  }
497
520
 
498
521
  /**
@@ -511,20 +534,26 @@ export interface TransitionTween {
511
534
  * Mount-time enter animation: at the element's first attach the property
512
535
  * snaps to this value and animates to the value it mounted with. Numbers
513
536
  * for the scalar properties; the color property takes a CSS color string
514
- * or packed number. Per-property entries only (not under `all`); a later
515
- * move or reorder re-runs nothing.
537
+ * or packed number, or a {@link TransitionEndpoint} giving the enter its
538
+ * own curve, duration, bounce or delay. Per-property entries only (not
539
+ * under `all`); a later move or reorder re-runs nothing.
516
540
  */
517
- from?: number | string
541
+ from?: number | string | TransitionEndpoint
518
542
  /**
519
- * Removal exit animation: an unmounted element stays visible, animates
520
- * the property to this value (honoring `delay`), and is freed when its
521
- * exit animations settle. Same value forms as `from`, per-property only.
522
- * A move never plays it, the exiting element is hit-test invisible, its
523
- * whole subtree stays painted with it, and no onTransitionEnd fires (the
524
- * component is already disposed). An attached element keeps its layout
525
- * slot until the exit finishes.
543
+ * Removal exit animation: when an element unmounts, every `exit`
544
+ * declared on it or under it animates its property to this value
545
+ * (honoring `delay`), and the unmounted element stays visible, subtree
546
+ * and all, until the last of them settles, then frees. Same value forms
547
+ * as `from`, per-property only. A move never plays it, the exiting
548
+ * subtree is hit-test invisible, and no onTransitionEnd fires (the
549
+ * components are already disposed). An exiting element leaves the layout
550
+ * flow at once: its siblings reflow as if it were gone, and it is painted
551
+ * at its last box, which follows its parent's moves but not the parent's
552
+ * own reflow. The {@link TransitionEndpoint} form gives the exit its own
553
+ * motion: an ease-out enter reads wrong played backwards, so pair it with
554
+ * `exit: { value, curve: "ease-in" }`.
526
555
  */
527
- exit?: number | string
556
+ exit?: number | string | TransitionEndpoint
528
557
  }
529
558
 
530
559
  /**
@@ -571,9 +600,12 @@ export type TransitionPropName =
571
600
  | "radius"
572
601
  | "color"
573
602
 
574
- /** Payload of onTransitionEnd: which animated property finished. */
603
+ /**
604
+ * Payload of onTransitionEnd: which animated property finished. `"layout"`
605
+ * is the layout slide settling on its box.
606
+ */
575
607
  export interface TransitionEndEvent {
576
- property: TransitionPropName
608
+ property: TransitionPropName | "layout"
577
609
  }
578
610
 
579
611
  export interface TransitionProps {
@@ -610,6 +642,20 @@ export interface TransitionProps {
610
642
  * lifecycle are unaffected. Adds on top of a per-entry `delay`.
611
643
  */
612
644
  stagger?: number
645
+ /**
646
+ * Layout slide: when a layout moves this element (a row removed
647
+ * above it, one inserted, a `<For>` reorder, a resize), it slides
648
+ * from where it was painted to its new box on this motion instead
649
+ * of jumping, and is hit-tested where it is drawn. A reflow
650
+ * mid-slide retargets (a spring keeps its velocity). Its first
651
+ * layout, a reparent and a `display: none` toggle snap. The slide is
652
+ * parent-relative: a parent that moves carries its children at
653
+ * once, so declare it on every level that should glide. `true`
654
+ * borrows the `all` entry's motion (an error without one); `all`
655
+ * alone never slides. Position only today: a size change snaps.
656
+ * Settles with onTransitionEnd property `"layout"`.
657
+ */
658
+ layout?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand | boolean
613
659
  } & {
614
660
  [P in TransitionPropName]?: Transition
615
661
  })
package/src/video.ts CHANGED
@@ -5,74 +5,134 @@
5
5
  // subtree effects - applies to video for free. A richer Video component
6
6
  // composes on top of this in a higher layer, not in core.
7
7
  //
8
+ // A plane player (`present: "plane"`, Android) has no texture: the platform
9
+ // composites the picture fullscreen beneath the UI on the video's own clock,
10
+ // and the UI simply draws over it. It exists from open until the owner is
11
+ // disposed, and it streams: the source may be a URL, read as it plays. On a
12
+ // platform without a plane the open fails (error() with kind "no-plane"),
13
+ // and an app that runs everywhere falls back to a texture player itself.
14
+ //
8
15
  // The imperative primitive lives in the `flux:video` module; import { open }
9
16
  // from "flux:video" for non-reactive use.
10
17
 
11
18
  import { createSignal, onCleanup } from "@solidjs/signals"
12
19
  import type { TextureId } from "flux:gpu"
13
- import { open } from "flux:video"
20
+ import { open, type VideoError, type VideoPlane, type VideoPlayer, type VideoSource } from "flux:video"
21
+
22
+ export type { VideoError, VideoErrorKind, VideoSource } from "flux:video"
14
23
 
15
24
  export type VideoOptions = {
16
- /** Start playback as soon as the file is open. */
25
+ /** Start playback as soon as the source is open. */
17
26
  autoplay?: boolean
27
+ /**
28
+ * Where the picture goes: a texture (default, displayed by the app) or the
29
+ * platform's video plane (fullscreen beneath the UI, Android only).
30
+ */
31
+ present?: "texture" | "plane"
32
+ /** Plane only: letterboxed (default) or cropped to fill the window. */
33
+ fit?: "contain" | "cover"
18
34
  }
19
35
 
20
36
  /** An opened video as reactive accessors plus playback controls. */
21
37
  export type VideoStream = {
22
- /** Texture id once the file is open, undefined while opening; render with <texture src={...}>. */
38
+ /** True once the source is open (and, for a plane, the plane exists). */
39
+ ready(): boolean
40
+ /** Texture id once open, undefined while opening and always for a plane player; render with <texture src={...}>. */
23
41
  texture(): TextureId | undefined
24
42
  /** Frame size, undefined while opening. */
25
43
  width(): number | undefined
26
44
  height(): number | undefined
27
- /** Duration in seconds, undefined while opening. */
45
+ /** Duration in seconds, undefined while opening or when the source has no known end. */
28
46
  duration(): number | undefined
29
- /** Set if opening failed (unreadable file, unsupported codec). */
30
- error(): Error | undefined
47
+ /**
48
+ * The failure: opening failed (unreadable or unreachable source,
49
+ * unsupported codec, no plane on this platform), or playback stopped on
50
+ * one later. Its `kind` says which; an app's plane-to-texture fallback
51
+ * keys on "no-plane" only.
52
+ */
53
+ error(): VideoError | undefined
31
54
  /** Start or resume playback (before open resolves, playback starts on resolve). */
32
55
  play(): void
33
56
  /** Pause playback; the current frame stays displayed. */
34
57
  pause(): void
58
+ /**
59
+ * Seek to a time in seconds. A no-op while opening, on a texture player,
60
+ * and on a source that cannot seek (see `seekable`). While paused the
61
+ * target frame is shown.
62
+ */
63
+ seek(seconds: number): void
64
+ /** Whether `seek` does anything (plain read, not a signal). */
65
+ seekable(): boolean
35
66
  /** Whether playback is running (plain read, not a signal). */
36
67
  playing(): boolean
37
68
  /** Presentation time of the displayed frame in seconds (plain read, not a signal). */
38
69
  currentTime(): number
39
70
  /** Whether the last frame has been displayed (plain read, not a signal). */
40
71
  finished(): boolean
72
+ /** Whether playback is held for the source to catch up (plain read, not a signal). */
73
+ buffering(): boolean
74
+ }
75
+
76
+ type AnyPlayer = VideoPlayer | VideoPlane
77
+
78
+ function toVideoError(e: unknown): VideoError {
79
+ if (e instanceof Error && "kind" in e) return e as VideoError
80
+ let error = new Error(String(e)) as VideoError
81
+ error.kind = "decode"
82
+ return error
41
83
  }
42
84
 
43
85
  /**
44
- * Opens a video file (MP4, H.264 + AAC) and exposes it as reactive signals:
45
- * read texture() in JSX and the frames appear once playback starts. Closes
86
+ * Opens a video (WebM, VP9 + Opus) from a path, an http(s) URL (plane
87
+ * players only) or a file(), and exposes it as reactive signals: read
88
+ * texture() in JSX and the frames appear once playback starts. Closes
46
89
  * automatically when the reactive owner is disposed (e.g. the component
47
- * unmounts). For imperative use, call open() from "flux:video" directly.
90
+ * unmounts), abandoning an open still in flight. For imperative use, call
91
+ * open() from "flux:video" directly.
48
92
  */
49
- export function createVideo(path: string, options: VideoOptions = {}): VideoStream {
93
+ export function createVideo(source: VideoSource, options: VideoOptions = {}): VideoStream {
94
+ let [ready, setReady] = createSignal(false)
50
95
  let [texture, setTexture] = createSignal<TextureId | undefined>(undefined)
51
96
  let [width, setWidth] = createSignal<number | undefined>(undefined)
52
97
  let [height, setHeight] = createSignal<number | undefined>(undefined)
53
98
  let [duration, setDuration] = createSignal<number | undefined>(undefined)
54
- let [error, setError] = createSignal<Error | undefined>(undefined)
55
- let player: Awaited<ReturnType<typeof open>> | undefined
99
+ let [error, setError] = createSignal<VideoError | undefined>(undefined)
100
+ let player: AnyPlayer | undefined
56
101
  let disposed = false
57
102
  let wantPlay = options.autoplay ?? false
103
+ // Disposal aborts a pending open, so a player never appears after the
104
+ // owner is gone.
105
+ let abort = new AbortController()
58
106
 
59
- open(path)
107
+ let opened: Promise<AnyPlayer> =
108
+ options.present === "plane"
109
+ ? open(source, { present: "plane", fit: options.fit, signal: abort.signal })
110
+ : open(source, { signal: abort.signal })
111
+ opened
60
112
  .then((video) => {
61
113
  if (disposed) {
62
114
  video.close()
63
115
  return
64
116
  }
65
117
  player = video
66
- setTexture(video.texture)
118
+ if ("texture" in video) setTexture(video.texture)
67
119
  setWidth(video.width)
68
120
  setHeight(video.height)
69
121
  setDuration(video.duration)
122
+ setReady(true)
70
123
  if (wantPlay) video.play()
124
+ // A failure that stops playback later lands in the same signal.
125
+ video.failed.then((failure) => {
126
+ if (failure && !disposed) setError(failure)
127
+ })
128
+ })
129
+ .catch((e) => {
130
+ if (!disposed) setError(toVideoError(e))
71
131
  })
72
- .catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
73
132
 
74
133
  onCleanup(() => {
75
134
  disposed = true
135
+ abort.abort()
76
136
  if (player) {
77
137
  player.close()
78
138
  player = undefined
@@ -80,6 +140,7 @@ export function createVideo(path: string, options: VideoOptions = {}): VideoStre
80
140
  })
81
141
 
82
142
  return {
143
+ ready,
83
144
  texture,
84
145
  width,
85
146
  height,
@@ -93,8 +154,13 @@ export function createVideo(path: string, options: VideoOptions = {}): VideoStre
93
154
  wantPlay = false
94
155
  player?.pause()
95
156
  },
157
+ seek(seconds: number) {
158
+ player?.seek(seconds)
159
+ },
160
+ seekable: () => player?.seekable ?? false,
96
161
  playing: () => player?.playing() ?? false,
97
162
  currentTime: () => player?.currentTime() ?? 0,
98
163
  finished: () => player?.finished() ?? false,
164
+ buffering: () => player?.buffering() ?? false,
99
165
  }
100
166
  }
package/src/window.ts CHANGED
@@ -2,18 +2,152 @@ import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } fro
2
2
  import { requestFrame, setPointerLock } from "flux:rendertree"
3
3
  import { renderFrame } from "srt:render"
4
4
  import { on, once } from "srt:events"
5
- import { exit } from "srt:app"
5
+ import { exit as nativeExit, background as nativeBackground } from "srt:app"
6
+ import { platform } from "flux:process"
6
7
  import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
7
8
  import { scanForOrphans, getNodePath } from "./renderer"
8
9
 
10
+ // ------ Lifecycle hooks ----------------
11
+
12
+ // A suspend or quit handler; the runtime waits for a returned promise.
13
+ export type LifecycleHandler = () => void | Promise<void>
14
+
15
+ // How long exit() waits for the quit handlers before leaving anyway. The
16
+ // platform-initiated ends (window close, Android onDestroy) are bounded by the
17
+ // runtime's own deadline instead; this one only keeps a hung handler from
18
+ // pinning an app that asked to leave.
19
+ const EXIT_HOOK_DEADLINE_MS = 2000
20
+
21
+ let suspendHandlers: LifecycleHandler[] = []
22
+ let quitHandlers: LifecycleHandler[] = []
23
+ // The suspend dispatch still in flight, if any: a quit that follows a
24
+ // suspend (a finishing Android activity runs onPause, then onDestroy) waits
25
+ // for it before its own handlers, so the persist that suspend started lands
26
+ // before the process ends.
27
+ let pendingSuspend: Promise<void> | null = null
28
+
29
+ function registerLifecycle(list: LifecycleHandler[], fn: LifecycleHandler) {
30
+ list.push(fn)
31
+ let cleanup = () => {
32
+ let i = list.lastIndexOf(fn)
33
+ if (i >= 0) list.splice(i, 1)
34
+ }
35
+ if (getOwner()) onCleanup(cleanup)
36
+ return cleanup
37
+ }
38
+
39
+ // Runs every handler (a throw counts as a rejected promise) and settles once
40
+ // all of them have, logging the failures: one handler's error must not cut
41
+ // another's write short.
42
+ function settleHandlers(name: string, list: LifecycleHandler[]): Promise<void> {
43
+ let results = [...list].map((fn) => {
44
+ try {
45
+ return Promise.resolve(fn())
46
+ } catch (err) {
47
+ return Promise.reject(err)
48
+ }
49
+ })
50
+ return Promise.allSettled(results).then((settled) => {
51
+ for (let r of settled) {
52
+ if (r.status === "rejected") console.error(`Error in ${name} handler:`, r.reason)
53
+ }
54
+ })
55
+ }
56
+
57
+ function dispatchSuspend(): Promise<void> {
58
+ let p = settleHandlers("onSuspend", suspendHandlers).finally(() => {
59
+ if (pendingSuspend === p) pendingSuspend = null
60
+ })
61
+ pendingSuspend = p
62
+ return p
63
+ }
64
+
65
+ function dispatchQuit(): Promise<void> {
66
+ let inflight = pendingSuspend ?? Promise.resolve()
67
+ return inflight.then(() => settleHandlers("onQuit", quitHandlers))
68
+ }
69
+
70
+ // The runtime asks through the event bus and waits for `done()`: the
71
+ // platform is held open (or its close budget spent) until then, so this must
72
+ // answer exactly once, whatever the handlers do. Subscribed at module load:
73
+ // core is always present, and a hook nobody listens to would leave the
74
+ // runtime waiting out its deadline on every close.
75
+ on("suspend", (e: { done: () => void }) => {
76
+ dispatchSuspend().then(e.done, e.done)
77
+ })
78
+ on("quit", (e: { done: () => void }) => {
79
+ dispatchQuit().then(e.done, e.done)
80
+ })
81
+
82
+ /**
83
+ * Calls `fn` when the app is being suspended and may be killed without
84
+ * further notice: the user switches away on Android or iOS (the platform's
85
+ * "will enter background" moment). Never fires on desktop, where a minimized
86
+ * app is not killed. This is the moment to persist session state - a file, a
87
+ * database row, an HTTP request - so a later launch can pick the session up;
88
+ * the runtime holds the platform open and waits for a returned promise, up to
89
+ * its deadline (about ten seconds, platform-dependent), then lets the
90
+ * platform proceed. Nothing decides for you whether to restore afterwards;
91
+ * that is the app's call.
92
+ *
93
+ * Returns a cleanup function; also auto-cleans within an owned scope.
94
+ */
95
+ export function onSuspend(fn: LifecycleHandler) {
96
+ return registerLifecycle(suspendHandlers, fn)
97
+ }
98
+
99
+ /**
100
+ * Calls `fn` when this app instance is ending: `exit()` (including the
101
+ * default action of an unprevented `back` on desktop), the desktop window
102
+ * closing, the Android activity finishing. Not when the app goes to the
103
+ * background (`background()`, or back at the root on Android). Not a last chance to save - on mobile the
104
+ * suspend hook has already run, and a user who quits usually does not want
105
+ * a session kept - but the place for work that needs a real close, and the
106
+ * runtime waits for a returned promise up to its deadline (well under a
107
+ * second on Android, where the platform's close budget is fixed; a couple of
108
+ * seconds on desktop). A force quit or a crash gives no signal on any
109
+ * platform.
110
+ *
111
+ * Returns a cleanup function; also auto-cleans within an owned scope.
112
+ */
113
+ export function onQuit(fn: LifecycleHandler) {
114
+ return registerLifecycle(quitHandlers, fn)
115
+ }
116
+
117
+ /**
118
+ * Ends the current app: back to the player in a dev client, quitting when
119
+ * standalone or at the player itself (on Android the activity finishes, so
120
+ * the next launch starts fresh). The default action of an unprevented `back`
121
+ * event on desktop; call it directly to exit programmatically, e.g. after
122
+ * intercepting back for an unsaved-changes dialog. Runs the `onQuit`
123
+ * handlers first and leaves once their promises settle (or the deadline
124
+ * passes); returns immediately.
125
+ */
126
+ export function exit() {
127
+ let deadline = new Promise<void>((resolve) => setTimeout(resolve, EXIT_HOOK_DEADLINE_MS))
128
+ Promise.race([dispatchQuit(), deadline]).then(nativeExit, nativeExit)
129
+ }
130
+
9
131
  /**
10
- * Leaves the current app, unconditionally: back to the player in a dev
11
- * client, quitting when standalone or at the player itself (on Android the
12
- * client backgrounds instead of dying). The default action of an unprevented
13
- * `back` event; call it directly to exit programmatically, e.g. after
14
- * intercepting back for an unsaved-changes dialog.
132
+ * Leaves the current app without ending it: back to the player in a dev
133
+ * client; otherwise the OS takes the app to the background - on Android the
134
+ * task moves behind the others with the process kept, on desktop the window
135
+ * minimizes. The default action of an unprevented `back` event on Android,
136
+ * where that is what the system itself does at the root of an app. No
137
+ * `onQuit` runs (the app is not ending); `onSuspend` fires on Android on the
138
+ * way, as for any background.
15
139
  */
16
- export { exit }
140
+ export function background() {
141
+ nativeBackground()
142
+ }
143
+
144
+ // The default action of an unprevented back event: what the platform itself
145
+ // does at the root of an app. Android backgrounds (since Android 12);
146
+ // everywhere else back at the root ends the app.
147
+ function backDefault() {
148
+ if (platform === "android") background()
149
+ else exit()
150
+ }
17
151
 
18
152
  // ------ Pointer routing -----------------
19
153
 
@@ -301,8 +435,10 @@ let backHandlers: ((e: BackEvent) => void)[] = []
301
435
  * desktop dev chord). Call `e.preventDefault()` when back means in-app
302
436
  * navigation right now (close a modal, previous screen); unprevented, the
303
437
  * event passes to the handler registered before this one, and if none of them
304
- * prevents it either, to the default action: exit(). Apps without a handler
305
- * exit on back everywhere, which is the correct zero-effort default.
438
+ * prevents it either, to the default action: what the platform does at the
439
+ * root of an app - `background()` on Android, `exit()` elsewhere. Apps
440
+ * without a handler get the platform's behavior, which is the correct
441
+ * zero-effort default.
306
442
  *
307
443
  * Handlers form a stack: the most recently registered runs first and the first
308
444
  * to prevent ends the dispatch, so each screen or overlay owns one step of the
@@ -516,7 +652,7 @@ export function attachWindow(nodeId: number) {
516
652
  // Top of the stack down, stopping as soon as one takes the event.
517
653
  let stack = [...backHandlers]
518
654
  for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
519
- if (!prevented) exit()
655
+ if (!prevented) backDefault()
520
656
  })
521
657
 
522
658
  unsubTextInput = on("textInput", (e: any) => {