@solidrt/core 0.0.22 → 0.0.24

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
@@ -7,6 +7,61 @@ trust this file and the types in src/types.d.ts and jsx-runtime.d.ts.
7
7
  SolidRT is a custom SolidJS renderer: it paints through a Rust runtime, not the
8
8
  DOM. There is no HTML, no CSS cascade, no `className`.
9
9
 
10
+ ## The window is device-sized - design fluid
11
+
12
+ A SolidRT window is host-sized and resizable, and the SAME app runs on phones,
13
+ tablets, and desktops. There is no default "desktop" size to design against.
14
+ Design fluid by default: derive sizing and layout from the live window, do not
15
+ hardcode desktop pixels.
16
+
17
+ - Size the layout from `windowSize()` / `capabilities.windowSizeClass`, not from
18
+ fixed pixel widths. Let flex (`flex`, `flexWrap`, `gap`) and percentages carry
19
+ the layout so it reflows on resize instead of clipping or leaving dead space.
20
+ - Gate hover-only affordances on `capabilities.hover`. Hover-zoom, hover-reveal,
21
+ and tooltips are dead on a touch device with no pointer that can rest. When
22
+ `capabilities.touch`, provide a tap/press path to the same action.
23
+ - `windowSizeClass` uses Material 3 width breakpoints (logical px): `compact`
24
+ (<600), `medium` (600-840), `expanded` (>=840). Drive column counts / layout
25
+ switches off it (see the responsive-grid example).
26
+
27
+ `env` and `capabilities` (both exported from `@solidrt/core`) are the two
28
+ objects that expose this. They are plain objects with REACTIVE GETTERS, not
29
+ functions - read them as `capabilities.windowSizeClass`, `env.displayScale`
30
+ (NOT `capabilities()`); the getter reads reactive state underneath, so a read
31
+ inside JSX / a memo / an effect re-runs when it changes. (`windowSize()` and
32
+ `safeArea()` from `./window` ARE functions - call those.)
33
+
34
+ ```ts
35
+ import { env, capabilities } from "@solidrt/core"
36
+
37
+ env.windowSize // { width, height } (same value as windowSize())
38
+ env.displayScale // device pixel ratio (hi-DPI factor)
39
+ env.safeArea // inset distances from each edge
40
+ env.systemTheme // "dark" | "light" | "unknown" (resolves after startup)
41
+ env.orientation // "portrait" | "landscape" | ... | "unknown"
42
+
43
+ capabilities.windowSizeClass // "compact" | "medium" | "expanded"
44
+ capabilities.hover // a pointer can rest over content (mouse/trackpad)
45
+ capabilities.touch // direct touch input present
46
+ capabilities.precisePointer // pixel-precise pointing (mouse/trackpad)
47
+ capabilities.keyboardNav // hardware-key navigation available
48
+ ```
49
+
50
+ Read behavior decisions through `capabilities`; read `env` directly only when
51
+ you need the raw fact (e.g. `env.displayScale` for asset sizing below).
52
+
53
+ ### Vectors vs raster, and hi-DPI
54
+
55
+ Because the drawn size is fluid and the display DPI varies, asset format is a
56
+ real design decision, not an afterthought:
57
+
58
+ - Prefer VECTORS (`<svg>`, `<d-path>`) whenever the render size is fluid or DPI
59
+ varies - they stay crisp at any size x `displayScale()`.
60
+ - RASTER (`<texture>` / `createImage`) needs source resolution >= displayed size
61
+ x `env.displayScale`, or it softens on hi-DPI. Author raster at 2-3x the
62
+ largest size you will ever draw it. A 256px PNG blown up large on a hi-DPI
63
+ tablet will look soft; the same art as SVG will not.
64
+
10
65
  ## Setup
11
66
 
12
67
  ```sh
@@ -16,18 +16,26 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
16
16
  - `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
