@solidrt/core 0.0.51 → 0.0.52

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/src/gpu.ts CHANGED
@@ -11,11 +11,14 @@
11
11
  // `flux:gpu` module.
12
12
  //
13
13
  // Sampling is a per-texture property declared at creation: `filter`
14
- // ("linear" default | "nearest") and `wrap` ("clamp" default | "repeat") on
15
- // every create* helper. One state for every consumer - `<texture>` display
16
- // and shader sampling both follow it - so a nearest texture upscales with
17
- // hard pixels everywhere (the retro/pixel-art path: render small, display
18
- // big). No mipmaps exist.
14
+ // ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat") and
15
+ // `mipmap` (default false) on every create* helper. One state for every
16
+ // consumer - `<texture>` display and shader sampling both follow it - so a
17
+ // nearest texture upscales with hard pixels everywhere (the retro/pixel-art
18
+ // path: render small, display big). `mipmap: true` keeps a mip chain the
19
+ // runtime regenerates after every upload or render, so shader sampling of a
20
+ // minified texture (3d surfaces at distance, a supersampled target) does
21
+ // not alias; the display draw samples the full-size level only.
19
22
  //
20
23
  // Combining several passes is a render-tree job, not a shader one: stack
21
24
  // `<texture>` elements and set their `blendMode` (e.g. `blendMode="plus"` for
@@ -62,8 +65,8 @@ export type CreateOptions = { autoFree?: boolean; label?: string }
62
65
 
63
66
  // Sampling options every texture-producing create* helper accepts, applied at
64
67
  // creation as a property of the texture id (there is no set-sampler-later).
65
- export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode }
66
- export type { FilterMode, WrapMode } from "flux:gpu"
68
+ export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean }
69
+ export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux:gpu"
67
70
 
68
71
  // Pixel format option for the pixel-upload creates (createTexture,
69
72
  // createMutableTexture), fixed for the id's lifetime like the sampler state.
