@solidrt/core 0.0.26 → 0.0.28

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,82 @@
1
+ // createPipeline compiles a custom GLSL ES 3.00 vertex+fragment pair that draws
2
+ // an interleaved vertex buffer into a texture, here a spinning cube with depth
3
+ // testing. The vertex shader declares `in` attributes matching the pipeline's
4
+ // attribute list (locations are resolved by name) and its own varyings; the
5
+ // fragment preamble provides fragColor/iResolution/iTime but no vUV. Uniforms
6
+ // are driven exactly like createShader: declaratively via the <texture> params
7
+ // prop, applied at the next repaint.
8
+ import { render, onFrame, createSignal } from "@solidrt/core"
9
+ import { createBuffer, createPipeline } from "@solidrt/core/gpu"
10
+
11
+ let VERTEX = `
12
+ in vec3 aPos;
13
+ in vec3 aColor;
14
+ out vec3 vColor;
15
+ uniform float uTime;
16
+
17
+ void main() {
18
+ float cy = cos(uTime), sy = sin(uTime);
19
+ float cx = cos(uTime * 0.7), sx = sin(uTime * 0.7);
20
+ mat3 rotY = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
21
+ mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
22
+ vec3 p = rotX * (rotY * aPos);
23
+ p.z += 2.5;
24
+
25
+ // Perspective projection (near 1, far 10). Clip y is negated: the target's
26
+ // memory row 0 is clip y = -1, and Impeller samples row 0 as the top, so
27
+ // camera-up needs the flip to be displayed up.
28
+ float f = 2.0;
29
+ float a = 11.0 / 9.0;
30
+ float b = -20.0 / 9.0;
31
+ gl_Position = vec4(p.x * f, -p.y * f, p.z * a + b, p.z);
32
+ vColor = aColor;
33
+ }
34
+ `
35
+
36
+ let FRAGMENT = `
37
+ in vec3 vColor;
38
+ void main() {
39
+ fragColor = vec4(vColor, 1.0);
40
+ }
41
+ `
42
+
43
+ // Interleaved [pos vec3, color vec3], 6 faces x 2 triangles, one color per face.
44
+ function cube(): Float32Array {
45
+ type Vec3 = [number, number, number]
46
+ let verts: number[] = []
47
+ let quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: Vec3) => {
48
+ for (let p of [a, b, c, a, c, d]) verts.push(p[0], p[1], p[2], color[0], color[1], color[2])
49
+ }
50
+ let s = 0.5
51
+ quad([-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s], [0.9, 0.3, 0.3]) // front
52
+ quad([s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s], [0.3, 0.9, 0.4]) // back
53
+ quad([s, -s, s], [s, -s, -s], [s, s, -s], [s, s, s], [0.3, 0.5, 0.9]) // right
54
+ quad([-s, -s, -s], [-s, -s, s], [-s, s, s], [-s, s, -s], [0.9, 0.8, 0.3]) // left
55
+ quad([-s, s, s], [s, s, s], [s, s, -s], [-s, s, -s], [0.8, 0.4, 0.9]) // top
56
+ quad([-s, -s, -s], [s, -s, -s], [s, -s, s], [-s, -s, s], [0.4, 0.9, 0.9]) // bottom
57
+ return new Float32Array(verts)
58
+ }
59
+
60
+ function App() {
61
+ let bufferId = createBuffer(cube())
62
+ let id = createPipeline(VERTEX, FRAGMENT, 512, 512, {
63
+ params: { uTime: 0 },
64
+ attributes: [
65
+ { name: "aPos", format: "vec3" },
66
+ { name: "aColor", format: "vec3" },
67
+ ],
68
+ buffer: bufferId,
69
+ depth: true,
70
+ clearColor: [0.08, 0.08, 0.12, 1],
71
+ })
72
+ let [time, setTime] = createSignal(0)
73
+ onFrame((tick) => setTime(tick / 1000))
74
+
75
+ return (
76
+ <window alignItems="center" justifyContent="center">
77
+ <texture src={id} params={{ uTime: time() }} width={400} height={400} />
78
+ </window>
79
+ )
80
+ }
81
+
82
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.26"
30
+ "@solidrt/flux-types": "0.0.28"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-beta.17",
package/src/gamepad.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import { on } from "srt:events"
3
+
4
+ // Gamepad State: a reactive mirror of the runtime's sticky "gamepads" event.
5
+ //
6
+ // The runtime coalesces pad activity to at most one snapshot per main-loop
7
+ // iteration and replays the latest one on subscribe, so the first read
8
+ // already sees any connected pads. Runtimes without gamepad support never
9
+ // emit the event and the accessor stays [].
10
+
11
+ /**
12
+ * One connected gamepad's current state. `buttons` holds the names of the
13
+ * currently-pressed buttons, using SDL3's positional names ("south", "east",
14
+ * "west", "north", "dpadUp", "dpadDown", "dpadLeft", "dpadRight", "start",
15
+ * "back", "guide", "leftShoulder", "rightShoulder", "leftStick",
16
+ * "rightStick"). `axes` has sticks ("leftX", "leftY", "rightX", "rightY") in
17
+ * -1..1 and triggers ("leftTrigger", "rightTrigger") in 0..1.
18
+ */
19
+ export interface GamepadState {
20
+ /** Runtime instance id: unique per connection, not stable across reconnects. */
21
+ id: number
22
+ name: string
23
+ buttons: string[]
24
+ axes: Record<string, number>
25
+ /**
26
+ * True when the device has an SDL controller-database mapping, so button
27
+ * and axis names reflect verified physical positions. False for raw HID
28
+ * joysticks: they still report, but names are assigned positionally in W3C
29
+ * standard-mapping order (button 0 is "south", ..., overflow "button17"...;
30
+ * axes 0-3 are "leftX"..."rightY", overflow "axis4"...), a d-pad hat folds
31
+ * into the "dpad*" names, and analog triggers, if any, appear wherever the
32
+ * device puts them (e.g. as *button* names "leftTrigger"/"rightTrigger" at
33
+ * indices 6/7) rather than as the trigger axes mapped pads have.
34
+ */
35
+ mapped: boolean
36
+ }
37
+
38
+ let gamepadsAccessor: (() => (GamepadState | null)[]) | undefined
39
+
40
+ /**
41
+ * Connected gamepads as a reactive accessor. Slots are stable web-style: a
42
+ * pad keeps its index for its whole connection, disconnecting leaves a null
43
+ * hole, and the next connect fills the lowest free slot - so slot index works
44
+ * as a persistent player number. Read inside a tracked scope (JSX, memo,
45
+ * effect, onFrame) to re-run on pad activity.
46
+ */
47
+ export function gamepads(): (GamepadState | null)[] {
48
+ if (!gamepadsAccessor) {
49
+ // ownedWrite: the sticky event replays synchronously inside on(), which
50
+ // may run within a tracked scope's first read (see environment.ts).
51
+ let [pads, setPads] = createSignal<(GamepadState | null)[]>([], { ownedWrite: true })
52
+ on("gamepads", (e: { pads?: (GamepadState | null)[] }) => setPads(e.pads ?? []))
53
+ gamepadsAccessor = pads
54
+ }
55
+ return gamepadsAccessor()
56
+ }
package/src/gpu.ts CHANGED
@@ -19,6 +19,12 @@ import * as gpu from "flux:gpu"
19
19
  // `<texture params={...}>` when a `<texture>` element is already in the tree.