17
17
  - `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
18
18
 
19
+ ## Performance
20
+ - `repaint-boundary.tsx` - `repaintBoundary` on a `<view>` to keep static content from rebuilding while a neighbor animates: `{true}` retains the recorded draw list, `"snapshot"` also retains the rasterized pixels as a GPU texture (for raster-expensive, screen-aligned, static subtrees).
21
+
22
+ ## Scrolling
23
+ - `scroll.tsx` - `createScroll`, the headless scroll primitive: it owns only the clamped offset (re-clamped on layout); you supply the viewport/content nodes via refs, apply the offset to `scrollX`/`scrollY`, and wire input (e.g. `onWheel`) to `scrollBy` yourself.
24
+
19
25
  ## Window state
20
26
  - `window-signals.tsx` - reactive `windowSize()` / `safeArea()` accessors (prefer over `onResize`).
27
+ - `responsive-grid.tsx` - one app across phone/tablet/desktop: `capabilities.windowSizeClass` (Material 3 breakpoints, a reactive getter) drives the column count and `windowSize()` sizes each card; reflows on resize.
21
28
 
22
29
  ## Overlays
23
30
  - `portal.tsx` - `createPortal` relocating content to the window root to escape clipping.
24
31
 
25
32
  ## Images and GPU
26
33
  - `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`.
34
+ - `inline-image.tsx` - bytes already in memory: `decodeImage` + `createTexture` (both synchronous) show an image with no `<Loading>` boundary. The sync counterpart to `image.tsx`.
35
+ - `gpu-shader.tsx` - a GLSL fragment shader rendered to a texture, animated by driving its `iTime` uniform declaratively through the `<texture params={{...}}>` prop.
28
36
 
29
37
  ## Vector graphics
30
38
  - `svg.tsx` - `<svg src={...}>` draws a whole SVG *document string* (not HTML/JSX children); multi-color fills vs a `currentColor` icon recolored by the `color` prop. This is how to use existing icon libraries (Lucide, Heroicons, etc.) - hand their SVG source to `src`.
31
39
 
32
40
  ## Bundling assets
33
- - `binary-import.tsx` - `import bytes from "./file" with { type: "binary" }` inlines a file's bytes into the bundle as a `Uint8Array` (combine with `image.tsx` to display an inlined image). `with { type: "text" }` works the same way for a string.
41
+ - `binary-import.tsx` - `import bytes from "./file" with { type: "binary" }` inlines a file's bytes into the bundle as a `Uint8Array` (the bytes are in memory, so `inline-image.tsx` displays them with the synchronous `decodeImage` + `createTexture` path). `with { type: "text" }` works the same way for a string.
@@ -6,8 +6,10 @@
6
6
  //
7
7
  // This example shows only the binary import itself: it reports the imported
8
8
  // file's length and leading bytes. What you do with the bytes afterwards is a
9
- // separate concern - e.g. hand them to createImage to decode and display an
10
- // image (see image.tsx), or to decodeImage/createTexture for manual control.
9
+ // separate concern. Since inlined bytes are already in memory, the synchronous
10
+ // path fits: decodeImage(bytes) + createTexture (no fetch, no <Loading>) - see
11
+ // inline-image.tsx. Reach for createImage instead when the source is a string
12
+ // URL loaded at runtime, or when you want the source to swap reactively.
11
13
  import { render } from "@solidrt/core"
12
14
  import bytes from "./logo.png" with { type: "binary" }
13
15
 
@@ -6,10 +6,11 @@
6
6
  // disposed.
7
7
  //
8
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"
9
+ // uniform) declaratively via the <texture> element's params prop; it applies at
10
+ // the next repaint, so a signal updated every frame stays paced to actual frames.
11
+ // The shader's size is baked in at creation.
12
+ import { render, onFrame, createSignal } from "@solidrt/core"
13
+ import { createShader } from "@solidrt/core/gpu"
13
14
 
14
15
  let FRAGMENT = `
