@solidrt/core 0.0.13 → 0.0.16

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
@@ -29,7 +29,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
29
29
  ```
30
30
 
31
31
  Peer deps @solidjs/signals and @solidjs/universal must match (currently
32
- 2.0.0-beta.14); bun resolves them from peerDependencies.
32
+ 2.0.0-beta.15); bun resolves them from peerDependencies.
33
33
 
34
34
  ## Element model (the parts that are easy to get wrong)
35
35
 
@@ -63,6 +63,24 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
63
63
  directly-positioned, often-animating elements (e.g. hundreds of balls), `d-`
64
64
  skips the per-element layout that plain elements would incur.
65
65
 
66
+ - Layout-affecting vs not (this matters for per-frame work). Props fall in three
67
+ buckets, split by where they take effect:
68
+ - `LayoutProps` - width/height, min/max sizes, margin, padding, `position` and
69
+ its `top`/`right`/`bottom`/`left` offsets, flex*/gap/display, grid*,
70
+ aspectRatio, overflow. Changing ANY of these triggers a Taffy reflow of the
71
+ node and its subtree.
72
+ - `TransformProps` - x, y, scale/scaleX/scaleY, rotate/rotateX/rotateY,
73
+ perspective, cx/cy, scrollX/scrollY. Applied at paint/composite; NO reflow.
74
+ - `PaintProps` - color, drawStyle, strokeWidth, blendMode, radius. Also no
75
+ reflow.
76
+ So to MOVE or animate an element - dragging, transitions, per-frame motion -
77
+ translate it with the transform `x`/`y` (or scale/rotate), never by animating
78
+ `left`/`top`/`margin`/`width`. Common trap: `left`/`top` read like "position"
79
+ but they are LAYOUT offsets (for `position:absolute`), so driving them every
80
+ frame reflows the tree. Anchor the element once with layout (e.g.
81
+ `position:absolute` at `left:0,top:0`, or just let normal flow place it) and
82
+ then translate it with `x`/`y`.
83
+
66
84
  - Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
67
85
  with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
68
86
  onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
@@ -0,0 +1,27 @@
1
+ # @solidrt/core examples
2
+
3
+ Single-concept SolidRT patterns. Each file is a complete, runnable app (ends in
4
+ `render(() => <App />)`) demonstrating exactly one thing - copy one and adapt it.
5
+ For the SolidJS 2.0 reactivity/control-flow model see `solid-js/CHEATSHEET.md`;
6
+ for the element/prop model see `@solidrt/core/AGENTS.md`.
7
+
8
+ ## Host elements and layout
9
+ - `window-root.tsx` - the minimal app; the root must be `<window>`.
10
+ - `view-layout.tsx` - `<view>` as a flex container; containers do not paint.
11
+ - `background-rect.tsx` - a `d-rect` filling its parent as a background.
12
+ - `detached-positioning.tsx` - the `d-` prefix: x/y placement, no reflow, detached-only children.
13
+ - `text-paint-styling.tsx` - the uniform `color` prop; `drawStyle="stroke"` vs fill.
14
+
15
+ ## Frame and lifecycle
16
+ - `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
17
+ - `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
18
+
19
+ ## Window state
20
+ - `window-signals.tsx` - reactive `windowSize()` / `safeArea()` accessors (prefer over `onResize`).
21
+
22
+ ## Overlays
23
+ - `portal.tsx` - `createPortal` relocating content to the window root to escape clipping.
24
+
25
+ ## Images and GPU
26
+ - `image.tsx` - `createImage` (async value: fetch + decode + upload) read inside a `<Loading>` boundary and shown with `<texture>`.
27
+ - `gpu-shader.tsx` - a GLSL fragment shader rendered to a texture, animated via `setShaderParams`.
@@ -0,0 +1,15 @@
1
+ // Containers do not paint, so a background is just a draw primitive placed behind
2
+ // the content. A d- primitive with no x/y/w/h fills its parent, which is exactly
3
+ // what you want for a background. Put it FIRST so siblings paint on top of it.
4
+ import { render } from "@solidrt/core"
5
+
6
+ function App() {
7
+ return (
8
+ <window alignItems="center" justifyContent="center">
9
+ <d-rect color="#1a3380" />
10
+ <text fontSize={28} color="#ffffff">Content on a full-bleed background</text>
11
+ </window>
12
+ )
13
+ }
14
+
15
+ render(() => <App />)
@@ -0,0 +1,21 @@
1
+ // The d- prefix means "detached from layout": the layout engine ignores it and
2
+ // you place it yourself with x/y. Because a detached node does not participate in
3
+ // layout, moving it (animating x/y) does not reflow its siblings - which makes
4
+ // d- elements the right choice for overlays, badges, and anything that moves
5
+ // independently. The rule: a detached node can only contain other detached
6
+ // nodes. Everything under a d- element must itself be a d- element (here d-rect +
7
+ // d-text) - nesting a plain <view> or <text> inside a d- element is an error.
8
+ import { render } from "@solidrt/core"
9
+
10
+ function App() {
11
+ return (
12
+ <window>
13
+ <d-view x={40} y={60}>
14
+ <d-rect w={160} h={64} radius={12} color="#3366b3" />
15
+ <d-text x={16} y={20} fontSize={18} color="#ffffff">Badge</d-text>
16
+ </d-view>
17
+ </window>
18
+ )
19
+ }
20
+
21
+ render(() => <App />)
@@ -0,0 +1,26 @@
1
+ // onFrame(fn) is the per-frame animation hook. tick is runtime-paced time in ms
2
+ // (smooth even when frame times jitter), frame is the present count, rate is the
3
+ // refresh rate in Hz. Drive a signal from the tick and read it in JSX - the graph
4
+ // repaints each frame. A pending onFrame is a standing request for the next
5
+ // frame, so the loop runs only while something is animating and stops when the
6
+ // callback is cleaned up (automatic within a reactive scope).
7
+ //
8
+ // Animate a transform (rotate / scale / x / y on a <view>), not a layout prop:
9
+ // transforms are applied at paint time, while animating width/margin/etc would
10
+ // re-run layout every frame.
11
+ import { render, onFrame, createSignal } from "@solidrt/core"
12
+
13
+ function App() {
14
+ let [angle, setAngle] = createSignal(0)
15
+ onFrame((tick) => setAngle(tick / 1000)) // radians; ~1 turn every 6.3s
16
+
17
+ return (
18
+ <window alignItems="center" justifyContent="center">
19
+ <view width={120} height={120} rotate={angle()}>
20
+ <rect width={120} height={120} radius={16} color="#3366b3" />
21
+ </view>
22
+ </window>
23
+ )
24
+ }
25
+
26
+ render(() => <App />)
@@ -0,0 +1,36 @@
1
+ // createShader compiles a GLSL ES 3.00 fragment shader and renders it into a
2
+ // texture, returning a texture id you display with <texture src={id}>. The
3
+ // fragment body may reference vUV (0..1, top-left origin), iResolution, iTime, and
4
+ // any `uniform float` it declares; there is no #version line - the runtime injects
5
+ // the preamble. The texture is freed automatically when the reactive owner is
6
+ // disposed.
7
+ //
8
+ // iResolution is filled in for you, but iTime is NOT - drive it (and any other
9
+ // uniform) yourself with setShaderParams from onFrame to animate. The shader's
10
+ // size is baked in at creation.
11
+ import { render, onFrame } from "@solidrt/core"
12
+ import { createShader, setShaderParams } from "@solidrt/core/gpu"
13
+
14
+ let FRAGMENT = `
15
+ void main() {
16
+ vec2 uv = vUV;
17
+ float t = iTime * 2.0;
18
+ float a = 0.5 + 0.5 * sin(uv.x * 10.0 + t);
19
+ float b = 0.5 + 0.5 * sin(uv.y * 10.0 - t * 1.3);
20
+ float c = 0.5 + 0.5 * sin((uv.x + uv.y) * 8.0 + t * 0.7);
21
+ fragColor = vec4(a, b, c, 1.0);
22
+ }
23
+ `
24
+
25
+ function App() {
26
+ let id = createShader(FRAGMENT, 512, 512, { iTime: 0 })
27
+ onFrame((tick) => setShaderParams(id, { iTime: tick / 1000 }))
28
+
29
+ return (
30
+ <window alignItems="center" justifyContent="center">
31
+ <texture src={id} width={400} height={400} />
32
+ </window>
33
+ )
34
+ }
35
+
36
+ render(() => <App />)
@@ -0,0 +1,27 @@
1
+ // createImage loads an image as a SolidJS 2.0 async value and returns an accessor
2
+ // for its GPU texture id. It handles fetch, decode, GPU upload, and cleanup for
3
+ // you. A string source is fetched; a Uint8Array is decoded directly. The texture
4
+ // carries its own pixel size, so <texture> needs no width/height to show it at
5
+ // natural size - pass them only to scale it.
6
+ //
7
+ // Reading img() suspends until ready, so read it inside a <Loading> boundary
8
+ // (this is the 2.0 async mechanic, not a manual undefined-signal + <Show>); a
9
+ // load failure surfaces to <Errored>. Pass an accessor (createImage(() => src()))
10
+ // to make the source reactive - the image reloads and the old texture is freed.
11
+ // For manual control, decodeImage + createTexture (from @solidrt/core/gpu) are
12
+ // the primitives underneath.
13
+ import { render, createImage, Loading } from "@solidrt/core"
14
+
15
+ function App() {
16
+ let img = createImage("https://picsum.photos/seed/solidrt/400/300")
17
+
18
+ return (
19
+ <window alignItems="center" justifyContent="center">
20
+ <Loading fallback={<text color="#888">loading...</text>}>
21
+ <texture src={img()} />
22
+ </Loading>
23
+ </window>
24
+ )
25
+ }
26
+
27
+ render(() => <App />)
@@ -0,0 +1,39 @@
1
+ // onLayout(fn) fires after layout is computed but before paint - the one point
2
+ // where measured geometry is available via getBoundingBox (a window-relative
3
+ // snapshot, valid only here or in an event handler). Its canonical use is to
4
+ // connect or annotate laid-out elements with detached drawing: let the layout
5
+ // engine place the boxes, read their boxes, then draw a d-path between them.
6
+ //
7
+ // Two rules make it safe:
8
+ // 1. Write the result to something that does NOT affect layout - a d-path's `d`,
9
+ // a detached node's position. Writing a layout-affecting prop here forces an
10
+ // extra layout pass every frame.
11
+ // 2. Call flush() after the write so it lands before the display list is built
12
+ // (onLayout runs after the frame's normal flush).
13
+ import { render, onLayout, getBoundingBox, createSignal, flush } from "@solidrt/core"
14
+
15
+ function App() {
16
+ let boxA!: { id: number }
17
+ let boxB!: { id: number }
18
+ let [d, setD] = createSignal("")
19
+
20
+ onLayout(() => {
21
+ let a = getBoundingBox(boxA)
22
+ let b = getBoundingBox(boxB)
23
+ if (!a || !b) return
24
+ let ax = a.x + a.width / 2, ay = a.y + a.height / 2
25
+ let bx = b.x + b.width / 2, by = b.y + b.height / 2
26
+ setD(`M ${ax} ${ay} L ${bx} ${by}`)
27
+ flush()
28
+ })
29
+
30
+ return (
31
+ <window flexDirection="row" justifyContent="space-between" alignItems="center" padding={48}>
32
+ <rect ref={n => (boxA = n)} width={80} height={80} radius={8} color="#3366b3" />
33
+ <rect ref={n => (boxB = n)} width={80} height={80} radius={8} color="#6699e6" />
34
+ <d-path d={d()} color="#e0245e" drawStyle="stroke" strokeWidth={3} />
35
+ </window>
36
+ )
37
+ }
38
+
39
+ render(() => <App />)
@@ -0,0 +1,38 @@
1
+ // createPortal relocates an already-built node to the window root (or a given
2
+ // mount), so content declared deep in the tree can escape the layout, clipping,
3
+ // and stacking of its surroundings - the primitive behind overlays like modals,
4
+ // menus, and tooltips. It moves one concrete node and removes it again when the
5
+ // surrounding reactive scope disposes.
6
+ //
7
+ // Key points:
8
+ // - A JSX element's runtime value IS the built node, so pass the JSX straight to
9
+ // createPortal. It returns void, so `return createPortal(...)` renders nothing
10
+ // in place while the content lives at the mount.
11
+ // - The default mount is the window's flex root, so the portaled node must be
12
+ // position="absolute" - otherwise it takes flow space and displaces content.
13
+ // - Pass a second argument (a node captured from a ref) to mount elsewhere.
14
+ import { render, createPortal } from "@solidrt/core"
15
+
16
+ // Declared inside the clipped card below, but drawn at the window root, on top of
17
+ // everything and outside the card's overflow clip.
18
+ function Banner() {
19
+ return createPortal(
20
+ <view position="absolute" top={0} left={0} right={0} padding={12}>
21
+ <d-rect color="#e0245e" />
22
+ <text color="#ffffff">Portaled banner - escaped the card</text>
23
+ </view>
24
+ )
25
+ }
26
+
27
+ function App() {
28
+ return (
29
+ <window padding={40}>
30
+ <view flex={1} overflow="hidden" alignItems="center" justifyContent="center">
31
+ <rect width={140} height={140} radius={8} color="#3366b3" />
32
+ <Banner />
33
+ </view>
34
+ </window>
35
+ )
36
+ }
37
+
38
+ render(() => <App />)
@@ -0,0 +1,18 @@
1
+ // Painting is uniform across primitives: the `color` prop sets the paint (a CSS
2
+ // color string or a gradient). There is no fill / stroke / background prop. To
3
+ // outline instead of fill, set drawStyle="stroke" and strokeWidth; "stroke-and-
4
+ // fill" does both. The same color prop styles <text>, alongside fontSize /
5
+ // fontWeight / fontFamily.
6
+ import { render } from "@solidrt/core"
7
+
8
+ function App() {
9
+ return (
10
+ <window flexDirection="column" gap={16} padding={24} alignItems="center" justifyContent="center">
11
+ <text fontSize={28} fontWeight={700} color="#1a3380">Filled text</text>
12
+ <rect width={160} height={64} radius={12} color="#3366b3" />
13
+ <oval width={120} height={64} drawStyle="stroke" strokeWidth={4} color="#1a3380" />
14
+ </window>
15
+ )
16
+ }
17
+
18
+ render(() => <App />)
@@ -0,0 +1,20 @@
1
+ // <view> is the layout container: a flexbox box positioned by the layout engine.
2
+ // It does NOT paint anything itself - it has no color. To make a box visible you
3
+ // place a draw primitive inside it (here an attached <rect>, which is both laid
4
+ // out and painted). Flex props (flexDirection, gap, padding, alignItems) work as
5
+ // in CSS.
6
+ import { render } from "@solidrt/core"
7
+
8
+ function App() {
9
+ return (
10
+ <window>
11
+ <view flex={1} flexDirection="column" gap={12} padding={24} justifyContent="center">
12
+ <rect height={48} radius={8} color="#3366b3" />
13
+ <rect height={48} radius={8} color="#6699e6" />
14
+ <rect height={48} radius={8} color="#99c2f0" />
15
+ </view>
16
+ </window>
17
+ )
18
+ }
19
+
20
+ render(() => <App />)
@@ -0,0 +1,14 @@
1
+ // The minimal valid app. render() is called once at the top level and the root
2
+ // element MUST be <window> - anything else throws. <window> takes layout props
3
+ // (it is a flex container), so children can be centered here directly.
4
+ import { render } from "@solidrt/core"
5
+
6
+ function App() {
7
+ return (
8
+ <window title="Hello" alignItems="center" justifyContent="center">
9
+ <text fontSize={24} color="#222">Hello SolidRT</text>
10
+ </window>
11
+ )
12
+ }
13
+
14
+ render(() => <App />)
@@ -0,0 +1,29 @@
1
+ // Window state is exposed as reactive accessors - read them in JSX and the UI
2
+ // updates when the window changes. Prefer these over the onResize event callback:
3
+ // they are reactive, sticky (the first read sees the current value), and live for
4
+ // the whole app.
5
+ //
6
+ // windowSize() -> { width, height }
7
+ // safeArea() -> { top, left, right, bottom } inset distances from each edge
8
+ // displayScale() -> device pixel ratio
9
+ //
10
+ // safeArea is mostly an interactivity boundary: the platform may intercept
11
+ // touches/gestures inside the insets (system bars, notch, home indicator), so do
12
+ // not place interactive content there. For plain drawing it is usually fine to
13
+ // extend into the left/right insets (full-bleed backgrounds); top and bottom are
14
+ // what matter, since content there - text especially - can be covered. So pad the
15
+ // top/bottom by the inset and let the background fill the whole window.
16
+ import { render, windowSize, safeArea } from "@solidrt/core"
17
+
18
+ function App() {
19
+ return (
20
+ <window>
21
+ <d-rect color="#101418" />
22
+ <view flex={1} flexDirection="column" gap={8} paddingTop={safeArea().top} paddingBottom={safeArea().bottom}>
23
+ <text fontSize={18} color="#e6e6e6">{windowSize().width} x {windowSize().height}</text>
24
+ </view>
25
+ </window>
26
+ )
27
+ }
28
+
29
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.13",
3
+ "version": "0.0.16",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "./gpu": "./src/gpu.ts",
12
12
  "./image": "./src/image.ts",
13
13
  "./microphone": "./src/microphone.ts",
14
+ "./scroll": "./src/scroll.ts",
14
15
  "./speech-recognition": "./src/speech-recognition.ts",
15
16
  "./text-input": "./src/text-input.ts",
16
17
  "./jsx-runtime": "./jsx-runtime.d.ts",
@@ -18,6 +19,7 @@
18
19
  },
19
20
  "files": [
20
21
  "src/",
22
+ "examples/",
21
23
  "jsx-runtime.d.ts",
22
24
  "AGENTS.md"
23
25
  ],
@@ -25,11 +27,11 @@
25
27
  "colord": "^2.9.3"
26
28
  },
27
29
  "devDependencies": {
28
- "@solidrt/flux-types": "0.0.13"
30
+ "@solidrt/flux-types": "0.0.0"
29
31
  },
30
32
  "peerDependencies": {
31
- "@solidjs/signals": "2.0.0-beta.14",
32
- "@solidjs/universal": "2.0.0-beta.14",
33
- "solid-js": "2.0.0-beta.14"
33
+ "@solidjs/signals": "2.0.0-beta.15",
34
+ "@solidjs/universal": "2.0.0-beta.15",
35
+ "solid-js": "2.0.0-beta.15"
34
36
  }
35
37
  }
package/src/gpu.ts CHANGED
@@ -6,6 +6,14 @@
6
6
  import { getOwner, onCleanup } from "@solidjs/signals"
7
7
  import * as gpu from "flux:gpu"
8
8
 
9
+ // Imperative companions to the reactive create* helpers, re-exported so callers
10
+ // that depend on @solidrt/core -- like @solidrt/components -- need not import flux
11
+ // directly: destroyTexture for the manual-cleanup path (textures made outside a
12
+ // reactive scope, e.g. after an await, are not auto-freed), setShaderParams to
13
+ // drive a shader's uniforms over time, and uploadTexture to push new pixels into
14
+ // a mutable texture.
15
+ export { destroyTexture, setShaderParams, uploadTexture } from "flux:gpu"
16
+
9
17
  /**
10
18
  * Uploads raw RGBA8 pixels to an immutable GPU texture and returns its id (use
11
19
  * it as `<texture src={id} />`). `data` must be exactly `width * height * 4`
package/src/image.ts CHANGED
@@ -1,7 +1,10 @@
1
- // CPU image codec: decode encoded image bytes into raw RGBA8 pixels (and, in
2
- // future, encode them back). Kept separate from the GPU/texture APIs because no
3
- // GPU is involved; pair decodeImage with createTexture from "@solidrt/core/gpu"
4
- // to upload the result.
1
+ // CPU image codec plus the reactive load-and-upload convenience. decodeImage is
2
+ // the raw primitive (no GPU involved); createImage is the owner-aware layer on
3
+ // top that fetches/decodes/uploads for you and swaps the texture when the source
4
+ // changes - the same relationship createTexture/createShader have to flux:gpu.
5
+
6
+ import { createMemo, onCleanup, NotReadyError } from "@solidjs/signals"
7
+ import { createTexture, destroyTexture } from "./gpu"
5
8
 
6
9
  export type DecodedImage = {
7
10
  data: Uint8Array
@@ -12,8 +15,51 @@ export type DecodedImage = {
12
15
  /**
13
16
  * Decodes encoded image bytes (PNG, JPEG, and the other formats the runtime's
14
17
  * image decoder supports) into raw, tightly-packed RGBA8 pixels plus the
15
- * decoded dimensions. Feed the result straight into `createTexture`.
18
+ * decoded dimensions. Feed the result straight into `createTexture`. Use this
19
+ * when you want manual control; for the common case reach for `createImage`.
16
20
  */
17
21
  export function decodeImage(bytes: Uint8Array): DecodedImage {
18
22
  return image.decodeImage(bytes)
23
+ }
24
+
25
+ export type ImageSource = string | Uint8Array
26
+
27
+ /**
28
+ * Loads an image as an async computation and returns a reactive accessor for its
29
+ * GPU texture id. This is a SolidJS 2.0 async value: reading it suspends until
30
+ * the image is ready, so read it inside a `<Loading>` boundary (a load failure
31
+ * surfaces to `<Errored>`). A string source is fetched; a Uint8Array is decoded
32
+ * directly. Pass an accessor instead of a value to make the source reactive -
33
+ * the image reloads and the old texture is freed whenever it changes; the
34
+ * current texture is freed when the owner is disposed. Display it with
35
+ * `<texture src={id()} />`; the texture carries its own pixel size, so no
36
+ * width/height is needed unless you want to scale it.
37
+ */
38
+ export function createImage(src: ImageSource | (() => ImageSource)): () => number {
39
+ let getSrc = typeof src === "function" ? src : () => src
40
+ let generation = 0
41
+
42
+ return createMemo<number>(async () => {
43
+ let source = getSrc()
44
+ let mine = ++generation
45
+
46
+ // Register cleanup synchronously, before the await: an onCleanup added after
47
+ // an await is orphaned because the reactive owner is not restored across it.
48
+ // The holder is filled in once the texture exists.
49
+ let holder = { id: -1 }
50
+ onCleanup(() => {
51
+ if (holder.id >= 0) destroyTexture(holder.id)
52
+ })
53
+
54
+ let bytes = typeof source === "string" ? await (await fetch(source)).bytes() : source
55
+
56
+ // If the source changed while we were loading, this run is superseded. Skip
57
+ // the GPU upload and stay pending: a texture created here would leak, since
58
+ // superseded async runs are not otherwise cleaned up. The newer run wins.
59
+ if (mine !== generation) throw new NotReadyError()
60
+
61
+ let { data, width, height } = decodeImage(bytes)
62
+ holder.id = createTexture(data, width, height)
63
+ return holder.id
64
+ })
19
65
  }
package/src/index.ts CHANGED
@@ -6,8 +6,8 @@ export type { Gradient, GradientStop } from "./color"
6
6
  export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur } from "./window"
7
7
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } from "./window"
8
8
  export { createTexture } from "./gpu"
9
- export { decodeImage } from "./image"
10
- export type { DecodedImage } from "./image"
9
+ export { createImage, decodeImage } from "./image"
10
+ export type { DecodedImage, ImageSource } from "./image"
11
11
  export type {
12
12
  LayoutProps,
13
13
  TransformProps,
@@ -29,3 +29,55 @@ export type {
29
29
  Color,
30
30
  } from "./types"
31
31
  export type { MeasureTextOptions } from "flux:rendertree"
32
+
33
+ // --- Authoring-surface re-exports -------------------------------------------
34
+ // A SolidRT app is built from three substrate packages: @solidjs/signals
35
+ // (reactivity), solid-js (control-flow components), and @solidjs/universal (the
36
+ // renderer factory, surfaced via ./renderer). Forwarding the app-facing pieces
37
+ // here means an app imports its whole vocabulary from "@solidrt/core" instead of
38
+ // having to know which substrate package each symbol lives in. These are already
39
+ // peerDependencies, so this adds no new dependency. Curated on purpose - do not
40
+ // `export *` from solid-js, which would leak DOM/hydration-only helpers that are
41
+ // meaningless on the flux runtime.
42
+
43
+ // Reactivity (from @solidjs/signals).
44
+ export {
45
+ createSignal,
46
+ createMemo,
47
+ createEffect,
48
+ createRenderEffect,
49
+ createRoot,
50
+ createStore,
51
+ reconcile,
52
+ mapArray,
53
+ repeat,
54
+ untrack,
55
+ flush,
56
+ onCleanup,
57
+ onSettled,
58
+ } from "@solidjs/signals"
59
+ export type { Accessor, Setter, Signal, Store, StoreSetter } from "@solidjs/signals"
60
+
61
+ // Control flow, components, and context (from solid-js).
62
+ export {
63
+ For,
64
+ Show,
65
+ Switch,
66
+ Match,
67
+ Repeat,
68
+ Loading,
69
+ Errored,
70
+ Reveal,
71
+ lazy,
72
+ createUniqueId,
73
+ createContext,
74
+ useContext,
75
+ children,
76
+ } from "solid-js"
77
+ export type {
78
+ Component,
79
+ ParentComponent,
80
+ FlowComponent,
81
+ VoidComponent,
82
+ ComponentProps,
83
+ } from "solid-js"
package/src/renderer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createRoot } from "@solidjs/signals"
1
+ import { createRoot, onCleanup } from "@solidjs/signals"
2
2
  import { createRenderer } from "@solidjs/universal"
3
3
  import * as tree from "flux:rendertree"
4
4
  import { attachWindow } from "./window"
@@ -30,6 +30,34 @@ function createProxyNode(elementType: ElementType): ProxyNode {
30
30
  return node
31
31
  }
32
32
 
33
+ // Detaches `node` from `parent` and destroys it (and all descendants) on both
34
+ // the JS and native sides. Hoisted out of the renderer config so createPortal
35
+ // can reuse it (createRenderer does not return its removeNode hook).
36
+ function removeNode(parent: ProxyNode, node: ProxyNode): void {
37
+ if (!node || !parent) return
38
+
39
+ // console.debug("[srt] removeNode", parent.id, node.id)
40
+
41
+ // Update JS tree references
42
+ let index = parent.children.indexOf(node)
43
+ if (index !== -1) {
44
+ parent.children.splice(index, 1)
45
+ }
46
+ node.parent = undefined
47
+
48
+ tree.deleteNode(parent.id, node.id)
49
+
50
+ // Recursively clean up node and all descendants. Clear focus before
51
+ // dropping handlers so onBlur still fires for a focused descendant.
52
+ let cleanup = (n: ProxyNode) => {
53
+ for (let child of n.children) cleanup(child)
54
+ if (n.id === getFocusedNodeId()) setFocus(null)
55
+ nodes.delete(n.id)
56
+ cleanupNodeHandlers(n.id)
57
+ }
58
+ cleanup(node)
59
+ }
60
+
33
61
  export let {
34
62
  effect,
35
63
  memo,
@@ -116,30 +144,7 @@ export let {
116
144
  }
117
145
  },
118
146
 
119
- removeNode: (parent: ProxyNode, node: ProxyNode): void => {
120
- if (!node || !parent) return
121
-
122
- // console.debug("[srt] removeNode", parent.id, node.id)
123
-
124
- // Update JS tree references
125
- let index = parent.children.indexOf(node)
126
- if (index !== -1) {
127
- parent.children.splice(index, 1)
128
- }
129
- node.parent = undefined
130
-
131
- tree.deleteNode(parent.id, node.id)
132
-
133
- // Recursively clean up node and all descendants. Clear focus before
134
- // dropping handlers so onBlur still fires for a focused descendant.
135
- let cleanup = (n: ProxyNode) => {
136
- for (let child of n.children) cleanup(child)
137
- if (n.id === getFocusedNodeId()) setFocus(null)
138
- nodes.delete(n.id)
139
- cleanupNodeHandlers(n.id)
140
- }
141
- cleanup(node)
142
- },
147
+ removeNode,
143
148
 
144
149
  getParentNode: (node: ProxyNode) => node?.parent,
145
150
  getFirstChild: (node: ProxyNode) => node?.children[0],
@@ -152,6 +157,10 @@ export let {
152
157
  },
153
158
  })
154
159
 
160
+ // The app's single <window> node, set by render(). Serves as the default mount
161
+ // target for createPortal (single window by design, so one ambient ref).
162
+ let windowRoot: ProxyNode | undefined
163
+
155
164
  /**
156
165
  * Mounts a SolidRT app. Call once at the top level: `render(() => <App />)`.
157
166
  * The element returned by `code` MUST be a `<window>` (it becomes the native
@@ -164,7 +173,32 @@ export function render(code: () => any) {
164
173
  if (!root || root.elementType !== "window") {
165
174
  throw new Error("render() root must be a <window> element")
166
175
  }
176
+ windowRoot = root
167
177
  attachWindow(root.id)
168
178
  insert(null, root)
169
179
  })
170
180
  }
181
+
182
+ /**
183
+ * Relocates an already-built node out of its lexical position to `mount` (the
184
+ * window root by default), then removes it again when the surrounding reactive
185
+ * scope disposes. The low-level portal primitive: it moves a single node and
186
+ * nothing more. Conveniences (an overlay layer, centering, a backdrop) belong
187
+ * in higher packages built on top of it.
188
+ *
189
+ * `node` is a concrete node, not an accessor: its own children (including any
190
+ * reactive content) are already wired by the JSX that built it and keep working
191
+ * wherever it is mounted. We only move the root.
192
+ *
193
+ * The default mount is the window's flex root, so a portaled node that is not
194
+ * `position: "absolute"` will take flow space and displace app content. Position
195
+ * the portal root absolutely, or pass a `mount` target that does it for you.
196
+ */
197
+ export function createPortal(node: ProxyNode, mount?: ProxyNode): void {
198
+ let target = mount ?? windowRoot
199
+ if (!target) {
200
+ throw new Error("createPortal: no mount target (called before render()?)")
201
+ }
202
+ insertNode(target, node)
203
+ onCleanup(() => removeNode(target, node))
204
+ }
package/src/scroll.ts ADDED
@@ -0,0 +1,93 @@
1
+ // Headless scroll mechanism. This primitive owns the objective part of a
2
+ // scrollable region -- the offset and its clamping against the measured content
3
+ // and viewport sizes -- and nothing with a UI opinion. Wheel/drag input,
4
+ // momentum, scrollbars and styling are policy and belong to the component (the
5
+ // "skin") that composes this, the same way createCaretScroll backs TextInput.
6
+
7
+ import { createSignal, flush } from "@solidjs/signals"
8
+ import { getBoundingBox } from "./core"
9
+ import { onLayout } from "./window"
10
+
11
+ export type ScrollAxis = "vertical" | "horizontal" | "both"
12
+
13
+ export type ScrollOffset = { x: number; y: number }
14
+
15
+ export type ScrollOptions = {
16
+ /** Which axes can scroll. Locked axes are pinned to 0. Default "vertical". */
17
+ axis?: ScrollAxis
18
+ }
19
+
20
+ export type Scroll = {
21
+ /** Current clamped offset, as a reactive accessor. */
22
+ offset(): ScrollOffset
23
+ /** Scroll by a delta (positive moves content up/left), clamped to range. */
24
+ scrollBy(dx: number, dy: number): void
25
+ /** Scroll to an absolute offset, clamped to range. */
26
+ scrollTo(x: number, y: number): void
27
+ }
28
+
29
+ /**
30
+ * Returns the scroll offset for a viewport node given its content node. The
31
+ * offset is retained between frames and re-clamped in onLayout against the
32
+ * current content-vs-viewport overflow, so the view stays valid when content
33
+ * grows or shrinks (e.g. an offset that scrolled to the bottom snaps up when the
34
+ * list gets shorter). scrollBy/scrollTo clamp against the most recently measured
35
+ * range. Pure geometry: no input handling and no visual policy.
36
+ *
37
+ * The viewport node is the clipping box (overflow hidden); the content node is
38
+ * the inner wrapper that holds the children and takes their natural size. Apply
39
+ * the returned offset to the viewport's scrollX/scrollY.
40
+ */
41
+ export function createScroll(
42
+ viewport: () => { id: number } | undefined,
43
+ content: () => { id: number } | undefined,
44
+ options: ScrollOptions = {},
45
+ ): Scroll {
46
+ let axis = options.axis ?? "vertical"
47
+ let canX = axis === "horizontal" || axis === "both"
48
+ let canY = axis === "vertical" || axis === "both"
49
+
50
+ let [offset, setOffset] = createSignal<ScrollOffset>({ x: 0, y: 0 })
51
+
52
+ // Last measured overflow, refreshed each layout. scrollBy/scrollTo clamp
53
+ // against these between layouts; onLayout re-clamps once new sizes are known.
54
+ let maxX = 0
55
+ let maxY = 0
56
+
57
+ let clamp = (x: number, y: number): ScrollOffset => ({
58
+ x: canX ? Math.max(0, Math.min(x, maxX)) : 0,
59
+ y: canY ? Math.max(0, Math.min(y, maxY)) : 0,
60
+ })
61
+
62
+ let set = (x: number, y: number) => {
63
+ let cur = offset()
64
+ let next = clamp(x, y)
65
+ if (next.x !== cur.x || next.y !== cur.y) setOffset(next)
66
+ }
67
+
68
+ onLayout(() => {
69
+ let vp = viewport()
70
+ let ct = content()
71
+ if (!vp || !ct) return
72
+ let vb = getBoundingBox(vp)
73
+ let cb = getBoundingBox(ct)
74
+ if (!vb || !cb) return
75
+ maxX = Math.max(0, cb.width - vb.width)
76
+ maxY = Math.max(0, cb.height - vb.height)
77
+ let cur = offset()
78
+ let next = clamp(cur.x, cur.y)
79
+ if (next.x !== cur.x || next.y !== cur.y) {
80
+ setOffset(next)
81
+ flush()
82
+ }
83
+ })
84
+
85
+ return {
86
+ offset,
87
+ scrollBy: (dx, dy) => {
88
+ let cur = offset()
89
+ set(cur.x + dx, cur.y + dy)
90
+ },
91
+ scrollTo: (x, y) => set(x, y),
92
+ }
93
+ }
package/src/types.d.ts CHANGED
@@ -153,14 +153,22 @@ export interface TransformProps {
153
153
  scrollY?: number
154
154
  }
155
155
 
156
+ // Window-relative pointer coordinates are reported as clientX/clientY (matching
157
+ // the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
158
+ // pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
156
159
  export interface PointerEvent {
157
- x: number
158
- y: number
160
+ clientX: number
161
+ clientY: number
162
+ pointerId: number
163
+ pointerType: "mouse" | "touch" | "pen" | (string & {})
164
+ button?: number
165
+ shiftKey: boolean
166
+ ctrlKey: boolean
167
+ altKey: boolean
168
+ metaKey: boolean
159
169
  }
160
170
 
161
- export interface WheelEvent {
162
- x: number
163
- y: number
171
+ export interface WheelEvent extends PointerEvent {
164
172
  deltaX: number
165
173
  deltaY: number
166
174
  }