20
20
  export { destroyTexture, setShaderParams, uploadTexture } from "flux:gpu"
21
21
 
22
+ // Pipeline plumbing re-exported raw: setDrawCount re-renders a pipeline after
23
+ // its buffer gained or lost dynamic geometry; destroyBuffer is the manual
24
+ // cleanup path for buffers created outside a reactive scope.
25
+ export { destroyBuffer, setDrawCount } from "flux:gpu"
26
+ export type { Topology, VertexAttribute } from "flux:gpu"
27
+
22
28
  // captureSnapshot renders a node to a texture and readTexture reads any
23
29
  // texture's bytes back. Re-exported raw (no reactive auto-cleanup wrapper):
24
30
  // captureSnapshot resolves asynchronously, by which point the reactive owner is
@@ -79,4 +85,71 @@ export function createShader(
79
85
  let id = gpu.createShader(fragmentSrc, width, height, params, textures)
80
86
  if (getOwner()) onCleanup(() => gpu.destroyTexture(id))
81
87
  return id
88
+ }
89
+
90
+ // View any TypedArray or ArrayBuffer as a Uint8Array over the same memory,
91
+ // without copying, so vertex data can be authored as Float32Array.
92
+ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
93
+ if (data instanceof Uint8Array) return data
94
+ if (data instanceof ArrayBuffer) return new Uint8Array(data)
95
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
96
+ }
97
+
98
+ /**
99
+ * Compiles a GLSL ES 3.00 vertex+fragment pipeline and renders it into a
100
+ * texture, returning the texture id (usable anywhere a normal texture id is,
101
+ * e.g. `<texture src>`). Unlike `createShader` the vertex stage is yours:
102
+ * declare `in` attributes matching `opts.attributes` (one interleaved vertex
103
+ * in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
104
+ * the fragment stage. Both sources may reference `iResolution`/`iTime` and any
105
+ * `uniform float` they declare; drive values with `<texture src={id}
106
+ * params={{...}} />` or `setShaderParams`, exactly like a fragment shader.
107
+ * `opts.depth` attaches a private depth buffer (cleared + tested per render);
108
+ * `opts.vertexCount` defaults to the whole buffer and can be changed later
109
+ * with `setDrawCount`. Frees the texture and GL program when the reactive
110
+ * owner is disposed; create outside any reactive scope for app-lifetime
111
+ * pipelines.
112
+ */
113
+ export function createPipeline(
114
+ vertexSrc: string,
115
+ fragmentSrc: string,
116
+ width: number,
117
+ height: number,
118
+ opts?: {
119
+ params?: Record<string, number>
120
+ textures?: Record<string, number>
121
+ attributes?: gpu.VertexAttribute[]
122
+ buffer?: number
123
+ topology?: gpu.Topology
124
+ vertexCount?: number
125
+ depth?: boolean
126
+ clearColor?: [number, number, number, number]
127
+ },
128
+ ): number {
129
+ let id = gpu.createPipeline(vertexSrc, fragmentSrc, width, height, opts)
130
+ if (getOwner()) onCleanup(() => gpu.destroyTexture(id))
131
+ return id
132
+ }
133
+
134
+ /**
135
+ * Creates a vertex buffer for pipeline attributes from raw data (typically a
136
+ * Float32Array laid out to match the pipeline's interleaved attribute list).
137
+ * Update it later with {@link writeBuffer}; the buffer's byte size is fixed at
138
+ * creation, so reserve room up front for dynamic geometry. Freed automatically
139
+ * when the reactive owner is disposed; created outside a reactive scope you
140
+ * must call `destroyBuffer` yourself. Destroy pipelines before their buffer.
141
+ */
142
+ export function createBuffer(data: ArrayBuffer | ArrayBufferView): number {
143
+ let id = gpu.createBuffer(toUint8(data))
144
+ if (getOwner()) onCleanup(() => gpu.destroyBuffer(id))
145
+ return id
146
+ }
147
+
148
+ /**
149
+ * Overwrites part of a vertex buffer at `byteOffset` (default 0). Every
150
+ * pipeline drawing from the buffer re-renders with its last-applied params,
151
+ * so geometry-only changes reach the screen without a params update.
152
+ */
153
+ export function writeBuffer(id: number, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
154
+ gpu.writeBuffer(id, toUint8(data), byteOffset)
82
155
  }