15
16
  void main() {
@@ -24,11 +25,12 @@ void main() {
24
25
 
25
26
  function App() {
26
27
  let id = createShader(FRAGMENT, 512, 512, { iTime: 0 })
27
- onFrame((tick) => setShaderParams(id, { iTime: tick / 1000 }))
28
+ let [time, setTime] = createSignal(0)
29
+ onFrame((tick) => setTime(tick / 1000))
28
30
 
29
31
  return (
30
32
  <window alignItems="center" justifyContent="center">
31
- <texture src={id} width={400} height={400} />
33
+ <texture src={id} params={{ iTime: time() }} width={400} height={400} />
32
34
  </window>
33
35
  )
34
36
  }
@@ -8,8 +8,19 @@
8
8
  // (this is the 2.0 async mechanic, not a manual undefined-signal + <Show>); a
9
9
  // load failure surfaces to <Errored>. Pass an accessor (createImage(() => src()))
10
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.
11
+ //
12
+ // The rule: createImage always suspends (needs <Loading>), because it is an
13
+ // async value. For bytes you already hold - a `with { type: "binary" }` import
14
+ // or anything in memory - skip it: decodeImage(bytes) + createTexture (both
15
+ // synchronous) build the texture with no boundary at all. See inline-image.tsx.
16
+ // createImage earns its async only when it fetches a string URL or swaps source
17
+ // reactively; decodeImage + createTexture are the primitives underneath.
18
+ //
19
+ // Raster vs vector: a texture has a fixed source resolution, so it softens when
20
+ // drawn larger than (displayed size x env.displayScale) on a hi-DPI display.
21
+ // Author raster at 2-3x the largest size you will draw it. When the render size
22
+ // is fluid or DPI varies, prefer a vector (svg.tsx / <d-path>) instead - it
23
+ // stays crisp at any size.
13
24
  import { render, createImage, Loading } from "@solidrt/core"
14
25
 
15
26
  function App() {
@@ -0,0 +1,27 @@
1
+ // Displaying an image whose bytes are already in memory - here a
2
+ // `with { type: "binary" }` import that inlines the file into the bundle. Because
3
+ // the bytes are on hand, the whole path is synchronous: decodeImage(bytes) turns
4
+ // the encoded PNG into raw RGBA8 pixels, and createTexture uploads them to the
5
+ // GPU and returns an id. No fetch, no async value, so no <Loading> boundary - the
6
+ // texture is ready in the same tick the component builds.
7
+ //
8
+ // Contrast image.tsx, which uses createImage for a string URL: that fetches at
9
+ // runtime, so it suspends and must be read inside <Loading>. The rule: createImage
10
+ // (async) for URLs or reactive sources; decodeImage + createTexture (sync) for
11
+ // bytes you already hold. Called here in the component body, the texture is freed
12
+ // automatically when the owner is disposed.
13
+ import { render, decodeImage, createTexture } from "@solidrt/core"
14
+ import bytes from "./logo.png" with { type: "binary" }
15
+
16
+ function App() {
17
+ let { data, width, height } = decodeImage(bytes)
18
+ let id = createTexture(data, width, height)
19
+
20
+ return (
21
+ <window alignItems="center" justifyContent="center">
22
+ <texture src={id} />
23
+ </window>
24
+ )
25
+ }
26
+
27
+ render(() => <App />)
@@ -0,0 +1,56 @@
1
+ // repaintBoundary marks a <view> subtree as its own retained cache, so nearby
2
+ // frequently changing content does not force it to rebuild every frame. It has
3
+ // two modes - both shown here, next to a square that spins every frame:
4
+ //
5
+ // - repaintBoundary={true} retains the recorded DRAW LIST: the subtree is
6
+ // recorded once and the commands are replayed until something inside changes.
7
+ // Skips re-recording. The default choice for static content next to animation.
8
+ //
9
+ // - repaintBoundary="snapshot" additionally retains the RASTERIZED PIXELS as a
10
+ // GPU texture, so replay skips rasterization too. Worth the texture memory
11
+ // only for raster-expensive static content (many glyphs, blurs, dense vector).
12
+ // It re-rasters on layout-size / display-scale changes, crops anything painted
13
+ // outside the layout box, and an ancestor scale animation smears the bitmap -
14
+ // so reach for it only for screen-aligned, static, raster-heavy subtrees.
15
+ //
16
+ // Rule of thumb: start with {true}; upgrade to "snapshot" only when the cached
17
+ // content is expensive to rasterize and stays screen-aligned and static.
18
+ import { render, onFrame, createSignal, For } from "@solidrt/core"
19
+
20
+ // Raster-expensive static content: a dense grid of rects. Cheap to replay from a
21
+ // draw list, but re-rasterizing it every frame would be wasteful - the case
22
+ // "snapshot" is built for.
23
+ function Grid(props: { boundary: true | "snapshot" }) {
24
+ let cells = Array.from({ length: 64 }, (_, i) => i)
25
+ return (
26
+ <view
27
+ repaintBoundary={props.boundary}
28
+ width={200}
29
+ height={200}
30
+ flexDirection="row"
31
+ flexWrap="wrap"
32
+ gap={3}
33
+ >
34
+ <For each={cells}>
35
+ {(i) => <rect width={22} height={22} radius={3} color={i % 2 ? "#2a3f5f" : "#3366b3"} />}
36
+ </For>
37
+ </view>
38
+ )
39
+ }
40
+
41
+ function App() {
42
+ let [angle, setAngle] = createSignal(0)
43
+ onFrame((tick) => setAngle(tick / 1000))
44
+
45
+ return (
46
+ <window flexDirection="row" alignItems="center" justifyContent="center" gap={32}>
47
+ <Grid boundary={true} />
48
+ <Grid boundary="snapshot" />
49
+ <view width={120} height={120} rotate={angle()}>
50
+ <rect width={120} height={120} radius={16} color="#e0245e" />
51
+ </view>
52
+ </window>
53
+ )
54
+ }
55
+
56
+ render(() => <App />)
@@ -0,0 +1,42 @@
1
+ // Responsive layout: the SAME app on a phone, tablet, or desktop. The window is
2
+ // host-sized and resizable, so drive the layout from the live window instead of
3
+ // hardcoding pixels. Here the column count comes from
4
+ // `capabilities.windowSizeClass` (Material 3 width breakpoints: compact <600,
5
+ // medium 600-840, expanded >=840). Resize the window and the grid reflows.
6
+ //
7
+ // `capabilities` and `env` are plain objects with reactive GETTERS, not
8
+ // functions - read `capabilities.windowSizeClass` (no call). Reading it inside
9
+ // JSX tracks, so the memo below re-runs on every resize. `windowSize()` IS a
10
+ // function (call it) - we read its width to size each card exactly.
11
+ import { render, capabilities, windowSize, createMemo, For } from "@solidrt/core"
12
+
13
+ const GAP = 16
14
+ const PAD = 24
15
+ const COLORS = ["#1f6feb", "#3fb950", "#db6d28", "#a371f7", "#e3b341", "#f778ba", "#2dd4bf", "#f85149"]
16
+
17
+ function App() {
18
+ // Column count from the size class; card width derived so N fit per row.
19
+ let cols = createMemo(() => (capabilities.windowSizeClass === "expanded" ? 3 : capabilities.windowSizeClass === "medium" ? 2 : 1))
20
+ let cardWidth = createMemo(() => (windowSize().width - PAD * 2 - GAP * (cols() - 1)) / cols())
21
+
22
+ return (
23
+ <window>
24
+ <d-rect color="#0b0f17" />
25
+ <view flex={1} flexDirection="column" gap={GAP} padding={PAD}>
26
+ <text color="#e6e6e6" fontSize={18}>{capabilities.windowSizeClass} - {cols()} column{cols() === 1 ? "" : "s"}</text>
27
+ <view flexDirection="row" flexWrap="wrap" gap={GAP}>
28
+ <For each={COLORS}>
29
+ {(c) => (
30
+ <view width={cardWidth()} height={96} alignItems="center" justifyContent="center">
31
+ <d-rect color={c} radius={12} />
32
+ <text color="#0b0f17" fontSize={16} fontWeight={700}>card</text>
33
+ </view>
34
+ )}
35
+ </For>
36
+ </view>
37
+ </view>
38
+ </window>
39
+ )
40
+ }
41
+
42
+ render(() => <App />)
@@ -0,0 +1,56 @@
1
+ // createScroll is a HEADLESS scroll primitive: it owns only the geometry - the
2
+ // clamped offset, re-clamped in onLayout against the measured content-vs-viewport
3
+ // overflow - and nothing with a UI opinion. Input handling, momentum, and
4
+ // scrollbars are policy you supply. (@solidrt/components ScrollView is one such
5
+ // skin built on top of this.)
6
+ //
7
+ // The shape it expects:
8
+ // - a VIEWPORT node: the clipping box, overflow="hidden".
9
+ // - a CONTENT node inside it: an inner wrapper that takes the children's natural
10
+ // size (flexShrink={0} so it can exceed the viewport).
11
+ // Capture both with refs, pass their accessors to createScroll, then apply the
12
+ // returned offset to the viewport's scrollX/scrollY. createScroll does no input,
13
+ // so wire an event (here onWheel) to scroll.scrollBy - positive dy moves content
14
+ // up. scrollTo(x, y) jumps to an absolute, clamped offset.
15
+ import { render, For, createScroll } from "@solidrt/core"
16
+ import type { WheelEvent } from "@solidrt/core"
17
+
18
+ function App() {
19
+ let viewport: { id: number } | undefined
20
+ let content: { id: number } | undefined
21
+
22
+ // Default axis is "vertical"; pass { axis: "horizontal" } or "both" for others.
23
+ let scroll = createScroll(() => viewport, () => content)
24
+
25
+ let onWheel = (e: WheelEvent) => scroll.scrollBy(e.deltaX, e.deltaY)
26
+
27
+ let rows = Array.from({ length: 30 }, (_, i) => i)
28
+
29
+ return (
30
+ <window alignItems="center" justifyContent="center">
31
+ <view
32
+ ref={(n: { id: number }) => (viewport = n)}
33
+ width={220}
34
+ height={280}
35
+ overflow="hidden"
36
+ clipRadius={12}
37
+ scrollY={scroll.offset().y}
38
+ onWheel={onWheel}
39
+ >
40
+ <d-rect color="#1a2233" radius={12} />
41
+ <view ref={(n: { id: number }) => (content = n)} flexShrink={0} padding={12} gap={8}>
42
+ <For each={rows}>
43
+ {(i) => (
44
+ <view height={40} justifyContent="center" paddingLeft={12}>
45
+ <rect height={40} radius={6} color={i % 2 ? "#2a3f5f" : "#3366b3"} />
46
+ <text color="#ffffff">Row {i}</text>
47
+ </view>
48
+ )}
49
+ </For>
50
+ </view>
51
+ </view>
52
+ </window>
53
+ )
54
+ }
55
+
56
+ render(() => <App />)
package/examples/svg.tsx CHANGED
@@ -10,6 +10,10 @@
10
10
  // "currentColor" is recolored by the host `color` prop. For per-shape authored
11
11
  // or animated vector art, compose <d-path> instead of this document layer.
12
12
  //
13
+ // Being a vector, an <svg> is resolution-independent: it stays crisp at any
14
+ // drawn size x displayScale(). Prefer it over a raster <texture> (image.tsx)
15
+ // whenever the render size is fluid or the display DPI varies.
16
+ //
13
17
  // This is how you use an existing icon library (Lucide, Heroicons, Feather,
14
18
  // Material, etc.): those ship SVG source, so import/inline the icon string and
15
19
  // hand it to `src`. The `currentColor` convention they follow means the `color`
package/jsx-runtime.d.ts CHANGED
@@ -7,7 +7,6 @@ import type {
7
7
  ViewProps,
8
8
  TextProps,
9
9
  TextureProps,
10
- AudioProps,
11
10
  LayoutProps,
12
11
  Element as CoreElement,
13
12
  ElementChildrenAttribute as CoreElementChildrenAttribute
@@ -23,26 +22,29 @@ export namespace JSX {
23
22
  type RefCallback<T> = (el: T) => unknown
24
23
  type Ref<T> = T | RefCallback<T>
25
24
 
26
- interface IntrinsicAttributes {
25
+ // ref lives on the element prop types (intersected into every entry below)
26
+ // rather than in IntrinsicAttributes: under our config, declaring it only in
27
+ // IntrinsicAttributes did not make it reach intrinsic elements, so `ref` on a
28
+ // host element was reported as an excess property.
29
+ interface ElementRef {
27
30
  ref?: Ref<{ id: number }> | undefined
28
31
  }
29
32
 
30
33
  interface IntrinsicElements {
31
- window: WindowProps
32
- view: ViewProps
33
- text: TextProps & LayoutProps
34
- rect: RectProps & LayoutProps
35
- oval: OvalProps & LayoutProps
36
- path: PathProps & LayoutProps
37
- svg: SvgProps & LayoutProps
38
- texture: TextureProps & LayoutProps
39
- audio: AudioProps
40
- "d-view": ViewProps
41
- "d-rect": RectProps
42
- "d-oval": OvalProps
43
- "d-path": PathProps
44
- "d-svg": SvgProps
45
- "d-texture": TextureProps
46
- "d-text": TextProps
34
+ window: WindowProps & ElementRef
35
+ view: ViewProps & ElementRef
36
+ text: TextProps & LayoutProps & ElementRef
37
+ rect: RectProps & LayoutProps & ElementRef
38
+ oval: OvalProps & LayoutProps & ElementRef
39
+ path: PathProps & LayoutProps & ElementRef
40
+ svg: SvgProps & LayoutProps & ElementRef
41
+ texture: TextureProps & LayoutProps & ElementRef
42
+ "d-view": ViewProps & ElementRef
43
+ "d-rect": RectProps & ElementRef
44
+ "d-oval": OvalProps & ElementRef
45
+ "d-path": PathProps & ElementRef
46
+ "d-svg": SvgProps & ElementRef
47
+ "d-texture": TextureProps & ElementRef
48
+ "d-text": TextProps & ElementRef
47
49
  }
48
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -11,7 +11,6 @@
11
11
  "./gpu": "./src/gpu.ts",
12
12
  "./image": "./src/image.ts",
13
13
  "./microphone": "./src/microphone.ts",
14
- "./scroll": "./src/scroll.ts",
15
14
  "./speech-recognition": "./src/speech-recognition.ts",
16
15
  "./text-input": "./src/text-input.ts",
17
16
  "./jsx-runtime": "./jsx-runtime.d.ts",
@@ -27,7 +26,7 @@
27
26
  "colord": "^2.9.3"
28
27
  },
29
28
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.22"
29
+ "@solidrt/flux-types": "0.0.24"
31
30
  },
32
31
  "peerDependencies": {
33
32
  "@solidjs/signals": "2.0.0-beta.15",
package/src/color.ts CHANGED
@@ -43,6 +43,11 @@ type Stop = { offset: number; color: number }
43
43
  // element's box), so one gradient can be reused on elements of any size. Branded
44
44
  // so the renderer can tell it from a solid color string. The object crosses to
45
45
  // the runtime as-is and is decoded by key (see properties/paint.rs).
46
+ //
47
+ // These 0..1 coords are deliberately their own normalized space, NOT the pixel/
48
+ // `pct()` length vocabulary used by layout and transformOrigin: a gradient's
49
+ // position is naturally a fraction (like a stop offset), so 0..1 reads cleaner
50
+ // than pct(0)..pct(100). Do not "unify" them onto pct().
46
51
  export type Gradient =
47
52
  | { readonly __gradient: "linear"; x0: number; y0: number; x1: number; y1: number; stops: Stop[] }
48
53
  | { readonly __gradient: "radial"; cx: number; cy: number; r: number; circle: boolean; stops: Stop[] }
@@ -10,6 +10,14 @@ import { windowSize, safeArea, displayScale, windowFocused, keyboardHeight } fro
10
10
  // been seen this session. Seen-flags only ever go from false to true, so a
11
11
  // capability derived from them can appear mid-session (e.g. the first mouse
12
12
  // move) but never flickers away.
13
+ //
14
+ // `ownedWrite: true` on the signals below: each is lazily created the first
15
+ // time its ensure* function runs, which can happen inside a tracked scope
16
+ // (e.g. a memo's first read of env.inputDevices). Sticky events replay their
17
+ // cached value synchronously on subscribe (srt:events' on()), so that same
18
+ // call can immediately write the signal it just created. That's a legitimate
19
+ // internal-state write, not a stray write escaping a computation, so it opts
20
+ // out of the write-in-owned-scope guard.
13
21
 
14
22
  /** Connected input device classes, as reported by the runtime. */
15
23
  export interface InputDevices {
@@ -26,7 +34,7 @@ let devicesAccessor: (() => InputDevices | undefined) | undefined
26
34
 
27
35
  function ensureDevicesState() {
28
36
  if (devicesAccessor) return
29
- let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined)
37
+ let [devices, setDevices] = createSignal<InputDevices | undefined>(undefined, { ownedWrite: true })
30
38
  // Sticky: the current state replays on subscribe, so the first read already
31
39
  // sees it on runtimes that report devices.
32
40
  on("inputDevices", (d: InputDevices) => {
@@ -39,7 +47,7 @@ let systemThemeAccessor: (() => SystemTheme) | undefined
39
47
 
40
48
  function ensureSystemThemeState() {
41
49
  if (systemThemeAccessor) return
42
- let [theme, setTheme] = createSignal<SystemTheme>("unknown")
50
+ let [theme, setTheme] = createSignal<SystemTheme>("unknown", { ownedWrite: true })
43
51
  on("systemTheme", (e: { theme?: SystemTheme }) => setTheme(e.theme ?? "unknown"))
44
52
  systemThemeAccessor = theme
45
53
  }
@@ -48,7 +56,7 @@ let orientationAccessor: (() => Orientation) | undefined
48
56
 
49
57
  function ensureOrientationState() {
50
58
  if (orientationAccessor) return
51
- let [orientation, setOrientation] = createSignal<Orientation>("unknown")
59
+ let [orientation, setOrientation] = createSignal<Orientation>("unknown", { ownedWrite: true })
52
60
  on("displayOrientation", (e: { orientation?: Orientation }) => {
53
61
  setOrientation(e.orientation ?? "unknown")
54
62
  })
@@ -59,7 +67,7 @@ let textScaleAccessor: (() => number) | undefined
59
67
 
60
68
  function ensureTextScaleState() {
61
69
  if (textScaleAccessor) return
62
- let [scale, setScale] = createSignal(1)
70
+ let [scale, setScale] = createSignal(1, { ownedWrite: true })
63
71
  // Sticky, like systemTheme. Guard nonsense values: a runtime bug reporting
64
72
  // 0 or a negative would otherwise collapse all text.
65
73
  on("textScale", (e: { scale?: number }) => {
package/src/gpu.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  // GPU textures and shaders, reactive (SolidJS) layer: the create* helpers free
2
- // their texture automatically when the reactive owner is disposed. The imperative
3
- // primitive lives in the `flux:gpu` module; import { uploadTexture,
4
- // setShaderParams, destroyTexture, ... } from "flux:gpu" for non-reactive use.
2
+ // their texture automatically when the reactive owner is disposed. Drive a
3
+ // shader's uniforms declaratively with `<texture src={id} params={{...}} />`
4
+ // (see TextureProps) - the preferred way, deferred to the next real repaint so
5
+ // a fast-changing signal stays paced to actual frames. setShaderParams is the
6
+ // imperative exception: reach for it only when there is no `<texture>` element
7
+ // to hold a params prop, e.g. a shader that only feeds another shader as a
8
+ // sampler2D input. The imperative primitives (uploadTexture, setShaderParams,
9
+ // destroyTexture, ...) live in the `flux:gpu` module.
5
10
 
6
11
  import { getOwner, onCleanup } from "@solidjs/signals"
7
12
  import * as gpu from "flux:gpu"
8
13
 
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.
14
+ // Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
15
+ // -- need not import flux directly: destroyTexture for the manual-cleanup path
16
+ // (textures made outside a reactive scope, e.g. after an await, are not
17
+ // auto-freed), uploadTexture to push new pixels into a mutable texture, and
18
+ // setShaderParams as the non-reactive exception described above - prefer
19
+ // `<texture params={...}>` when a `<texture>` element is already in the tree.
15
20
  export { destroyTexture, setShaderParams, uploadTexture } from "flux:gpu"
16
21
 
17
22
  /**
@@ -48,12 +53,14 @@ export function createMutableTexture(data: Uint8Array, width: number, height: nu
48
53
  * Compiles a GLSL ES 3.00 fragment shader and renders it into a texture,
49
54
  * returning the texture id (usable anywhere a normal texture id is, e.g.
50
55
  * `<texture src>`). The fragment body may reference `vUV` (0..1, top-left
51
- * origin), `iResolution`, `iTime`, and any `uniform float` it declares; pass
52
- * their values via `params`. `textures` binds each declared `uniform sampler2D`
53
- * to an existing texture id (e.g. a camera or decoded image) so the shader can
54
- * read it; those inputs are re-sampled on every `setShaderParams` call, so live
55
- * sources stay current. Frees the texture and shader program when the reactive
56
- * owner is disposed; create outside any reactive scope for app-lifetime shaders.
56
+ * origin), `iResolution`, `iTime`, and any `uniform float` it declares; drive
57
+ * their values with `<texture src={id} params={{...}} />` (preferred) or, when
58
+ * there is no `<texture>` element for it, imperatively with `setShaderParams`.
59
+ * `textures` binds each declared `uniform sampler2D` to an existing texture id
60
+ * (e.g. a camera or decoded image) so the shader can read it; those inputs are
61
+ * re-sampled on every params update, so live sources stay current. Frees the
62
+ * texture and shader program when the reactive owner is disposed; create
63
+ * outside any reactive scope for app-lifetime shaders.
57
64
  */
58
65
  export function createShader(
59
66
  fragmentSrc: string,
package/src/image.ts CHANGED
@@ -34,6 +34,12 @@ export type ImageSource = string | Uint8Array
34
34
  * current texture is freed when the owner is disposed. Display it with
35
35
  * `<texture src={id()} />`; the texture carries its own pixel size, so no
36
36
  * width/height is needed unless you want to scale it.
37
+ *
38
+ * For bytes you already hold (a `with { type: "binary" }` import, or anything in
39
+ * memory) this suspends needlessly: `decodeImage` + `createTexture` are both
40
+ * synchronous, so reach for them directly and skip the `<Loading>` boundary.
41
+ * `createImage` earns its async only for a fetched string URL or a reactive
42
+ * source.
37
43
  */
38
44
  export function createImage(src: ImageSource | (() => ImageSource)): () => number {
39
45
  let getSrc = typeof src === "function" ? src : () => src
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export type { Capabilities, WindowSizeClass } from "./capabilities"
13
13
  export { createTexture } from "./gpu"
14
14
  export { createImage, decodeImage } from "./image"
15
15
  export type { DecodedImage, ImageSource } from "./image"
16
+ export { createScroll } from "./scroll"
17
+ export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
16
18
  export type {
17
19
  LayoutProps,
18
20
  TransformProps,
@@ -30,11 +32,16 @@ export type {
30
32
  PathProps,
31
33
  TextProps,
32
34
  TextureProps,
33
- AudioProps,
34
35
  Color,
36
+ Pct,
35
37
  } from "./types"
36
38
  export type { MeasureTextOptions } from "flux:rendertree"
37
39
 
40
+ // A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
41
+ // half the element box. Keeps percentages a first-class branded value rather
42
+ // than a string that has to be parsed - a bare number stays pixels.
43
+ export let pct = (v: number): import("./types").Pct => ({ __unit: "pct", v })
44
+
38
45
  // --- Authoring-surface re-exports -------------------------------------------
39
46
  // A SolidRT app is built from three substrate packages: @solidjs/signals
40
47
  // (reactivity), solid-js (control-flow components), and @solidjs/universal (the
package/src/types.d.ts CHANGED
@@ -37,10 +37,17 @@ export interface ElementChildrenAttribute {
37
37
 
38
38
  type Children = Element
39
39
 
40
+ // Doc-comment policy for this file: JSX props mostly mirror CSS/DOM, which any
41
+ // developer or agent already knows, so a comment restating standard semantics
42
+ // is noise. Add one only where a prop deviates from that standard, is bespoke
43
+ // to this engine, or has an interaction that isn't decidable from the type
44
+ // alone (e.g. shorthand-vs-longhand precedence, a subsetted value range).
45
+
40
46
  interface FlexboxProps {
41
- gap?: number
42
- rowGap?: number
43
- columnGap?: number
47
+ gap?: LengthPercentage
48
+ rowGap?: LengthPercentage
49
+ columnGap?: LengthPercentage
50
+ /** Shorthand; overridden by flexGrow/flexShrink/flexBasis when they're also set. */
44
51
  flex?: number | "none" | "auto" | (string & {})
45
52
  flexGrow?: number
46
53
  flexShrink?: number
@@ -54,6 +61,7 @@ interface FlexboxProps {
54
61
  justifyContent?: "start" | "end" | "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-evenly" | "space-around"
55
62
  }
56
63
 
64
+ /** CSS grid subset: line-based placement only, no named lines, no grid-template-areas, and auto tracks take a fixed size (no minmax/fr/keyword). */
57
65
  interface GridProps {
58
66
  gridAutoFlow?: "row" | "column" | "row-dense" | "column-dense"
59
67
  gridAutoColumns?: number
@@ -66,10 +74,23 @@ interface GridProps {
66
74
  gridTemplateRows?: string
67
75
  }
68
76
 
69
- type Dimension = number | "auto" | `${number}%`
77
+ /**
78
+ * A layout length: a bare number is pixels, `pct(n)` is a percentage of the
79
+ * containing block, plus "auto" and the `"50%"` string form (kept for paste).
80
+ */
81
+ type Dimension = number | Pct | "auto" | `${number}%`
82
+
83
+ /** Like {@link Dimension} without "auto" (e.g. gap, which has no auto value). */
84
+ type LengthPercentage = number | Pct | `${number}%`
70
85
 
71
86
  export interface LayoutProps extends FlexboxProps, GridProps {
72
87
  display?: "block" | "flex" | "grid" | "none"
88
+ /**
89
+ * No "fixed" or "sticky". Unlike CSS, `absolute` does not itself become a
90
+ * containing block: an absolute element resolves against the nearest
91
+ * ancestor with `position: relative`, so a chain of absolute elements
92
+ * resolves against whatever relative element is above all of them.
93
+ */
73
94
  position?: "relative" | "absolute"
74
95
 
75
96
  top?: Dimension
@@ -116,6 +137,16 @@ export interface PaintProps {
116
137
  strokeWidth?: number
117
138
  }
118
139
 
140
+ /** A percentage value, from `pct(50)`. Resolves against the element box. */
141
+ export type Pct = { readonly __unit: "pct"; v: number }
142
+
143
+ // One axis of the transform origin (the point rotate/scale/3D pivot around),
144
+ // split per axis to match the engine's x/y prop convention. A bare number is
145
+ // pixels; `pct(50)` is a fraction of the box, so a percentage origin tracks the
146
+ // layout size with no reactive wiring. Unset defaults to the axis center.
147
+ type OriginX = number | Pct | "left" | "center" | "right"
148
+ type OriginY = number | Pct | "top" | "center" | "bottom"
149
+
119
150
  export interface TransformProps {
120
151
  rotate?: number
121
152
  scale?: number
@@ -134,8 +165,8 @@ export interface TransformProps {
134
165
  perspective?: number
135
166
  x?: number
136
167
  y?: number
137
- cx?: number
138
- cy?: number
168
+ originX?: OriginX
169
+ originY?: OriginY
139
170
  // Group opacity in 0..1: children are composited together, then faded as a
140
171
  // whole (CSS `opacity`). Does not affect hit testing.
141
172
  opacity?: number
@@ -197,12 +228,10 @@ interface Position {
197
228
 
198
229
  // Primitives
199
230
 
200
- export interface WindowProps extends LayoutProps {
231
+ export interface WindowProps extends LayoutProps, PointerProps {
201
232
  children?: Children
202
233
  title?: string
203
234
  fullscreen?: boolean
204
- vsync?: boolean
205
- fps?: boolean
206
235
  }
207
236
 
208
237
  export interface ViewProps extends LayoutProps, TransformProps, PointerProps {
@@ -229,11 +258,6 @@ export interface ViewProps extends LayoutProps, TransformProps, PointerProps {
229
258
  repaintBoundary?: boolean | "snapshot"
230
259
  }
231
260
 
232
- export interface AudioProps {
233
- src?: Uint8Array
234
- play?: number
235
- }
236
-
237
261
  // draw primitives
238
262
 
239
263
  export interface RectProps extends Position, PaintProps, PointerProps {
@@ -245,7 +269,9 @@ export interface RectProps extends Position, PaintProps, PointerProps {
245
269
  }
246
270
 
247
271
  export interface OvalProps extends Position, PaintProps, PointerProps {
272
+ /** Bounding box width of the ellipse (not a radius); defaults to the layout box. */
248
273
  w?: number
274
+ /** Bounding box height of the ellipse (not a radius); defaults to the layout box. */
249
275
  h?: number
250
276
  }
251
277
 
@@ -254,7 +280,9 @@ export interface LineProps extends PaintProps, PointerProps {
254
280
  y1?: number
255
281
  x2?: number
256
282
  y2?: number
283
+ /** Dash pattern in local units: the drawn segment length. Both onLength and offLength must be set to dash; with either unset the line is solid. */
257
284
  onLength?: number
285
+ /** Dash pattern in local units: the gap length. Both onLength and offLength must be set to dash; with either unset the line is solid. */
258
286
  offLength?: number
259
287
  }
260
288
 
@@ -287,13 +315,16 @@ export interface TextProps extends Position, PaintProps, PointerProps {
287
315
  maxLines?: number
288
316
  }
289
317
 
290
- export interface TextureProps extends Position {
318
+ export interface TextureProps extends Position, PointerProps {
291
319
  src?: number
292
- imageWidth?: number
293
- imageHeight?: number
320
+ w?: number
321
+ h?: number
294
322
  srcX?: number
295
323
  srcY?: number
296
324
  srcW?: number
297
325
  srcH?: number
326
+ // Shader uniform values, when src names a shader texture. Applied at the
327
+ // next repaint (not synchronously), so a fast-changing signal stays paced
328
+ // to real frames rather than triggering a GL render pass per write.
298
329
  params?: Record<string, number>
299
330
  }
package/src/window.ts CHANGED
@@ -93,7 +93,9 @@ export function onResize(fn: (data: ResizeEvent) => void) {
93
93
  // Singleton accessors over the same events as onResize / onWindowFocus. There
94
94
  // is one window, so these are bare accessors rather than a createX instance.
95
95
  // Lazily subscribed on first read (resize is sticky, so the first read sees the
96
- // current value); app-lifetime, so no onCleanup.
96
+ // current value); app-lifetime, so no onCleanup. `ownedWrite: true` on the
97
+ // signals below is the sticky-replay-into-a-tracked-scope case explained on
98
+ // the ensure* functions in environment.ts.
97
99
 
98
100
  let sizeAccessor: (() => { width: number; height: number }) | undefined
99
101
  let safeAreaAccessor: (() => SafeArea) | undefined
@@ -101,9 +103,9 @@ let displayScaleAccessor: (() => number) | undefined
101
103
 
102
104
  function ensureResizeState() {
103
105
  if (sizeAccessor) return
104
- let [size, setSize] = createSignal({ width: 0, height: 0 })
105
- let [safe, setSafe] = createSignal<SafeArea>({ top: 0, left: 0, right: 0, bottom: 0 })
106
- let [scale, setScale] = createSignal(1)
106
+ let [size, setSize] = createSignal({ width: 0, height: 0 }, { ownedWrite: true })
107
+ let [safe, setSafe] = createSignal<SafeArea>({ top: 0, left: 0, right: 0, bottom: 0 }, { ownedWrite: true })
108
+ let [scale, setScale] = createSignal(1, { ownedWrite: true })
107
109
  on("resize", (e: ResizeEvent) => {
108
110
  setSize({ width: e.width, height: e.height })
109
111
  setSafe(e.safeArea)