@@ -98,7 +101,11 @@ export type { BufferId, DrawId, ProgramId, RenderPipelineId, ShaderStageId, Text
98
101
  // resize in place at a stable id (so `<texture src>` and sampler bindings
99
102
  // stay valid); because the id survives, the owner-scoped auto-free
100
103
  // registered at creation keeps working and no re-registration is needed.
104
+ // depthTexture(target) names a draw target's sampleable depth (created with
105
+ // `depth: "texture"`): a sampler-only id bound like any texture, owned by
106
+ // the target - no auto-free of its own, it dies with the target's.
101
107
  export {
108
+ depthTexture,
102
109
  destroyTexture,
103
110
  endBufferWrite,
104
111
  resizeTexture,
@@ -112,7 +119,9 @@ export {
112
119
  // updated draw range (vertexCount after its buffer gained or lost dynamic
113
120
  // geometry, firstVertex for a different window of a shared buffer,
114
121
  // instanceCount for an instanced population; absent keys keep their current
115
- // value, like params); destroyBuffer is the manual
122
+ // value, like params) and/or swapped buffers (instanceBuffer pointed at a
123
+ // larger buffer once a population outgrows the old one - the growth
124
+ // primitive; replace-only, the range is rechecked); destroyBuffer is the manual
116
125
  // cleanup path for buffers created outside a reactive scope. renderTarget is
117
126
  // the explicit render verb for `render: "manual"` targets - targets whose
118
127
  // pass is state (accumulation, feedback) rather than a pure function of its
@@ -122,22 +131,22 @@ export {
122
131
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
123
132
  // ping-pong buffer, reset state to a known image.
124
133
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
125
- export type { BlendMode, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
134
+ export type { BlendMode, BufferUpdate, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, InstanceAttribute, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
126
135
 
127
136
  // The draw-list verbs, re-exported raw: entries live and die with their draw
128
137
  // target (see createDrawTarget below), so there is no per-entry lifetime to
129
138
  // wrap. addDraw adds an entry (appended, or inserted via opts.before) and
130
139
  // returns its stable DrawId; removeDraw drops one; setDrawParams /
131
- // setDrawTextures / setDrawRange are the per-entry forms of setTargetParams /
132
- // setTargetTextures / setDraw, taking (target, draw, value) with identical
133
- // merge and validation semantics. The per-object hot path is setDrawParams (a
140
+ // setDrawTextures / setDrawRange / setDrawBuffers are the per-entry forms of
141
+ // setTargetParams / setTargetTextures / setDraw (its range and buffer halves),
142
+ // taking (target, draw, value) with identical merge and validation semantics. The per-object hot path is setDrawParams (a
134
143
  // moved mesh = one call with its new matrix); the per-target one is
135
144
  // setTargetParams (exported above), which on a draw target writes the SHARED
136
145
  // params every entry reads. setDrawOrder replaces the whole
137
146
  // list order with a full permutation of the live ids - the sorting verb
138
147
  // (opaque front-to-back, transparent back-to-front, re-issued when the
139
148
  // camera moves).
140
- export { addDraw, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures } from "flux:gpu"
149
+ export { addDraw, removeDraw, setDrawBuffers, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures } from "flux:gpu"
141
150
 
142
151
  // The device ceilings (max texture/target size, sampler inputs per pass,
143
152
  // vertex attributes per pipeline), queried once at startup. Creates and binds
@@ -162,6 +171,7 @@ export {
162
171
  destroyRenderPipeline,
163
172
  destroyShader,
164
173
  linkProgram,
174
+ programAttributes,
165
175
  } from "flux:gpu"
166
176
 
167
177
  /**
@@ -283,7 +293,7 @@ export function createShaderTexture(
283
293
  width: number,
284
294
  height: number,
285
295
  params?: gpu.ShaderParams | null,
286
- opts?: CreateOptions & SamplerOptions & { textures?: Record<string, gpu.TextureId> },
296
+ opts?: CreateOptions & SamplerOptions & { textures?: gpu.TextureBindings },
287
297
  ): gpu.TextureId {
288
298
  let id = gpu.createShaderTexture(fragmentSrc, width, height, params, opts)
289
299
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => gpu.destroyTexture(id))
@@ -320,6 +330,12 @@ export function createShaderTexture(
320
330
  * the default `"clear"` clears to `clearColor` per render; state that must
321
331
  * read its own pixels (decay, blur, simulation) still ping-pongs across two
322
332
  * manual targets, and `copyTexture` seeds either shape.
333
+ *
334
+ * `samples` (2, 4 or 8) multisamples the target's storage so filled
335
+ * geometry gets anti-aliased edges; the texture id still names a
336
+ * single-sample image, so nothing downstream changes. Clamped to the device
337
+ * maximum, falls back to single-sample (with a warning) where the driver
338
+ * refuses, and throws with `loadOp: "load"`.
323
339
  */
324
340
  export function createShaderTarget(
325
341
  pipeline: gpu.RenderPipelineId,
@@ -327,12 +343,16 @@ export function createShaderTarget(
327
343
  height: number,
328
344
  params?: gpu.ShaderParams | null,
329
345
  opts?: {
330
- textures?: Record<string, gpu.TextureId>
346
+ textures?: gpu.TextureBindings
331
347
  buffer?: gpu.BufferId
332
348
  instanceBuffer?: gpu.BufferId
349
+ /** One buffer per instance slot of the pipeline (index = the
350
+ * attributes' `slot`); pass this OR `instanceBuffer`, not both. */
351
+ instanceBuffers?: gpu.BufferId[]
333
352
  clearColor?: [number, number, number, number]
334
353
  render?: "auto" | "manual"
335
354
  loadOp?: "clear" | "load"
355
+ samples?: 1 | 2 | 4 | 8
336
356
  } & (gpu.DrawRange | (gpu.IndexBinding & gpu.IndexRange)) &
337
357
  CreateOptions &
338
358
  SamplerOptions,
@@ -352,7 +372,10 @@ export function createShaderTarget(
352
372
  * `setDrawParams` / `setDrawTextures` / `setDrawRange`. `depth: true` gives
353
373
  * the target the depth storage all entries share (cross-entry occlusion);
354
374
  * whether an entry tests/writes it stays pipeline state, and a depth-testing
355
- * pipeline into a depthless target throws at `addDraw`.
375
+ * pipeline into a depthless target throws at `addDraw`. `depth: "texture"`
376
+ * makes that storage a sampleable depth texture with its own id,
377
+ * `depthTexture(target)` - the shadow-map / depth-effect input; not with
378
+ * `samples`.
356
379
  *
357
380
  * `params` seeds the target's SHARED params - values every entry reads,
358
381
  * written once per target instead of once per entry (a camera's
@@ -379,11 +402,12 @@ export function createDrawTarget(
379
402
  height: number,
380
403
  params?: gpu.ShaderParams | null,
381
404
  opts?: {
382
- depth?: boolean
383
- textures?: Record<string, gpu.TextureId>
405
+ depth?: boolean | "texture"
406
+ textures?: gpu.TextureBindings
384
407
  clearColor?: [number, number, number, number]
385
408
  render?: "auto" | "manual"
386
409
  loadOp?: "clear" | "load"
410
+ samples?: 1 | 2 | 4 | 8
387
411
  } & CreateOptions &
388
412
  SamplerOptions,
389
413
  ): gpu.TextureId {
@@ -393,29 +417,31 @@ export function createDrawTarget(
393
417
  }
394
418
 
395
419
  /** The reactive shader description `createShaderTextureMemo` builds from.
396
- * Sampling (`filter`/`wrap`) is creation-time state, so changing it rebuilds
420
+ * Sampling (`filter`/`wrap`/`mipmap`) is creation-time state, so changing it rebuilds
397
421
  * at a fresh id, like a fragment-source or sampler-binding change. */
398
422
  export type ShaderSpec = {
399
423
  fragmentSrc: string
400
424
  width: number
401
425
  height: number
402
426
  params?: gpu.ShaderParams
403
- textures?: Record<string, gpu.TextureId>
427
+ textures?: gpu.TextureBindings
404
428
  } & SamplerOptions
405
429
 
406
430
  // Shallow name->value equality for params/textures records; treats undefined
407
431
  // as the empty record. A param value may be a number or a flat number array
408
- // (typed uniforms), so arrays compare elementwise.
409
- function sameValue(a: number | number[] | undefined, b: number | number[] | undefined): boolean {
432
+ // (typed uniforms), so arrays compare elementwise; a texture binding may be
433
+ // an `{ id, filter?, wrap? }` override, compared field by field.
434
+ type RecordValue = number | number[] | gpu.TextureBinding
435
+ function sameValue(a: RecordValue | undefined, b: RecordValue | undefined): boolean {
410
436
  if (a === b) return true
411
- if (!Array.isArray(a) || !Array.isArray(b)) return false
412
- return a.length === b.length && a.every((v, i) => v === b[i])
437
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => v === b[i])
438
+ if (typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
439
+ return a.id === b.id && a.filter === b.filter && a.wrap === b.wrap
440
+ }
441
+ return false
413
442
  }
414
443
 
415
- function sameRecord(
416
- a: Record<string, number | number[]> | undefined,
417
- b: Record<string, number | number[]> | undefined,
418
- ): boolean {
444
+ function sameRecord(a: Record<string, RecordValue> | undefined, b: Record<string, RecordValue> | undefined): boolean {
419
445
  if (a === b) return true
420
446
  let ka = a ? Object.keys(a) : []
421
447
  let kb = b ? Object.keys(b) : []
@@ -450,7 +476,12 @@ export function createShaderTextureMemo(
450
476
  opts?: { onError?: (error: unknown) => void },
451
477
  ): () => gpu.TextureId {
452
478
  let make = (s: ShaderSpec) =>
453
- gpu.createShaderTexture(s.fragmentSrc, s.width, s.height, s.params, { textures: s.textures, filter: s.filter, wrap: s.wrap })
479
+ gpu.createShaderTexture(s.fragmentSrc, s.width, s.height, s.params, {
480
+ textures: s.textures,
481
+ filter: s.filter,
482
+ wrap: s.wrap,
483
+ mipmap: s.mipmap,
484
+ })
454
485
  let current = untrack(spec)
455
486
  let currentId = make(current)
456
487
  let [id, setId] = createSignal(currentId)
@@ -460,7 +491,8 @@ export function createShaderTextureMemo(
460
491
  next.fragmentSrc === current.fragmentSrc &&
461
492
  sameRecord(next.textures, current.textures) &&
462
493
  next.filter === current.filter &&
463
- next.wrap === current.wrap
494
+ next.wrap === current.wrap &&
495
+ next.mipmap === current.mipmap
464
496
  ) {
465
497
  // Program and inputs unchanged: mutate in place, the id stays stable.
466
498
  if (next.width !== current.width || next.height !== current.height) {
@@ -545,11 +577,14 @@ export function createPipelineTexture(
545
577
  height: number,
546
578
  params?: gpu.ShaderParams | null,
547
579
  opts?: {
548
- textures?: Record<string, gpu.TextureId>
580
+ textures?: gpu.TextureBindings
549
581
  attributes?: gpu.VertexAttribute[]
550
582
  buffer?: gpu.BufferId
551
- instanceAttributes?: gpu.VertexAttribute[]
583
+ instanceAttributes?: gpu.InstanceAttribute[]
552
584
  instanceBuffer?: gpu.BufferId
585
+ /** One buffer per instance slot (index = the attributes' `slot`);
586
+ * pass this OR `instanceBuffer`, not both. */
587
+ instanceBuffers?: gpu.BufferId[]
553
588
  topology?: gpu.Topology
554
589
  depth?: boolean
555
590
  depthWrite?: boolean
@@ -558,6 +593,7 @@ export function createPipelineTexture(
558
593
  clearColor?: [number, number, number, number]
559
594
  render?: "auto" | "manual"
560
595
  loadOp?: "clear" | "load"
596
+ samples?: 1 | 2 | 4 | 8
561
597
  } & (gpu.DrawRange | (gpu.IndexBinding & gpu.IndexRange)) &
562
598
  CreateOptions &
563
599
  SamplerOptions,
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./renderer"
2
- export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, onPointerMove } from "./core"
2
+ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, snapshotTexture, onPointerMove } from "./core"
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
@@ -17,9 +17,11 @@ export type { TextureId } from "./gpu"
17
17
  export { createImage, decodeImage, encodeImage } from "./image"
18
18
  export type { DecodedImage, ImageSource } from "./image"
19
19
  export { parseSvg, svg } from "./svg"
20
+ export { Logo } from "./logo"
21
+ export type { LogoProps } from "./logo"
20
22
  export type { SvgDocument, SvgDraw } from "./svg"
21
23
  export { createScroll } from "./scroll"
22
- export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
24
+ export type { Scroll, ScrollAxis, ScrollBehavior, ScrollOffset, ScrollOptions, ScrollToOptions } from "./scroll"
23
25
  export { arena } from "./arena"
24
26
  export type { ArenaOwner } from "./arena"
25
27
  export { createPan } from "./pan"
@@ -31,6 +33,10 @@ export type {
31
33
  LayoutProps,
32
34
  TransformProps,
33
35
  PointerProps,
36
+ TransitionProps,
37
+ Transition,
38
+ TransitionPropName,
39
+ TransitionEndEvent,
34
40
  PointerEvent,
35
41
  WheelEvent,
36
42
  KeyEvent,
package/src/logo.tsx ADDED
@@ -0,0 +1,92 @@
1
+ // The SolidRT brand mark: seven puzzle segments authored in a 100x100 space,
2
+ // each filled with its own light-to-dark gradient. Static by default; the
3
+ // animated forms stagger a fade per segment, either once (fade in and hold)
4
+ // or in a loop (in, then out, repeat) like the scaffold's welcome screen.
5
+ import { For, Show, createSignal } from "solid-js"
6
+ import { createLinearGradient } from "./color"
7
+ import { onFrame } from "./window"
8
+
9
+ export interface LogoProps {
10
+ // Rendered width and height in pixels; the mark is square. Default 100.
11
+ size?: number
12
+ // "none" draws the mark at full opacity and requests no frames (default).
13
+ // "once" fades the segments in one after the other and then holds; "loop"
14
+ // fades them in, then out, and repeats.
15
+ animation?: "none" | "once" | "loop"
16
+ }
17
+
18
+ // Per segment: the delay of its fade in ms (its fade out follows at the same
19
+ // offset after the last fade in has completed), its gradient, its path.
20
+ const SEGMENTS = [
21
+ { base: 0, light: "#3f5494", dark: "#162b6c", d: "M50.000 50.000 L28.330 50.000 C28.330 48.810 27.695 47.711 26.665 47.116 C25.635 46.521 24.365 46.521 23.335 47.116 C22.305 47.711 21.670 48.810 21.670 50.000 L0.000 50.000 L50.000 0.000 L50.000 9.170 C48.810 9.170 47.711 9.805 47.116 10.835 C46.521 11.865 46.521 13.135 47.116 14.165 C47.711 15.195 48.810 15.830 50.000 15.830 L50.000 25.000 L50.000 34.170 C48.810 34.170 47.711 34.805 47.116 35.835 C46.521 36.865 46.521 38.135 47.116 39.165 C47.711 40.195 48.810 40.830 50.000 40.830 L50.000 50.000 Z" },
22
+ { base: 90, light: "#547ebf", dark: "#2b5696", d: "M50.000 50.000 L50.000 59.170 C48.810 59.170 47.711 59.805 47.116 60.835 C46.521 61.865 46.521 63.135 47.116 64.165 C47.711 65.195 48.810 65.830 50.000 65.830 L50.000 75.000 L50.000 84.170 C48.810 84.170 47.711 84.805 47.116 85.835 C46.521 86.865 46.521 88.135 47.116 89.165 C47.711 90.195 48.810 90.830 50.000 90.830 L50.000 100.000 L0.000 50.000 L21.670 50.000 C21.670 48.810 22.305 47.711 23.335 47.116 C24.365 46.521 25.635 46.521 26.665 47.116 C27.695 47.711 28.330 48.810 28.330 50.000 L50.000 50.000 Z" },
23
+ { base: 180, light: "#7ea9ea", dark: "#5681c1", d: "M50.000 25.000 L50.000 15.830 C48.810 15.830 47.711 15.195 47.116 14.165 C46.521 13.135 46.521 11.865 47.116 10.835 C47.711 9.805 48.810 9.170 50.000 9.170 L50.000 0.000 L75.000 25.000 L65.830 25.000 C65.830 26.190 65.195 27.289 64.165 27.884 C63.135 28.479 61.865 28.479 60.835 27.884 C59.805 27.289 59.170 26.190 59.170 25.000 L50.000 25.000 Z" },
24
+ { base: 270, light: "#547ebf", dark: "#2b5696", d: "M50.000 25.000 L59.170 25.000 C59.170 26.190 59.805 27.289 60.835 27.884 C61.865 28.479 63.135 28.479 64.165 27.884 C65.195 27.289 65.830 26.190 65.830 25.000 L75.000 25.000 L75.000 34.170 C73.810 34.170 72.711 34.805 72.116 35.835 C71.521 36.865 71.521 38.135 72.116 39.165 C72.711 40.195 73.810 40.830 75.000 40.830 L75.000 50.000 L65.830 50.000 C65.830 48.810 65.195 47.711 64.165 47.116 C63.135 46.521 61.865 46.521 60.835 47.116 C59.805 47.711 59.170 48.810 59.170 50.000 L50.000 50.000 L50.000 40.830 C48.810 40.830 47.711 40.195 47.116 39.165 C46.521 38.135 46.521 36.865 47.116 35.835 C47.711 34.805 48.810 34.170 50.000 34.170 L50.000 25.000 Z" },
25
+ { base: 360, light: "#7ea9ea", dark: "#5681c1", d: "M50.000 50.000 L59.170 50.000 C59.170 48.810 59.805 47.711 60.835 47.116 C61.865 46.521 63.135 46.521 64.165 47.116 C65.195 47.711 65.830 48.810 65.830 50.000 L75.000 50.000 L64.855 60.145 C64.013 59.304 62.787 58.976 61.638 59.283 C60.489 59.591 59.591 60.489 59.283 61.638 C58.976 62.787 59.304 64.013 60.145 64.855 L50.000 75.000 L50.000 65.830 C48.810 65.830 47.711 65.195 47.116 64.165 C46.521 63.135 46.521 61.865 47.116 60.835 C47.711 59.805 48.810 59.170 50.000 59.170 L50.000 50.000 Z" },
26
+ { base: 450, light: "#3f5494", dark: "#162b6c", d: "M75.000 50.000 L75.000 59.170 C73.810 59.170 72.711 59.805 72.116 60.835 C71.521 61.865 71.521 63.135 72.116 64.165 C72.711 65.195 73.810 65.830 75.000 65.830 L75.000 75.000 L50.000 100.000 L50.000 90.830 C48.810 90.830 47.711 90.195 47.116 89.165 C46.521 88.135 46.521 86.865 47.116 85.835 C47.711 84.805 48.810 84.170 50.000 84.170 L50.000 75.000 L60.145 64.855 C59.304 64.013 58.976 62.787 59.283 61.638 C59.591 60.489 60.489 59.591 61.638 59.283 C62.787 58.976 64.013 59.304 64.855 60.145 L75.000 50.000 Z" },
27
+ { base: 540, light: "#7ea9ea", dark: "#5681c1", d: "M100.000 50.000 L75.000 75.000 L75.000 65.830 C73.810 65.830 72.711 65.195 72.116 64.165 C71.521 63.135 71.521 61.865 72.116 60.835 C72.711 59.805 73.810 59.170 75.000 59.170 L75.000 50.000 L75.000 40.830 C73.810 40.830 72.711 40.195 72.116 39.165 C71.521 38.135 71.521 36.865 72.116 35.835 C72.711 34.805 73.810 34.170 75.000 34.170 L75.000 25.000 L100.000 50.000 Z" },
28
+ ]
29
+
30
+ const FADE = 360
31
+ const LAST = SEGMENTS[SEGMENTS.length - 1]!.base
32
+ // The whole mark is visible from IN_DONE; a loop cycle fades out after that.
33
+ const IN_DONE = LAST + FADE
34
+ const CYCLE = IN_DONE + LAST + FADE
35
+
36
+ let clamp = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
37
+ let ease = (t: number) => 1 - (1 - t) * (1 - t)
38
+ let byte = (x: number) => Math.round(clamp(x) * 255).toString(16).padStart(2, "0")
39
+
40
+ export function Logo(props: LogoProps) {
41
+ let size = () => props.size ?? 100
42
+ let mode = () => props.animation ?? "none"
43
+
44
+ // Elapsed animation time; only advanced while an animated mode is mounted.
45
+ let [clock, setClock] = createSignal(0)
46
+
47
+ // The frame loop is mounted through the <Show> below so a mode change
48
+ // remounts it: onFrame holds a standing frame request while registered.
49
+ // "once" releases its request as soon as the last segment is in.
50
+ let Animate = () => {
51
+ let start = -1
52
+ let stop = onFrame((tick) => {
53
+ if (start < 0) start = tick
54
+ let t = tick - start
55
+ if (mode() === "loop") setClock(t % CYCLE)
56
+ else if (t < IN_DONE) setClock(t)
57
+ else {
58
+ setClock(IN_DONE)
59
+ stop()
60
+ }
61
+ })
62
+ return null
63
+ }
64
+
65
+ let alpha = (seg: (typeof SEGMENTS)[number]) => {
66
+ if (mode() === "none") return 1
67
+ let t = clock()
68
+ if (t < seg.base) return 0
69
+ let fadeIn = clamp((t - seg.base) / FADE)
70
+ if (mode() === "once") return ease(fadeIn)
71
+ let end = IN_DONE + seg.base + FADE
72
+ if (t >= end) return 0
73
+ return ease(Math.min(fadeIn, clamp((end - t) / FADE)))
74
+ }
75
+
76
+ let fill = (seg: (typeof SEGMENTS)[number]) => {
77
+ let a = byte(alpha(seg))
78
+ return createLinearGradient(0, 0, 1, 1, [
79
+ { offset: 0, color: seg.light + a },
80
+ { offset: 1, color: seg.dark + a },
81
+ ])
82
+ }
83
+
84
+ return (
85
+ <view width={size()} height={size()} designSize={[100, 100]}>
86
+ <Show when={mode() !== "none"}>
87
+ <Animate />
88
+ </Show>
89
+ <For each={SEGMENTS}>{(seg) => <d-path d={seg.d} color={fill(seg)} />}</For>
90
+ </view>
91
+ )
92
+ }
package/src/renderer.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { createRoot, onCleanup } from "@solidjs/signals"
1
+ import { createRoot, onCleanup, NotReadyError } from "@solidjs/signals"
2
2
  import { createRenderer } from "@solidjs/universal"
3
+ import { createErrorBoundary } from "solid-js"
3
4
  import type { Element } from "solid-js"
4
5
  import * as tree from "flux:rendertree"
5
- import { attachWindow } from "./window"
6
+ import { attachWindow, setWindowRoot } from "./window"
6
7
  import { setEventHandler, setFocusable, setTextInputHints, cleanupNode, focusedNode, setFocus } from "./core"
7
8
 
8
9
  export { getEventHandler } from "./core"
@@ -213,20 +214,7 @@ function applyProp<T>(node: ProxyNode, name: string, value: T): void {
213
214
  setTreeProperty(node, name, value)
214
215
  }
215
216
 
216
- export let {
217
- effect,
218
- memo,
219
- createComponent,
220
- createElement,
221
- createTextNode,
222
- insertNode,
223
- insert,
224
- spread,
225
- setProp,
226
- mergeProps,
227
- applyRef,
228
- ref,
229
- } = createRenderer<ProxyNode>({
217
+ let renderer = createRenderer<ProxyNode>({
230
218
  createElement: (elementType: string, props?: Record<string, any>): ProxyNode => {
231
219
  let proxy = createProxyNode(elementType)
232
220
 
@@ -235,11 +223,13 @@ export let {
235
223
  if (elementType === "window") tree.createRoot(proxy.id)
236
224
  else tree.createNode(proxy.id, elementType)
237
225
 
238
- // The universal JSX template hands static props here as an object; children
239
- // and ref arrive through their own hooks, so skip them.
226
+ // The universal JSX template hands static props here as an object. The
227
+ // compiler routes children/ref expressions through their own hooks, so
228
+ // those names only appear here in degenerate literal forms (children="hi",
229
+ // a bare ref) - let them flow to the tree so the unknown-property warning
230
+ // reports them instead of dropping them silently.
240
231
  if (props) {
241
232
  for (let name in props) {
242
- if (name === "children" || name === "ref") continue
243
233
  applyProp(proxy, name, props[name])
244
234
  }
245
235
  }
@@ -309,28 +299,185 @@ export let {
309
299
  },
310
300
  })
311
301
 
312
- // The app's single <window> node, set by render(). Serves as the default mount
313
- // target for createPortal (single window by design, so one ambient ref).
302
+ export let { memo, createComponent, createElement, createTextNode, insertNode, spread, setProp, mergeProps, applyRef, ref } =
303
+ renderer
304
+ let { effect: rawEffect, insert: rawInsert } = renderer
305
+
306
+ // ------ Per-node error containment --------
307
+ //
308
+ // Every reactive write into the tree goes through two exports the compiled
309
+ // JSX calls: `effect` (an element's dynamic props) and `insert` (a child
310
+ // expression). An error thrown while computing either is contained right
311
+ // there: the props or children keep their last good value, the effect stays
312
+ // subscribed (the throwing read is tracked, so fixing it recomputes and the
313
+ // node recovers on its own), and the rest of the app keeps running. Without
314
+ // this one unclaimed error halts the whole reactive system. A NotReadyError
315
+ // is not an error but a pending async read on its way to the nearest
316
+ // <Loading>, so it passes through. Reported once per site until it recovers,
317
+ // not once per run. What escapes these two (a user createEffect that throws)
318
+ // still reaches render()'s root boundary.
319
+ const SKIP = Symbol("skip")
320
+
321
+ function guard<T>(fn: (prev?: T) => T, describe: () => string, nested: boolean, empty: T): (prev?: T) => T {
322
+ let last = empty
323
+ let failing = false
324
+ return (prev?: T) => {
325
+ try {
326
+ let value = fn(prev === SKIP ? undefined : prev)
327
+ if (failing) {
328
+ failing = false
329
+ console.warn(`Recovered: ${describe()} computes again`)
330
+ }
331
+ // A child expression resolving to a function is read by an inner
332
+ // effect (universal's insert); that read gets the same containment.
333
+ // Solid's flatten only unwraps zero-arity functions and inserts any
334
+ // other function as a node, so only accessors are wrapped, and the
335
+ // wrapper keeps arity 0.
336
+ if (nested && typeof value === "function" && value.length === 0) {
337
+ let inner = guard(value as any, describe, true, empty)
338
+ value = (() => inner()) as any
339
+ }
340
+ last = value
341
+ return value
342
+ } catch (e) {
343
+ if (e instanceof NotReadyError) throw e
344
+ if (!failing) {
345
+ failing = true
346
+ console.error(`Contained error: ${describe()} threw and keeps its last value until it computes again.`, e)
347
+ }
348
+ return last
349
+ }
350
+ }
351
+ }
352
+
353
+ // Universal's declarations trail its runtime (effect takes options, insert
354
+ // takes initial and options), so the wrappers carry the runtime signatures.
355
+ type EffectFn = <T>(fn: (prev?: T) => T, effectFn?: (value: T, prev?: T) => void, options?: unknown) => void
356
+ type InsertFn = (parent: ProxyNode, accessor: unknown, marker?: unknown, initial?: unknown, options?: unknown) => ProxyNode
357
+ let effectRaw = rawEffect as unknown as EffectFn
358
+ let insertRaw = rawInsert as unknown as InsertFn
359
+
360
+ export let effect: EffectFn = (fn, effectFn, options) =>
361
+ effectRaw<any>(
362
+ guard<any>(fn, () => "an element's prop expression", false, SKIP),
363
+ effectFn && ((value, prev) => (value === SKIP ? undefined : effectFn(value, prev === SKIP ? undefined : prev))),
364
+ options,
365
+ )
366
+
367
+ export let insert: InsertFn = (parent, accessor, marker, initial, options) =>
368
+ insertRaw(
369
+ parent,
370
+ typeof accessor === "function"
371
+ ? guard(accessor as any, () => `a child expression of <${parent.elementType}> ${getNodePath(parent.id).join("/")}`, true, undefined)
372
+ : accessor,
373
+ marker,
374
+ initial,
375
+ options,
376
+ )
377
+
378
+ // The current <window> node: the app's, or the error window standing in for
379
+ // it. Serves as the default mount target for createPortal (single window by
380
+ // design, so one ambient ref).
314
381
  let windowRoot: ProxyNode | undefined
382
+ let rendered = false
383
+ // Ids of error windows built by the root boundary, alive only while shown.
384
+ let errorWindows = new Set<number>()
315
385
 
316
386
  /**
317
387
  * Mounts a SolidRT app. Call once at the top level: `render(() => <App />)`.
318
388
  * The element returned by `code` MUST be a `<window>` (it becomes the native
319
389
  * window and root of the render tree); anything else throws. Runs inside a
320
390
  * reactive root, so the whole tree is disposed together on engine reload.
391
+ *
392
+ * The whole app, window included, sits inside an error boundary: an error no
393
+ * <Errored> claims replaces the app's window with an error window (message,
394
+ * stack, a reset button) instead of halting the reactive system for good.
395
+ * The app's subtree stays alive behind it - the boundary keeps it and marks
396
+ * only the failed computations - so reset recomputes those in place and the
397
+ * same window node comes back.
321
398
  */
322
399
  export function render(code: () => any) {
400
+ // Once per app: there is no unmount; teardown is engine teardown.
401
+ if (rendered) {
402
+ throw new Error("render() already called; an app has exactly one render()")
403
+ }
404
+ rendered = true
323
405
  createRoot(() => {
324
- let root = code()
325
- if (!root || root.elementType !== "window") {
326
- throw new Error("render() root must be a <window> element")
327
- }
328
- windowRoot = root
329
- attachWindow(root.id)
330
- insert(null, root)
406
+ let root = createErrorBoundary(
407
+ () => {
408
+ let win = code()
409
+ if (!win || win.elementType !== "window") {
410
+ throw new Error("render() root must be a <window> element")
411
+ }
412
+ return win
413
+ },
414
+ (error, reset) => {
415
+ // The boundary hands the error as an accessor.
416
+ let err = error()
417
+ console.error("Uncaught error: the app is replaced by the error window until reset or reload.", err)
418
+ let win = errorWindow(err, reset)
419
+ errorWindows.add(win.id)
420
+ return win
421
+ },
422
+ )
423
+ rawEffect(
424
+ () => root() as ProxyNode,
425
+ (win, prev) => swapRoot(win, prev),
426
+ )
331
427
  })
332
428
  }
333
429
 
430
+ // The boundary's value changed: the app's window on mount and after a
431
+ // successful reset, an error window after an error. Creating a window already
432
+ // made it the native root; setRoot is the way back to an existing one.
433
+ function swapRoot(win: ProxyNode, prev?: ProxyNode) {
434
+ windowRoot = win
435
+ if (prev === undefined) {
436
+ attachWindow(win.id)
437
+ return
438
+ }
439
+ // Creating the error window made it the native root; the app's window
440
+ // coming back is the case that needs the explicit way back.
441
+ if (!errorWindows.has(win.id)) tree.setRoot(win.id)
442
+ setWindowRoot(win.id)
443
+ // Keys route to the focused node; one inside the hidden window must not
444
+ // keep hearing them.
445
+ setFocus(null)
446
+ // The app's window survives behind an error window (the boundary keeps its
447
+ // subtree for reset); an error window replaced by anything is dead.
448
+ if (errorWindows.has(prev.id) || !errorWindows.has(win.id)) {
449
+ errorWindows.delete(prev.id)
450
+ destroyNode(prev)
451
+ }
452
+ }
453
+
454
+ // The error window: the in-app sibling of the runtime's startup BSOD, built
455
+ // from the primitives directly (no JSX in core). Static content; the reset
456
+ // button recomputes the failed sources, and a reload replaces everything.
457
+ function errorWindow(err: unknown, reset: () => void): ProxyNode {
458
+ let message = err instanceof Error ? err.message : String(err)
459
+ let stack = err instanceof Error && err.stack ? err.stack : ""
460
+ let text = (content: string, props: Record<string, any>) => {
461
+ let node = createElement("text", props)
462
+ insertNode(node, createTextNode(content))
463
+ return node
464
+ }
465
+ let win = createElement("window", { title: "Application error" })
466
+ insertNode(win, createElement("d-rect", { color: "#1144bb" }))
467
+ let column = createElement("view", { flexGrow: 1, flexDirection: "column", padding: 40, gap: 12 })
468
+ insertNode(column, text(":(", { color: "white", fontSize: 64, fontWeight: 700 }))
469
+ insertNode(column, text("Something went wrong", { color: "white", fontSize: 22 }))
470
+ insertNode(column, text(message, { color: "white", fontSize: 16 }))
471
+ if (stack) insertNode(column, text(stack, { color: "#aac2ff", fontSize: 12, fontFamily: "mono" }))
472
+ insertNode(column, text("Fix the error and save to reload, or reset to retry the failed computations.", { color: "#aac2ff", fontSize: 14 }))
473
+ let button = createElement("view", { alignSelf: "flex-start", padding: 12, onPointerDown: () => reset() })
474
+ insertNode(button, createElement("d-rect", { color: "white", radius: 6 }))
475
+ insertNode(button, text("Reset", { color: "#1144bb", fontSize: 16, fontWeight: 600 }))
476
+ insertNode(column, button)
477
+ insertNode(win, column)
478
+ return win
479
+ }
480
+
334
481
  /**
335
482
  * Relocates an already-built node out of its lexical position to `mount` (the
336
483
  * window root by default), then removes it again when the surrounding reactive
@@ -362,6 +509,11 @@ export function createPortal(node: Element, mount?: ProxyNode): null {
362
509
  throw new Error("createPortal: node must be a single built element")
363
510
  }
364
511
  insertNode(target, node as ProxyNode)
365
- onCleanup(() => removeNode(target, node as ProxyNode))
512
+ // A destroyed mount target has already swept the portaled node with it
513
+ // (destroy walks the mount tree), so detaching then would hand freed ids to
514
+ // the native side, which panics. Gone from the proxy map = already freed.
515
+ onCleanup(() => {
516
+ if (nodes.has((node as ProxyNode).id)) removeNode(target, node as ProxyNode)
517
+ })
366
518
  return null
367
519
  }
@@ -47,6 +47,10 @@ declare module "*.ogg" {
47
47
  const bytes: Uint8Array
48
48
  export default bytes
49
49
  }
50
+ declare module "*.glb" {
51
+ const bytes: Uint8Array
52
+ export default bytes
53
+ }
50
54
 
51
55
  // UI event bus (lattice), provided by the runtime as a builtin module.
52
56
  // on/once return an unsubscribe function. Notable events: the routed pointer