package/src/image.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // top that fetches/decodes/uploads for you and swaps the texture when the source
4
4
  // changes - the same relationship createTexture/createShader have to flux:gpu.
5
5
 
6
- import { createMemo, onCleanup, NotReadyError } from "@solidjs/signals"
6
+ import { createMemo, onCleanup } from "@solidjs/signals"
7
7
  import { createTexture, destroyTexture } from "./gpu"
8
8
 
9
9
  export type DecodedImage = {
@@ -24,6 +24,80 @@ export function decodeImage(bytes: Uint8Array): DecodedImage {
24
24
 
25
25
  export type ImageSource = string | Uint8Array
26
26
 
27
+ // Shared loader for URL sources. Every mount of the same URL shares one
28
+ // fetch/decode/texture (refcounted; the texture is destroyed when the last
29
+ // mount releases it). Byte caching and fetch politeness live below, in the
30
+ // runtime's fetch layer (disk cache + per-host limit); this map exists for
31
+ // what a byte cache cannot provide, sharing the decoded GPU texture.
32
+ // Uint8Array sources bypass all of this: no key, per-mount texture.
33
+ type ImageEntry = {
34
+ refs: number
35
+ texture: number
36
+ promise: Promise<number>
37
+ }
38
+
39
+ let imageCache = new Map<string, ImageEntry>()
40
+
41
+ async function loadImage(url: string): Promise<number> {
42
+ // Images are assets: cache to disk, no freshness. Use a versioned URL (or
43
+ // fetch + decodeImage manually) when a URL's content must be re-checked.
44
+ let res = await fetch(url, { cache: "force-cache" })
45
+ if (!res.ok) throw new Error(`Image fetch failed: HTTP ${res.status} for ${url}`)
46
+ let bytes = await res.bytes()
47
+ let decoded: DecodedImage
48
+ try {
49
+ decoded = decodeImage(bytes)
50
+ } catch (e) {
51
+ throw new Error(`Image decode failed for ${url} (first bytes: ${sniffBytes(bytes)}): ${e}`)
52
+ }
53
+ return createTexture(decoded.data, decoded.width, decoded.height)
54
+ }
55
+
56
+ function acquireImage(url: string): ImageEntry {
57
+ let entry = imageCache.get(url)
58
+ if (!entry) {
59
+ let e: ImageEntry = { refs: 0, texture: -1, promise: undefined as never }
60
+ e.promise = loadImage(url).then(
61
+ id => {
62
+ // Everyone released while the load was in flight: nothing owns the
63
+ // texture, so drop it here instead of recording it.
64
+ if (e.refs === 0) {
65
+ destroyTexture(id)
66
+ imageCache.delete(url)
67
+ } else {
68
+ e.texture = id
69
+ }
70
+ return id
71
+ },
72
+ err => {
73
+ // Concurrent mounts shared this rejection; dropping the entry lets a
74
+ // later remount retry (a transient failure recovers with the network).
75
+ imageCache.delete(url)
76
+ throw err
77
+ },
78
+ )
79
+ // Awaiters observe the rejection; this keeps a fully-released failed
80
+ // entry from surfacing as an unhandled rejection.
81
+ e.promise.catch(() => {})
82
+ imageCache.set(url, e)
83
+ entry = e
84
+ }
85
+ entry.refs++
86
+ return entry
87
+ }
88
+
89
+ function releaseImage(url: string): void {
90
+ let entry = imageCache.get(url)
91
+ if (!entry) return
92
+ entry.refs--
93
+ if (entry.refs > 0) return
94
+ if (entry.texture >= 0) {
95
+ destroyTexture(entry.texture)
96
+ imageCache.delete(url)
97
+ }
98
+ // Still pending: the settle handler above sees refs === 0 and cleans up.
99
+ }
100
+
27
101
  /**
28
102
  * Loads an image as an async computation and returns a reactive accessor for its
29
103
  * GPU texture id. This is a SolidJS 2.0 async value: reading it suspends until
@@ -35,6 +109,13 @@ export type ImageSource = string | Uint8Array
35
109
  * `<texture src={id()} />`; the texture carries its own pixel size, so no
36
110
  * width/height is needed unless you want to scale it.
37
111
  *
112
+ * URL loads are shared: mounts of the same URL reuse one fetch and one texture
113
+ * (freed when the last user is disposed). The bytes are fetched with
114
+ * `cache: "force-cache"` - images are assets, cached on disk with no
115
+ * freshness check - so use a versioned URL when the content behind a URL can
116
+ * change. A failed load rejects every mount sharing it; a later remount
117
+ * retries.
118
+ *
38
119
  * For bytes you already hold (a `with { type: "binary" }` import, or anything in
39
120
  * memory) this suspends needlessly: `decodeImage` + `createTexture` are both
40
121
  * synchronous, so reach for them directly and skip the `<Loading>` boundary.
@@ -43,29 +124,43 @@ export type ImageSource = string | Uint8Array
43
124
  */
44
125
  export function createImage(src: ImageSource | (() => ImageSource)): () => number {
45
126
  let getSrc = typeof src === "function" ? src : () => src
46
- let generation = 0
47
127
 
48
128
  return createMemo<number>(async () => {
49
129
  let source = getSrc()
50
- let mine = ++generation
51
130
 
52
- // Register cleanup synchronously, before the await: an onCleanup added after
53
- // an await is orphaned because the reactive owner is not restored across it.
54
- // The holder is filled in once the texture exists.
131
+ if (typeof source === "string") {
132
+ // Acquire and register cleanup synchronously, before the await: an
133
+ // onCleanup added after an await is orphaned because the reactive owner
134
+ // is not restored across it.
135
+ let entry = acquireImage(source)
136
+ onCleanup(() => releaseImage(source))
137
+ return await entry.promise
138
+ }
139
+
140
+ // Byte sources decode and upload synchronously; this run owns the texture.
55
141
  let holder = { id: -1 }
56
142
  onCleanup(() => {
57
143
  if (holder.id >= 0) destroyTexture(holder.id)
58
144
  })
59
-
60
- let bytes = typeof source === "string" ? await (await fetch(source)).bytes() : source
61
-
62
- // If the source changed while we were loading, this run is superseded. Skip
63
- // the GPU upload and stay pending: a texture created here would leak, since
64
- // superseded async runs are not otherwise cleaned up. The newer run wins.
65
- if (mine !== generation) throw new NotReadyError(source)
66
-
67
- let { data, width, height } = decodeImage(bytes)
68
- holder.id = createTexture(data, width, height)
145
+ let decoded: DecodedImage
146
+ try {
147
+ decoded = decodeImage(source)
148
+ } catch (e) {
149
+ throw new Error(`Image decode failed (first bytes: ${sniffBytes(source)}): ${e}`)
150
+ }
151
+ holder.id = createTexture(decoded.data, decoded.width, decoded.height)
69
152
  return holder.id
70
153
  })
154
+ }
155
+
156
+ // A payload that fails to decode is usually not an image at all (an HTML error
157
+ // page, a JSON error body); showing its first bytes makes that recognizable in
158
+ // the log without a debugger.
159
+ function sniffBytes(bytes: Uint8Array): string {
160
+ let head = ""
161
+ for (let i = 0; i < Math.min(bytes.length, 24); i++) {
162
+ let b = bytes[i] ?? 0
163
+ head += b >= 32 && b < 127 ? String.fromCharCode(b) : "."
164
+ }
165
+ return JSON.stringify(head)
71
166
  }
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export { setPointerCapture, releasePointerCapture } from "./window"
8
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
9
9
  export { env } from "./environment"
10
10
  export type { InputDevices, SystemTheme, Orientation } from "./environment"
11
+ export { gamepads } from "./gamepad"
12
+ export type { GamepadState } from "./gamepad"
11
13
  export { capabilities } from "./capabilities"
12
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
13
15
  export { createTexture } from "./gpu"
package/src/renderer.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createRoot, onCleanup } from "@solidjs/signals"
2
2
  import { createRenderer } from "@solidjs/universal"
3
+ import type { Element } from "solid-js"
3
4
  import * as tree from "flux:rendertree"
4
5
  import { attachWindow } from "./window"
5
6
  import { setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
@@ -87,6 +88,25 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
87
88
  }
88
89
  }
