@solidrt/core 0.0.54 → 0.0.56

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.
@@ -0,0 +1,45 @@
1
+ // Enter, exit and stagger are declared on elements; the runtime plays them
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.
10
+ //
11
+ // An enter plays once per mount. To replay it, remount the subtree: a keyed
12
+ // <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
14
+ // (staggered exits), even taps mount a fresh one (staggered enters).
15
+ import { render, createSignal, Show } from "@solidrt/core"
16
+ import type { Transition } from "@solidrt/core"
17
+
18
+ const ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries"]
19
+ // Extra delay per row before its enter or exit starts.
20
+ const STAGGER_MS = 70
21
+ // Slide in from the left, out to the right; fade both ways.
22
+ 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
24
+
25
+ function App() {
26
+ let [epoch, setEpoch] = createSignal(1)
27
+
28
+ return (
29
+ <window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)} transition={{ stagger: STAGGER_MS }}>
30
+ <text color="#8899aa" fontSize={14}>Tap to replay</text>
31
+ <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
+ }
40
+ </Show>
41
+ </window>
42
+ )
43
+ }
44
+
45
+ render(() => <App />)
@@ -0,0 +1,90 @@
1
+ // App-side line breaking. prepareText shapes a paragraph's words once (through
2
+ // the shared word cache) into units carrying advance, ink width, ascent and
3
+ // descent; layoutNextLine then breaks one line out of them per call, at
4
+ // whatever width it is handed, and returns the cursor the next call continues
5
+ // from. The engine never sees a "shape" or a "column": here the paragraph is
6
+ // poured band by band into a circle (each band's width is the chord at that
7
+ // height, its line centered on it) and, when the circle is full, the same
8
+ // cursor continues in a column beside it. Every line is a d-text of exactly its
9
+ // own text, so the whole break redoes on every resize for arithmetic plus
10
+ // word-cache hits; nothing is shaped twice.
11
+ import { For, createMemo, layoutNextLine, prepareText, render, windowSize } from "@solidrt/core"
12
+
13
+ const FONT = { fontSize: 16, lineHeight: 1.4 }
14
+ const PAD = 32
15
+ // Gap between the circle and the column that continues its text.
16
+ const GAP = 32
17
+ // A chord shorter than this holds no word: skip the band at the circle's poles.
18
+ const MIN_BAND = 90
19
+ // Breathing room between the ring and the line ends.
20
+ const INSET = 10
21
+
22
+ const TEXT =
23
+ "A word is shaped once and keeps its width. From there a line is a loop: add words while the pen stays " +
24
+ "inside the width you were given, stop, and hand the cursor to whoever wants the next line. Give each " +
25
+ "call the chord of a circle and the paragraph fills the circle; give the next call a column and it " +
26
+ "continues there, because a column is only the same loop in a new box. The engine shaped the words " +
27
+ "before the first frame and never hears about circles or columns at all. Resize the window: the circle " +
28
+ "grows, every band changes width, and the text re-breaks into it while the words stay as they were. " +
29
+ "That is the whole trick, and it is the oldest one in typesetting: a compositor with a case of type " +
30
+ "never reshaped a letter when the column narrowed. The letters kept their width and only the breaks " +
31
+ "moved. Text engines lost that when a paragraph became one opaque object laid out at one width, and " +
32
+ "every layout question turned into a re-shape. Prepared text gives the idea back, and this page is that " +
33
+ "loop and nothing else."
34
+
35
+ type Placed = { x: number, y: number, w: number, text: string }
36
+
37
+ function App() {
38
+ let prepared = prepareText(TEXT, FONT)
39
+ let lines = createMemo<Placed[]>(() => {
40
+ let { width, height } = windowSize()
41
+ let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
42
+ let cx = PAD + r
43
+ let cy = height / 2
44
+ let units = prepared.units
45
+ // Single style: every line is as tall as its first unit.
46
+ let lineH = units[0]!.ascent + units[0]!.descent
47
+ let out: Placed[] = []
48
+ let cursor = 0
49
+ // The circle, top to bottom: each band's width is the chord at its middle.
50
+ for (let y = cy - r; y + lineH <= cy + r && cursor < units.length; y += lineH) {
51
+ let dy = y + lineH / 2 - cy
52
+ let half = Math.sqrt(Math.max(0, (r - INSET) * (r - INSET) - dy * dy))
53
+ if (2 * half < MIN_BAND) continue
54
+ let line = layoutNextLine(prepared, cursor, 2 * half)
55
+ if (!line) break
56
+ out.push({ x: cx - line.width / 2, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
57
+ cursor = line.cursor
58
+ }
59
+ // The column beside it continues from the same cursor.
60
+ let colX = cx + r + GAP
61
+ let colW = width - colX - PAD
62
+ for (let y = PAD; y + lineH <= height - PAD && cursor < units.length; y += lineH) {
63
+ let line = layoutNextLine(prepared, cursor, colW)
64
+ if (!line) break
65
+ out.push({ x: colX, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
66
+ cursor = line.cursor
67
+ }
68
+ return out
69
+ })
70
+ let circle = createMemo(() => {
71
+ let { width, height } = windowSize()
72
+ let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
73
+ return { x: PAD, y: height / 2 - r, d: 2 * r }
74
+ })
75
+ return (
76
+ <window>
77
+ <d-rect color="#f4efe6" />
78
+ <d-oval x={circle().x} y={circle().y} w={circle().d} h={circle().d} drawStyle="stroke" strokeWidth={1} color="#d8b26a" />
79
+ <For each={lines()} keyed={false}>
80
+ {line => (
81
+ <d-text x={line().x} y={line().y} w={line().w + 1} {...FONT} color="#2b2620">
82
+ {line().text}
83
+ </d-text>
84
+ )}
85
+ </For>
86
+ </window>
87
+ )
88
+ }
89
+
90
+ render(() => <App />)
@@ -0,0 +1,73 @@
1
+ // Per-glyph positions. prepareText with `carets: true` reports, per wrap unit,
2
+ // the x of every grapheme boundary from the shaping the engine draws - kerning
3
+ // included - so one d-text per glyph placed from them sits exactly on the whole
4
+ // headline. Measuring characters one at a time with measureText cannot know a
5
+ // glyph's neighbors, so pairs like AV and TA come out loose and the row drifts
6
+ // right. Three rows of the same headline: drawn whole, per glyph from carets,
7
+ // per glyph from measureText. Per-glyph d-texts are what a per-character effect
8
+ // (a wave, a color cycle, a stagger) animates.
9
+ import { measureText, prepareText, render } from "@solidrt/core"
10
+
11
+ const FONT = { fontSize: 48, fontWeight: 800 } as const
12
+ const HEADLINE = "AVATAR Two Ya"
13
+ const LEFT = 32
14
+ const TOP = 40
15
+ const ROW = 96
16
+ // A single glyph never wraps at this width; it only bounds the d-text.
17
+ const GLYPH_W = FONT.fontSize * 2
18
+ const LABEL = { fontSize: 13, color: "#8a8378" }
19
+
20
+ type Glyph = { x: number, text: string }
21
+
22
+ // Kerned: each unit's pen position plus its caret offsets.
23
+ function fromCarets(): Glyph[] {
24
+ let prepared = prepareText(HEADLINE, { ...FONT, carets: true })
25
+ let out: Glyph[] = []
26
+ let pen = 0
27
+ for (let unit of prepared.units) {
28
+ let carets = unit.carets!
29
+ for (let i = 0; i + 1 < carets.length; i++) {
30
+ out.push({ x: pen + carets[i]!.x, text: prepared.text.slice(carets[i]!.offset, carets[i + 1]!.offset) })
31
+ }
32
+ pen += unit.advance
33
+ }
34
+ return out
35
+ }
36
+
37
+ // Unkerned: each character measured alone, advanced by its own width.
38
+ function fromMeasure(): Glyph[] {
39
+ let out: Glyph[] = []
40
+ let x = 0
41
+ for (let ch of HEADLINE) {
42
+ out.push({ x, text: ch })
43
+ x += measureText(ch, FONT).width
44
+ }
45
+ return out
46
+ }
47
+
48
+ function Row(props: { y: number, label: string, glyphs: Glyph[], color: string }) {
49
+ return (
50
+ <d-view x={LEFT} y={props.y}>
51
+ <d-text y={-20} {...LABEL}>{props.label}</d-text>
52
+ {props.glyphs.map(g => (
53
+ <d-text x={g.x} w={GLYPH_W} {...FONT} color={props.color}>{g.text}</d-text>
54
+ ))}
55
+ </d-view>
56
+ )
57
+ }
58
+
59
+ function App() {
60
+ return (
61
+ <window>
62
+ <d-rect color="#f4efe6" />
63
+ <d-view x={LEFT} y={TOP}>
64
+ <d-text y={-20} {...LABEL}>whole headline</d-text>
65
+ <d-text {...FONT} color="#2b2620">{HEADLINE}</d-text>
66
+ </d-view>
67
+ <Row y={TOP + ROW} label="per glyph from carets: true (kerned)" glyphs={fromCarets()} color="#2b2620" />
68
+ <Row y={TOP + 2 * ROW} label="per glyph from measureText per character (drifts)" glyphs={fromMeasure()} color="#b3452b" />
69
+ </window>
70
+ )
71
+ }
72
+
73
+ render(() => <App />)
@@ -30,6 +30,11 @@
30
30
  // geometry - diagrams, slides, dashboards, game boards, emulator screens - fit
31
31
  // one design space instead.
32
32
  //
33
+ // Design space changes the units, not the element choice: laid-out views and
34
+ // text stay the default under a designSize (fact 3). The d-* scene below is
35
+ // free-form drawn geometry; panels, cards and labels would still be built
36
+ // from laid-out elements, as the flex row at the bottom shows.
37
+ //
33
38
  // Resize the window and watch: the scene keeps its aspect ratio, everything
34
39
  // including text scales together, and the readout reports the same design
35
40
  // coordinates for the same spot on the scene at any window size.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -17,6 +17,7 @@
17
17
  "./microphone": "./src/microphone.ts",
18
18
  "./speech-recognition": "./src/speech-recognition.ts",
19
19
  "./text-input": "./src/text-input.ts",
20
+ "./video": "./src/video.ts",
20
21
  "./jsx-runtime": "./jsx-runtime.d.ts",
21
22
  "./jsx-runtime-dev": "./jsx-runtime.d.ts"
22
23
  },
@@ -29,11 +30,11 @@
29
30
  "AGENTS.md"
30
31
  ],
31
32
  "devDependencies": {
32
- "@solidrt/flux-types": "0.0.54"
33
+ "@solidrt/flux-types": "0.0.56"
33
34
  },
34
35
  "peerDependencies": {
35
- "@solidjs/signals": "2.0.0-rc.3",
36
- "@solidjs/universal": "2.0.0-rc.3",
37
- "solid-js": "2.0.0-rc.3"
36
+ "@solidjs/signals": "2.0.0-rc.6",
37
+ "@solidjs/universal": "2.0.0-rc.6",
38
+ "solid-js": "2.0.0-rc.6"
38
39
  }
39
40
  }
package/src/color.ts CHANGED
@@ -26,6 +26,23 @@ export function mixColors(a: string, b: string, t: number): string {
26
26
  return tree.mixColors(a, b, t)
27
27
  }
28
28
 
29
+ // Alpha byte range of the packed 0xRRGGBBAA color.
30
+ const ALPHA_MAX = 255
31
+
32
+ /**
33
+ * The same CSS color at opacity `alpha` (0 = transparent, 1 = opaque),
34
+ * replacing whatever alpha the input carried. Returns `#rrggbbaa`. Accepts
35
+ * any color `parseColor` does, so a named color or an `rgb()` is fine. For a
36
+ * transparent variant of a theme color prefer this over an `rgba()` literal
37
+ * rebuilt by hand; for a tone that must not depend on what is drawn beneath,
38
+ * `mixColors` against the backdrop.
39
+ */
40
+ export function withAlpha(color: string, alpha: number): string {
41
+ let rgb = parseColor(color) >>> 8
42
+ let a = Math.round(Math.min(1, Math.max(0, alpha)) * ALPHA_MAX)
43
+ return "#" + rgb.toString(16).padStart(6, "0") + a.toString(16).padStart(2, "0")
44
+ }
45
+
29
46
  /**
30
47
  * Perceived brightness of a CSS color, 0 (black) to 1 (white), YIQ-weighted.
31
48
  * Compare a text color against its backdrop to decide rendering polarity,
package/src/core.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as tree from "flux:rendertree"
2
2
  import type { TextureId } from "flux:gpu"
3
- import { createSignal, onCleanup } from "@solidjs/signals"
3
+ import { createSignal, getOwner, onCleanup } from "@solidjs/signals"
4
4
  import { on } from "srt:events"
5
5
 
6
6
  let handlers = new Map<number, Map<string, Function>>()
@@ -93,7 +93,7 @@ export function onPointerMove(fn: (e: GlobalPointerEvent) => void): () => void {
93
93
  if (interestRoot != null) syncInterest(interestRoot)
94
94
  }
95
95
  }
96
- onCleanup(cleanup)
96
+ if (getOwner()) onCleanup(cleanup)
97
97
  return cleanup
98
98
  }
99
99
 
@@ -326,6 +326,19 @@ export function getBoundingBoxViewport(node: { id: number }): BoundingBox | null
326
326
  return tree.getBoundingBoxViewport(node.id)
327
327
  }
328
328
 
329
+ /**
330
+ * The node's laid-out box in its parent's frame: layout output before any
331
+ * transform composes (DOM offsetLeft/offsetTop/offsetWidth/offsetHeight
332
+ * semantics). The untransformed companion to getBoundingBox: pointer events
333
+ * report localX/localY in this box's units, whatever designSize fits or
334
+ * transforms sit on the ancestor chain. A snapshot read like getBoundingBox
335
+ * (call it inside `onLayout` or an event handler); `null` for detached nodes
336
+ * and before the first layout.
337
+ */
338
+ export function getLayoutBox(node: { id: number }): BoundingBox | null {
339
+ return tree.getLayoutBox(node.id)
340
+ }
341
+
329
342
  /**
330
343
  * The texture id of a snapshot boundary's retained rasterization
331
344
  * (`repaintBoundary="snapshot"`): the subtree's pixels as a live texture any
@@ -352,11 +365,19 @@ export function measureText(text: string, options?: tree.MeasureTextOptions): {
352
365
  }
353
366
 
354
367
  /**
355
- * Segments `text` into wrap units (words with their trailing whitespace) and
356
- * shapes each in the given font, once, for laying lines out in app code with
357
- * layoutNextLine or arithmetic of your own over `units`. For the non-standard
358
- * case (text into a shape, around a moving obstacle, handed between columns,
359
- * fitted by size); regular text of any length is a <text>.
368
+ * App-side line breaking. Segments `text` into wrap units (words with their
369
+ * trailing whitespace) and shapes each in the given font, once, through the
370
+ * shared word cache; laying lines out is then arithmetic over `units`, with
371
+ * layoutNextLine or a loop of your own. This is how text goes into a shape,
372
+ * parts around an obstacle that is not in the flow, pours across columns
373
+ * (layoutNextLine's `cursor` continues a paragraph in the next box), fits a
374
+ * box by trying sizes, and gets per-glyph positions (`carets: true`; the
375
+ * only kerned source, see TextUnit.carets). Draw each line as a d-text of
376
+ * exactly its own text. Re-breaking every frame is cheap - the words are
377
+ * shaped already and the lines' d-texts hit the same cache - so a shape
378
+ * that moves or breathes can re-flow at frame rate. A paragraph that just
379
+ * wraps in a box is a <text>, which also does floats, balancing and
380
+ * ellipsis.
360
381
  */
361
382
  export function prepareText(text: string, options?: tree.MeasureTextOptions): tree.PreparedText {
362
383
  return tree.prepareText(text, options)
@@ -1,4 +1,4 @@
1
- import { createRoot, createSignal } from "@solidjs/signals"
1
+ import { createSignal, runWithOwner } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
3
  import { onPointerMove } from "./core"
4
4
  import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
@@ -12,13 +12,17 @@ import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } fro
12
12
  // capability derived from them can appear mid-session (e.g. the first mouse
13
13
  // move) but never flickers away.
14
14
  //
15
- // `ownedWrite: true` on the signals below: each is lazily created the first
16
- // time its ensure* function runs, which can happen inside a tracked scope
17
- // (e.g. a memo's first read of env.inputDevices). Sticky events replay their
18
- // cached value synchronously on subscribe (srt:events' on()), so that same
19
- // call can immediately write the signal it just created. That's a legitimate
20
- // internal-state write, not a stray write escaping a computation, so it opts
21
- // out of the write-in-owned-scope guard.
15
+ // Every fact below is lazily created the first time its ensure* function
16
+ // runs, which is usually inside a component or a computation (a memo's first
17
+ // read of env.inputDevices, a HUD effect's first read of pointerLocked()).
18
+ // Created there, the fact would belong to that reader: sticky events replay
19
+ // their cached value synchronously on subscribe (srt:events' on()), so the
20
+ // on() call writes the signal it just created inside the reader's owned
21
+ // scope - a render-time write, which the guard rejects - and a root or a
22
+ // subscription created there is disposed with the reader. The facts are
23
+ // app-lifetime, so each ensure* creates under no owner (runWithOwner(null)):
24
+ // the replay lands unowned, nothing is filed under the reader, and no
25
+ // `ownedWrite` opt-out is needed.
22
26
 
23
27
  /** Connected input device classes, as reported by the runtime. */
24
28
  export interface InputDevices {
@@ -39,58 +43,68 @@ let devicesAccessor: (() => InputDevices | undefined) | undefined
39
43
 
40
44
  function ensureDevicesState() {
41
45
  if (devicesAccessor) return
42
- let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined, { ownedWrite: true })
43
- // Sticky: the current state replays on subscribe, so the first read already
44
- // sees it on runtimes that report devices.
45
- on("inputDevices", (d: InputDevices) => {
46
- setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
46
+ runWithOwner(null, () => {
47
+ let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined)
48
+ // Sticky: the current state replays on subscribe, so the first read already
49
+ // sees it on runtimes that report devices.
50
+ on("inputDevices", (d: InputDevices) => {
51
+ setDevices({ keyboard: !!d.keyboard, mouse: !!d.mouse, touch: !!d.touch, screenKeyboard: !!d.screenKeyboard })
52
+ })
53
+ devicesAccessor = devices
47
54
  })
48
- devicesAccessor = devices
49
55
  }
50
56
 
51
57
  let systemThemeAccessor: (() => SystemTheme) | undefined
52
58
 
53
59
  function ensureSystemThemeState() {
54
60
  if (systemThemeAccessor) return
55
- let [theme, setTheme] = createSignal<SystemTheme>("unknown", { ownedWrite: true })
56
- on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
57
- systemThemeAccessor = theme
61
+ runWithOwner(null, () => {
62
+ let [theme, setTheme] = createSignal<SystemTheme>("unknown")
63
+ on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
64
+ systemThemeAccessor = theme
65
+ })
58
66
  }
59
67
 
60
68
  let visibilityAccessor: (() => Visibility) | undefined
61
69
 
62
70
  function ensureVisibilityState() {
63
71
  if (visibilityAccessor) return
64
- let [visibility, setVisibility] = createSignal<Visibility>("visible", { ownedWrite: true })
65
- // Sticky. The runtime may report the same state through several platform
66
- // paths; the signal's equality check turns repeats into no-ops for
67
- // reactive consumers.
68
- on("visibility", (e: { state?: Visibility }) => setVisibility(e.state === "hidden" ? "hidden" : "visible"))
69
- visibilityAccessor = visibility
72
+ runWithOwner(null, () => {
73
+ let [visibility, setVisibility] = createSignal<Visibility>("visible")
74
+ // Sticky. The runtime may report the same state through several platform
75
+ // paths; the signal's equality check turns repeats into no-ops for
76
+ // reactive consumers.
77
+ on("visibility", (e: { state?: Visibility }) => setVisibility(e.state === "hidden" ? "hidden" : "visible"))
78
+ visibilityAccessor = visibility
79
+ })
70
80
  }
71
81
 
72
82
  let orientationAccessor: (() => Orientation) | undefined
73
83
 
74
84
  function ensureOrientationState() {
75
85
  if (orientationAccessor) return
76
- let [orientation, setOrientation] = createSignal<Orientation>("unknown", { ownedWrite: true })
77
- on("displayOrientation", (e: { orientation?: Orientation }) => {
78
- setOrientation(e.orientation ?? "unknown")
86
+ runWithOwner(null, () => {
87
+ let [orientation, setOrientation] = createSignal<Orientation>("unknown")
88
+ on("displayOrientation", (e: { orientation?: Orientation }) => {
89
+ setOrientation(e.orientation ?? "unknown")
90
+ })
91
+ orientationAccessor = orientation
79
92
  })
80
- orientationAccessor = orientation
81
93
  }
82
94
 
83
95
  let textScaleAccessor: (() => number) | undefined
84
96
 
85
97
  function ensureTextScaleState() {
86
98
  if (textScaleAccessor) return
87
- let [scale, setScale] = createSignal(1, { ownedWrite: true })
88
- // Sticky, like systemTheme. Guard nonsense values: a runtime bug reporting
89
- // 0 or a negative would otherwise collapse all text.
90
- on("textScale", (e: { scale?: number }) => {
91
- setScale(typeof e.scale === "number" && e.scale > 0 ? e.scale : 1)
99
+ runWithOwner(null, () => {
100
+ let [scale, setScale] = createSignal(1)
101
+ // Sticky, like systemTheme. Guard nonsense values: a runtime bug reporting
102
+ // 0 or a negative would otherwise collapse all text.
103
+ on("textScale", (e: { scale?: number }) => {
104
+ setScale(typeof e.scale === "number" && e.scale > 0 ? e.scale : 1)
105
+ })
106
+ textScaleAccessor = scale
92
107
  })
93
- textScaleAccessor = scale
94
108
  }
95
109
 
96
110
  let mouseSeenAccessor: (() => boolean) | undefined
@@ -98,50 +112,54 @@ let touchSeenAccessor: (() => boolean) | undefined
98
112
 
99
113
  function ensurePointerState() {
100
114
  if (mouseSeenAccessor) return
101
- let [mouse, setMouse] = createSignal(false)
102
- let [touch, setTouch] = createSignal(false)
103
- let sawMouse = false
104
- let sawTouch = false
105
- let unsubs: (() => void)[] = []
106
- let unsubMove: () => void = null!
107
- let note = (e: { pointerType?: string }) => {
108
- if (e.pointerType === "mouse" && !sawMouse) {
109
- sawMouse = true
110
- setMouse(true)
111
- // Moves have nothing left to teach: touch is learned from downs (a
112
- // touch never moves without one), so drop the move subscription and
113
- // with it the ambient interest bit that forces move deliveries.
114
- unsubMove()
115
- } else if (e.pointerType === "touch" && !sawTouch) {
116
- sawTouch = true
117
- setTouch(true)
115
+ runWithOwner(null, () => {
116
+ let [mouse, setMouse] = createSignal(false)
117
+ let [touch, setTouch] = createSignal(false)
118
+ let sawMouse = false
119
+ let sawTouch = false
120
+ let unsubs: (() => void)[] = []
121
+ let unsubMove: () => void = null!
122
+ let note = (e: { pointerType?: string }) => {
123
+ if (e.pointerType === "mouse" && !sawMouse) {
124
+ sawMouse = true
125
+ setMouse(true)
126
+ // Moves have nothing left to teach: touch is learned from downs (a
127
+ // touch never moves without one), so drop the move subscription and
128
+ // with it the ambient interest bit that forces move deliveries.
129
+ unsubMove()
130
+ } else if (e.pointerType === "touch" && !sawTouch) {
131
+ sawTouch = true
132
+ setTouch(true)
133
+ }
134
+ // Both types observed: nothing left to learn, stop listening.
135
+ if (sawMouse && sawTouch) for (let u of unsubs) u()
118
136
  }
119
- // Both types observed: nothing left to learn, stop listening.
120
- if (sawMouse && sawTouch) for (let u of unsubs) u()
121
- }
122
- // Downs always deliver, so the raw bus tap suffices; moves are gated when
123
- // nobody listens, so they go through onPointerMove, whose subscription
124
- // keeps them flowing. The probe is app-lifetime: createRoot detaches its
125
- // scope cleanup from whatever computation first read the accessor.
126
- unsubMove = createRoot(() => onPointerMove(note))
127
- unsubs.push(unsubMove, on("pointerDown", note))
128
- mouseSeenAccessor = mouse
129
- touchSeenAccessor = touch
137
+ // Downs always deliver, so the raw bus tap suffices; moves are gated when
138
+ // nobody listens, so they go through onPointerMove, whose subscription
139
+ // keeps them flowing. Unowned here, so it registers no cleanup: the
140
+ // returned unsubscribe is the probe's only handle, as intended.
141
+ unsubMove = onPointerMove(note)
142
+ unsubs.push(unsubMove, on("pointerDown", note))
143
+ mouseSeenAccessor = mouse
144
+ touchSeenAccessor = touch
145
+ })
130
146
  }
131
147
 
132
148
  let keyboardSeenAccessor: (() => boolean) | undefined
133
149
 
134
150
  function ensureKeyboardState() {
135
151
  if (keyboardSeenAccessor) return
136
- let [keyboard, setKeyboard] = createSignal(false)
137
- // Soft keyboards also deliver some keydowns (Backspace, Return), so this can
138
- // read true on a touch-only device once the user types in a field. Only a
139
- // fallback: capabilities prefer runtime-reported device presence.
140
- let unsub = on("keydown", () => {
141
- setKeyboard(true)
142
- unsub()
152
+ runWithOwner(null, () => {
153
+ let [keyboard, setKeyboard] = createSignal(false)
154
+ // Soft keyboards also deliver some keydowns (Backspace, Return), so this can
155
+ // read true on a touch-only device once the user types in a field. Only a
156
+ // fallback: capabilities prefer runtime-reported device presence.
157
+ let unsub = on("keydown", () => {
158
+ setKeyboard(true)
159
+ unsub()
160
+ })
161
+ keyboardSeenAccessor = keyboard
143
162
  })
144
- keyboardSeenAccessor = keyboard
145
163
  }
146
164
 
147
165
  /**
package/src/gamepad.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
1
+ import { createSignal, runWithOwner } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
3
 
4
4
  // Gamepad State: a reactive mirror of the runtime's sticky "gamepads" event.
@@ -18,7 +18,7 @@ import { on } from "srt:events"
18
18
  *
19
19
  * The snapshot is a faithful report. Note that pressing "back" (select) on a
20
20
  * mapped pad ALSO emits the `back` event (see onBack) - it is the pad-side
21
- * sibling of Android's system back, the runtime's exit-to-launcher gesture.
21
+ * sibling of Android's system back, the runtime's exit-to-player gesture.
22
22
  * Apps that bind "back" for their own controls should preventDefault that
23
23
  * event.
24
24
  */
@@ -52,11 +52,13 @@ let gamepadsAccessor: (() => (GamepadState | null)[]) | undefined
52
52
  */
53
53
  export function gamepads(): (GamepadState | null)[] {
54
54
  if (!gamepadsAccessor) {
55
- // ownedWrite: the sticky event replays synchronously inside on(), which
56
- // may run within a tracked scope's first read (see environment.ts).
57
- let [pads, setPads] = createSignal<(GamepadState | null)[]>([], { ownedWrite: true })
58
- on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
59
- gamepadsAccessor = pads
55
+ // Under no owner: the sticky event replays synchronously inside on(),
56
+ // and the first read is usually a tracked scope (see environment.ts).
57
+ runWithOwner(null, () => {
58
+ let [pads, setPads] = createSignal<(GamepadState | null)[]>([])
59
+ on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
60
+ gamepadsAccessor = pads
61
+ })
60
62
  }
61
- return gamepadsAccessor()
63
+ return gamepadsAccessor!()
62
64
  }