89
90
 
91
+ // A property the native tree rejected must not take down the reactive system:
92
+ // a typo'd or not-yet-implemented prop poisons only itself. Warn once per
93
+ // element kind + property with a stack (the dev server remaps its frames to
94
+ // the .tsx source), then ignore further writes of the same pair.
95
+ let warnedUnknownProps = new Set<string>()
96
+
97
+ function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
98
+ try {
99
+ tree.setProperty(node.id, name, value)
100
+ } catch (e) {
101
+ if (!String(e).includes("unknown property")) throw e
102
+ let key = node.elementType + "." + name
103
+ if (warnedUnknownProps.has(key)) return
104
+ warnedUnknownProps.add(key)
105
+ let stack = new Error().stack ?? ""
106
+ console.warn(`Ignoring unknown property '${name}' on <${node.elementType}>\n${stack}`)
107
+ }
108
+ }
109
+
90
110
  // Applies a single prop to a node: routes events to the handler registry,
91
111
  // parses color strings/gradients, and forwards everything else to the tree.
92
112
  // Shared by the renderer's setProperty hook and by createElement, which since
@@ -103,16 +123,16 @@ function applyProp<T>(node: ProxyNode, name: string, value: T): void {
103
123
  }
104
124
 
105
125
  if (name === "color" && isGradient(value)) {
106
- tree.setProperty(node.id, name, value)
126
+ setTreeProperty(node, name, value)
107
127
  return
108
128
  }
109
129
 
110
130
  if (name === "color" && typeof value === "string") {
111
- tree.setProperty(node.id, name, parseColor(value))
131
+ setTreeProperty(node, name, parseColor(value))
112
132
  return
113
133
  }
114
134
 
115
- tree.setProperty(node.id, name, value)
135
+ setTreeProperty(node, name, value)
116
136
  }
117
137
 
118
138
  export let {
@@ -245,12 +265,23 @@ export function render(code: () => any) {
245
265
  * The default mount is the window's flex root, so a portaled node that is not
246
266
  * `position: "absolute"` will take flow space and displace app content. Position
247
267
  * the portal root absolutely, or pass a `mount` target that does it for you.
268
+ *
269
+ * Returns null (nothing in place), so a component may return a portal directly.
270
+ *
271
+ * Portals cannot mount during the initial render: the default target is the
272
+ * window root, which exists only after the app's first build returns, so a
273
+ * portal created during that build throws. This is the contract, not a bug:
274
+ * portal content is overlay content, opened by a signal that starts false.
248
275
  */
249
- export function createPortal(node: ProxyNode, mount?: ProxyNode): void {
276
+ export function createPortal(node: Element, mount?: ProxyNode): null {
250
277
  let target = mount ?? windowRoot
251
278
  if (!target) {
252
- throw new Error("createPortal: no mount target (called before render()?)")
279
+ throw new Error("createPortal: no mount target (portals cannot mount during the initial render; open them after mount)")
280
+ }
281
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
282
+ throw new Error("createPortal: node must be a single built element")
253
283
  }
254
- insertNode(target, node)
255
- onCleanup(() => removeNode(target, node))
284
+ insertNode(target, node as ProxyNode)
285
+ onCleanup(() => removeNode(target, node as ProxyNode))
286
+ return null
256
287
  }
@@ -40,6 +40,15 @@ declare module "srt:dev" {
40
40
  export function connect(address: string): void
41
41
  export function discover(): void
42
42
  export function stop(): void
43
+ /**
44
+ * Register a named debug command, listable and callable from the dev server
45
+ * (the list_debug / call_debug MCP tools). `args` arrives JSON-parsed; the
46
+ * return value must be JSON-serializable and synchronous (promises are not
47
+ * awaited). Re-registering a name replaces it; registrations reset on hot
48
+ * reload, so register at module init. Callable in every build, but only dev
49
+ * clients ever invoke commands.
50
+ */
51
+ export function registerDebug(name: string, fn: (args?: any) => unknown): void
43
52
  }
44
53
 
45
54
  // Frame draw (lattice runner). renderFrame() synchronously renders the current
package/src/scroll.ts CHANGED
@@ -49,6 +49,14 @@ export function createScroll(
49
49
 
50
50
  let [offset, setOffset] = createSignal<ScrollOffset>({ x: 0, y: 0 })
51
51
 
52
+ // A scroll viewport with no explicit main-axis size resolves to 0 in flex
53
+ // layout and its content silently vanishes - a classic trap (maxHeight alone
54
+ // does not size it either). Detect it at measure time and warn once, with a
55
+ // stack captured at creation so the warning points at the component that
56
+ // built the scroller (the dev server remaps the frames to .tsx).
57
+ let origin = new Error().stack ?? ""
58
+ let warnedCollapsed = false
59
+
52
60
  // Last measured overflow, refreshed each layout. scrollBy/scrollTo clamp
53
61
  // against these between layouts; onLayout re-clamps once new sizes are known.
54
62
  let maxX = 0
@@ -72,6 +80,18 @@ export function createScroll(
72
80
  let vb = getBoundingBox(vp)
73
81
  let cb = getBoundingBox(ct)
74
82
  if (!vb || !cb) return
83
+ if (!warnedCollapsed) {
84
+ let zeroY = canY && vb.height === 0 && cb.height > 0
85
+ let zeroX = canX && vb.width === 0 && cb.width > 0
86
+ if (zeroY || zeroX) {
87
+ warnedCollapsed = true
88
+ let axisName = zeroY ? "height" : "width"
89
+ console.warn(
90
+ `Scroll container resolved to ${axisName} 0, so its content is invisible. ` +
91
+ `Give it an explicit ${axisName} or flex; maxHeight/maxWidth alone does not size it.\n${origin}`,
92
+ )
93
+ }
94
+ }
75
95
  maxX = Math.max(0, cb.width - vb.width)
76
96
  maxY = Math.max(0, cb.height - vb.height)
77
97
  let cur = offset()
package/src/types.d.ts CHANGED
@@ -308,6 +308,11 @@ export interface TextProps extends Position, PaintProps, PointerProps {
308
308
  h?: number
309
309
  fontFamily?: "sans" | "mono" | (string & {})
310
310
  fontSize?: number
311
+ /**
312
+ * Line height as a MULTIPLIER of fontSize, not pixels (the theme uses
313
+ * 1.3-1.6). A CSS-reflex pixel value like 22 makes each line box 22x the
314
+ * font size, rendering the text as blank space.
315
+ */
311
316
  lineHeight?: number
312
317
  fontStyle?: "normal" | "italic"
313
318
  fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900