@idle-screens/schema 3.6.1 → 3.8.0
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/FORMAT.md +417 -42
- package/dist/index.d.ts +632 -13
- package/dist/index.js +1563 -110
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +192 -4
package/FORMAT.md
CHANGED
|
@@ -36,16 +36,23 @@ sprites, `colorWeights`, `pulse.wave`, `layout` (grid), `life`,
|
|
|
36
36
|
`emit` (sparse events), `clock` (phase-lock), `motion.ease` (settle / buoyant)
|
|
37
37
|
(2026-09-05 — time structure); `polygon` / `stroke` sprites and `rect.feather`
|
|
38
38
|
(2026-09-05 — shape glyphs); `layout: list | table` and the `bar` sprite
|
|
39
|
-
(2026-09-05 — data layout).
|
|
39
|
+
(2026-09-05 — data layout); `textBlock.anchor` / `font` / `opacity`, the
|
|
40
|
+
`maxWidth` cap lifted to 2.0, `sync` and `bed` on the sequence envelope,
|
|
41
|
+
the `fade` transition, `role` on text sprites, and `text: 'crossfade'` on
|
|
42
|
+
`morph` (2026-09-08 — ambient presentations); `background: { type: 'field' }`
|
|
43
|
+
(seeded, warped noise quantised into bands), the static per-entity
|
|
44
|
+
`rotate`, and the print `finish` (grain + dither screen, spec- and
|
|
45
|
+
sequence-level) (2026-09-10 — the print look).
|
|
40
46
|
|
|
41
47
|
## Safety invariants
|
|
42
48
|
|
|
43
49
|
These hold **by construction** — no spec can violate them:
|
|
44
50
|
|
|
45
|
-
1. **No flash primitive.** The background is static (drift is floored at 10 s
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
1. **No flash primitive.** The background is static (drift is floored at 10 s
|
|
52
|
+
— for a gradient's stop positions and for a `field`'s domain travel alike,
|
|
53
|
+
the only way either changes over time) and entities are bounded sprites,
|
|
54
|
+
so a compiled spec cannot strobe the full field. Provable by sampling any
|
|
55
|
+
compiled spec through `@idle-screens/validator`.
|
|
49
56
|
2. **Pulse/grow/cycle are bounded.** Breathing amplitude is capped and periods
|
|
50
57
|
floored at 500 ms (2 Hz — under the WCAG 3 Hz flash threshold). Every entity
|
|
51
58
|
gets its own seeded phase — and with `pulse.wave`, a position-derived phase —
|
|
@@ -98,6 +105,7 @@ must exist, have `count: 1`, and not themselves orbit a layer.
|
|
|
98
105
|
"units": "viewport", // optional: viewport (default) | px
|
|
99
106
|
"referenceViewport": 1080, // optional; design resolution for density scaling
|
|
100
107
|
"ghosting": 0.9, // optional 0..0.95; frame-persistence smear
|
|
108
|
+
"finish": { "grain": 0.5, "dither": 0.3 }, // optional; print screen over the finished frame
|
|
101
109
|
"background": { ... }, // optional; defaults to black
|
|
102
110
|
"layers": [ { ... }, ... ] // 1..36, rendered back-to-front
|
|
103
111
|
}
|
|
@@ -133,6 +141,123 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
|
|
|
133
141
|
bottom (e.g. an aquarium seafloor); optional `drift` slowly oscillates the
|
|
134
142
|
stop positions (period ≥ 10 s) so the background breathes. All colours are
|
|
135
143
|
hex (`#rgb` / `#rrggbb`).
|
|
144
|
+
- `{ "type": "field", "scale": 2.2, "octaves": 3, "warp": 0.4, "quantize": 6, "bands": ["#1b1a3a", "#0078bf", "#00a99d", "#ffe800", "#ff6c2f", "#ff48b0"], "drift": { "period": 40000, "amount": 0.3 } }`
|
|
145
|
+
— a **scalar field**: seeded value noise, optionally domain-warped, read
|
|
146
|
+
through a colour ramp. Thermal maps, contour terrain, sonar landmasses,
|
|
147
|
+
riso washes; the ground under ten of the playgrnd looks. Knobs:
|
|
148
|
+
|
|
149
|
+
| Knob | Range | Default | Meaning |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `scale` | 0.5..8 | — | noise features across the **short side** of the viewport (so a 16:9 frame shows more of the same field than a square one) |
|
|
152
|
+
| `octaves` | int 1..4 | 2 | layers of detail; each halves in amplitude and doubles in frequency |
|
|
153
|
+
| `warp` | 0..1 | 0 | domain warp: 0 round blobs, 1 the folded, smeared contours of a weather chart |
|
|
154
|
+
| `quantize` | 0 or int 2..8 | `bands.length` | posterise into that many levels, one read off the `bands` ramp per level — hard, crisp contours (Terrain, Sonar). `bands.length` is exactly one band per level; more levels step through the ramp. `0` interpolates the ramp smoothly (Aura, Mist) |
|
|
155
|
+
| `bands` | 2..8 hex | — | the ramp, low end of the field to high |
|
|
156
|
+
| `drift` | `{ period ≥ 10000, amount 0..1 }` | none | the field's slow **animate**: the sample domain travels a circle of radius `amount` feature units once per `period` ms, so the contours crawl and the field loops exactly. `amount` defaults to 0.3 |
|
|
157
|
+
| `seed` | number | the spec seed | the field's own seed — change it to get a different field without re-seating an entity |
|
|
158
|
+
|
|
159
|
+
**Analytic guarantee.** The field is a pure function of `(x, y, t, seed)`
|
|
160
|
+
with its own hash — it never draws from the entity RNG, so adding, steering
|
|
161
|
+
or reseeding a field leaves every entity exactly where it was, and
|
|
162
|
+
`luminanceGrid` samples the same function the renderer paints (per cell,
|
|
163
|
+
at the same bucketed time), so perception and paint agree by construction.
|
|
164
|
+
A field's own contours are *ground*, not ink: the grid's `coverage`,
|
|
165
|
+
centroid and profiles deviate from the field's per-cell value
|
|
166
|
+
(`backgroundCells`), so a bright field scores no coverage and the
|
|
167
|
+
`sparse-scene` / `low-contrast-layer` advisories judge entities against the
|
|
168
|
+
field's mean band exactly as they judge them against a gradient's stops.
|
|
169
|
+
|
|
170
|
+
**Raster and cache.** The renderer samples the field into a low-res raster
|
|
171
|
+
— 96 px on the short side, the long side by aspect; 48 px on the `basic` /
|
|
172
|
+
`minimal` capability tiers (`capabilityTier` on the mount context) — and
|
|
173
|
+
draws it scaled to the canvas, nearest-neighbour for quantised bands so the
|
|
174
|
+
contours stay crisp and smoothed for `quantize: 0`. A static field is
|
|
175
|
+
sampled **once per mount**; a drifting one is resampled only when `t`
|
|
176
|
+
crosses a 100 ms bucket, so ten times a second at most, however high the
|
|
177
|
+
frame rate. Steering any knob recomputes on the next frame.
|
|
178
|
+
|
|
179
|
+
**Flash safety.** `drift` is the only way a field changes over time, and
|
|
180
|
+
its `period` is floored at 10 s like the gradient's: the domain moves at
|
|
181
|
+
most 2π·`amount` feature units per period, so no region's mean luminance
|
|
182
|
+
can oscillate anywhere near the 3 Hz WCAG threshold (`field.test.ts` pins
|
|
183
|
+
the extreme — `scale: 0.5`, `amount: 1` at the floor — under one opposing
|
|
184
|
+
10 % transition per second). Taste, not safety: at `scale` below 1 the
|
|
185
|
+
whole frame breathes as one; keep `amount` ≤ 0.5 there, or the wall pulses.
|
|
186
|
+
|
|
187
|
+
**Steering.** Every numeric knob and every band is a paint path —
|
|
188
|
+
`background.scale`, `background.warp`, `background.quantize`,
|
|
189
|
+
`background.bands.2`, `background.drift.amount` — and glides (a fractional
|
|
190
|
+
`quantize` or `octaves` mid-glide rounds). `background.seed` is not
|
|
191
|
+
steerable. The background is paint, not structure, so a `morph` between
|
|
192
|
+
two field segments glides the field; a gradient → field morph steps
|
|
193
|
+
(different shape).
|
|
194
|
+
|
|
195
|
+
**Native:** tvOS ignores the field and paints a **vertical gradient through
|
|
196
|
+
`bands` in order** (first band at the top, last at the bottom), so a field
|
|
197
|
+
scene still carries its palette on the Apple TV until the native player
|
|
198
|
+
rasters the same sampler.
|
|
199
|
+
|
|
200
|
+
### `finish`
|
|
201
|
+
|
|
202
|
+
`{ "grain": 0.5, "dither": 0.3, "animate": false }` — the print pass, composited
|
|
203
|
+
over the **finished** frame as the last step of every render, after every
|
|
204
|
+
layer and after `ghosting` has done its work. It is what turns flat inks
|
|
205
|
+
into paper: riso speckle, gold-leaf tooth, an impasto's grain. Every knob is
|
|
206
|
+
optional; an absent `finish` presents the frame exactly as drawn.
|
|
207
|
+
|
|
208
|
+
| Knob | Range | Meaning |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `grain` | 0..1 | a **seeded, zero-mean noise tile** (256², generated once per mount from the spec seed) screened over the frame at `grain × 0.35` alpha |
|
|
211
|
+
| `dither` | 0..1 | an **8×8 ordered Bayer tile** screened at `dither × 0.25` alpha. A *stylistic screen* — the dot pattern of a halftone print — not true per-pixel dithering of the image (nothing is thresholded; the tile is simply composited) |
|
|
212
|
+
| `animate` | boolean | steps the grain tile's offset once per 1/12 s from the frame's **time bucket**, seeded — never `Math.random` — so two viewers at the same `t` show the same grain and a seek is exact. Default false: the tile sits still. The `basic` / `minimal` capability tiers ignore it (static tile) |
|
|
213
|
+
|
|
214
|
+
**Seeding rule.** The grain tile is a pure function of the spec seed (the
|
|
215
|
+
same lattice hash the `field` background uses); the offset is a pure
|
|
216
|
+
function of `(t, seed)`. Same spec + seed ⇒ the same grain on every
|
|
217
|
+
display, every time.
|
|
218
|
+
|
|
219
|
+
**Flash safety and perception.** Both tiles are centred on mid grey and
|
|
220
|
+
composited with `overlay` — what every shipping canvas2d implements — so
|
|
221
|
+
the frame's **mean luminance is unchanged** at every strength: `overlay`'s
|
|
222
|
+
split at the backdrop's midpoint is exactly what makes a zero-mean,
|
|
223
|
+
symmetric source average back to the backdrop. A finish can neither
|
|
224
|
+
brighten nor darken a wall, and `animate`'s 12 Hz step moves a zero-mean
|
|
225
|
+
texture whose regional mean is constant. The context-rejects-`overlay`
|
|
226
|
+
fallback chain (`soft-light`, then `multiply` as the last resort) is not
|
|
227
|
+
mean-preserving the same way — for the odd context that takes it, the
|
|
228
|
+
luminance-neutral guarantee narrows to "no worse than a very small, bounded
|
|
229
|
+
shift at `finish`'s already-small alpha," not exact invariance. The
|
|
230
|
+
analytic tools treat it as luminance-neutral:
|
|
231
|
+
`luminanceGrid` ignores it entirely (`finish.test.ts` pins the mean
|
|
232
|
+
identical with and without), `adviseSpec` says nothing about it, and
|
|
233
|
+
`describeScene` lists it under `spec.finish` so an agent knows the screen is
|
|
234
|
+
there.
|
|
235
|
+
|
|
236
|
+
**Never fed back into persistence.** With a `finish`, the scene — every
|
|
237
|
+
layer, and the frame `ghosting` decays into — is drawn on an offscreen
|
|
238
|
+
scene canvas; each frame ends by copying it to the visible canvas and
|
|
239
|
+
screening the finish over the *copy*. Screened into the persistence loop
|
|
240
|
+
instead, a static tile would reinforce itself every frame into mud;
|
|
241
|
+
`finish.test.ts` runs `ghosting: 0.9` with `grain: 1` for 120 frames and
|
|
242
|
+
asserts the scene canvas never sees a screen op and the visible one gets
|
|
243
|
+
exactly one copy and one screen per frame.
|
|
244
|
+
|
|
245
|
+
**Sequences.** A sequence applies the finish **once per composed frame** —
|
|
246
|
+
bed, segment and any `fade` composite together — on its own presentation
|
|
247
|
+
canvas; children never apply their own (a transparent child over a bed has
|
|
248
|
+
nothing to screen). Which finish: the sequence-level `finish` (a new field
|
|
249
|
+
on the envelope) when set; else the **active segment's** (the incoming one
|
|
250
|
+
during a fade) when it declares one; a bed's `finish` is ignored — the bed
|
|
251
|
+
is ground, finish the sequence. Absent everywhere ⇒ children draw straight
|
|
252
|
+
onto the visible surface as they always have.
|
|
253
|
+
|
|
254
|
+
**Steering.** `finish.grain` and `finish.dither` are numeric paint paths and
|
|
255
|
+
glide (`setParam("finish.grain", 0.8, { dur: 2000 })`); `finish.animate`
|
|
256
|
+
steps. Declare the block (`"finish": { "grain": 0 }`) to make the paths
|
|
257
|
+
exist — a steer cannot add a `finish` to a spec without one. A sequence's
|
|
258
|
+
own `finish` is not steerable; steer a segment's, or republish.
|
|
259
|
+
|
|
260
|
+
**Native:** tvOS **ignores `finish`** — the frame is presented as drawn.
|
|
136
261
|
|
|
137
262
|
### `layers[]`
|
|
138
263
|
|
|
@@ -148,7 +273,8 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
|
|
|
148
273
|
| `blend` | `lighter` \| `screen` \| `multiply` | source-over | additive glow / gentle additive / darkening |
|
|
149
274
|
| `region` | `{x?, y?}` ranges 0..1 | full viewport | fractional spawn window (placement only, not travel) |
|
|
150
275
|
| `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing; `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
|
|
151
|
-
| `spin` | number \| `[min,max]` ±360 deg/sec | none | per-entity rotation (seeded start angle); a range gives each entity a seeded speed (confetti, tumbling debris) |
|
|
276
|
+
| `spin` | number \| `[min,max]` ±360 deg/sec | none | per-entity rotation (seeded start angle); a range gives each entity a seeded speed (confetti, tumbling debris). `[0, 0]` does **not** hold the seeded angle — a zero speed renders at 0; use `rotate` |
|
|
277
|
+
| `rotate` | number \| `[min,max]` ±360 deg | none | **static** per-entity rotation: a scalar turns the whole layer, a range gives each entity a seeded angle (thrown blades, scattered glyphs, a tilted grid). Adds to `spin`'s start angle. Structural. **Native:** tvOS reads `spin` only ⇒ 0 |
|
|
152
278
|
| `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
|
|
153
279
|
| `trail` | `{length ≤ 5000, fade?}` | none | analytic afterglow trail; `length` is **milliseconds** of history (max 5000), `fade` a number 0..1 (not a boolean — `links.falloff` in the next row is the boolean) |
|
|
154
280
|
| `links` | see below | none | inter-entity lines |
|
|
@@ -156,7 +282,7 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
|
|
|
156
282
|
| `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
|
|
157
283
|
| `emit` | `{every ≥ 1000, life ≥ 500, jitter?, grow?}` (`life ≤ every`) | always lit | **sparse events**: each entity is dark except a `life`-ms window every `every` ms, fading in fast and out slow; `jitter` 0 staggers entities evenly (one event at a time while `life ≤ every / count`), 1 scatters the offsets (a fixed sequence, not seeded — declaring `emit` disturbs no other draw); `grow: [from, to]` scales size across the window — expansion rather than travel |
|
|
158
284
|
| `clock` | `{phase?, rate?}` | seeded phases | **phase-lock**: `pulse`, `grow` and `cycle` share one phase (turns, 0..1) and run at `rate` × time; two layers with the same clock breathe in step. Clocked periods must satisfy `period / rate ≥ 1000` |
|
|
159
|
-
| `key` | string | none | addressable name → `setParam("key.field", …)` |
|
|
285
|
+
| `key` | string | none | addressable name → `setParam("key.count", …)` for a layer field, `setParam("key.sprite.color", …)` for a sprite field (the path mirrors the JSON: sprite fields sit under `sprite`) |
|
|
160
286
|
| `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1`** — except with a `list`/`table` layout, where it anchors the block's top-left; overrides `region` |
|
|
161
287
|
|
|
162
288
|
`links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
|
|
@@ -170,10 +296,12 @@ with distance, removing pop-in at the cutoff.
|
|
|
170
296
|
|
|
171
297
|
- `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
|
|
172
298
|
glyph picked per entity (seeded); optional `cycle` rotates variants over time
|
|
173
|
-
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle":
|
|
299
|
+
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ..., "role": "read" }`
|
|
174
300
|
— a `font` **with** a px size keeps that shorthand, its number rescaled to the
|
|
175
301
|
viewport (verbatim only under `units: "px"`); a family/weight only
|
|
176
|
-
(`"bold monospace"`) composes with the seeded per-entity `size`
|
|
302
|
+
(`"bold monospace"`) composes with the seeded per-entity `size`; `role` is
|
|
303
|
+
the legibility opt-in described under `textBlock` below (same field, same
|
|
304
|
+
meaning on both text sprites)
|
|
177
305
|
- `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
|
|
178
306
|
`soft` renders a radial-falloff glow orb instead of a hard disc
|
|
179
307
|
- `{ "kind": "ring", "radius": [0.002, 0.005], "color": "#d8f6ff", "width": 0.001 }` —
|
|
@@ -188,8 +316,9 @@ with distance, removing pop-in at the cutoff.
|
|
|
188
316
|
- `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
|
|
189
317
|
a data bar: entity *i* draws `length × values[i] / max` (viewport units),
|
|
190
318
|
`thickness` thick, growing from its position toward `direction` (`right`
|
|
191
|
-
default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.values", […])`
|
|
192
|
-
glides every bar
|
|
319
|
+
default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.sprite.values", […])`
|
|
320
|
+
glides every bar (the field lives under `sprite`, so the path does too —
|
|
321
|
+
`bars.values` resolves nothing). With `layout: { type: "list" }` a chart is one layer and
|
|
193
322
|
its labels another — the dashboard genre stops needing one layer per number
|
|
194
323
|
- `{ "kind": "polygon", "radius": [0.02, 0.05], "sides": 3, "color": "#f2e8c9", "soft": false }` —
|
|
195
324
|
regular n-gon of the seeded circumradius, point up (`sides` 3..12, default
|
|
@@ -206,19 +335,79 @@ with distance, removing pop-in at the cutoff.
|
|
|
206
335
|
than a line); `orient: true` turns the path's +x along the entity's heading,
|
|
207
336
|
like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
|
|
208
337
|
O'Keeffe's petal, Basquiat's scrawl
|
|
209
|
-
- `{ "kind": "textBlock", "text": "Multi-line text with wrapping.", "maxWidth": 0.8, "fontSize": 0.04, "lineHeight": 1.4, "align": "left", "color": "#e6e8ef" }` —
|
|
338
|
+
- `{ "kind": "textBlock", "text": "Multi-line text with wrapping.", "maxWidth": 0.8, "fontSize": 0.04, "lineHeight": 1.4, "align": "left", "color": "#e6e8ef", "anchor": "top-left", "font": "system-ui, sans-serif", "opacity": 1 }` —
|
|
210
339
|
multi-line text block with deterministic line-breaking. All dimensions are
|
|
211
340
|
viewport fractions (of `min(w,h)`), not px — `units: "px"` specs reject
|
|
212
|
-
textBlock sprites at validation time. `position` is
|
|
213
|
-
**top-left corner
|
|
214
|
-
not the box itself — different from the
|
|
215
|
-
shift the meaning of `position`).
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
341
|
+
textBlock sprites at validation time. Without `anchor`, `position` is the
|
|
342
|
+
**top-left corner of the `maxWidth` layout box** regardless of `align`
|
|
343
|
+
(align moves text within the box, not the box itself — different from the
|
|
344
|
+
`text` sprite where `align`/`baseline` shift the meaning of `position`).
|
|
345
|
+
`maxWidth` may reach `2.0` — wider than the frame is legal (a wide caption
|
|
346
|
+
on a portrait screen); the `text-off-screen` advisory reports what runs
|
|
347
|
+
out. Line breaks are computed from a fixed character-class metrics table so
|
|
348
|
+
wrapping is identical across platforms; note that the table is approximate
|
|
349
|
+
— a painted line may slightly exceed `maxWidth` when the real font is wider
|
|
350
|
+
than the table estimates, so `maxWidth` is a layout target, not a hard clip.
|
|
220
351
|
Use with `count: 1`, `motion: { type: "static" }`, and `position`.
|
|
221
352
|
|
|
353
|
+
`anchor` (one of nine compass points: `top-left`, `top`, `top-right`,
|
|
354
|
+
`left`, `center`, `right`, `bottom-left`, `bottom`, `bottom-right`) says
|
|
355
|
+
which point of the **rendered text** `position` names — the widest line
|
|
356
|
+
wide, as `align` lays it inside `maxWidth`, and `lines × lineHeight` tall,
|
|
357
|
+
measured after line-breaking. With `anchor: "center"` and
|
|
358
|
+
`position: { "x": 0.5, "y": 0.5 }` the block is centred on **every** aspect
|
|
359
|
+
ratio — `position` is in viewport fractions while `maxWidth` is in
|
|
360
|
+
`min(w,h)`, so hand-computing a centred `x` for one screen misses on the
|
|
361
|
+
next; the anchor removes the arithmetic. Under an anchor `align` only
|
|
362
|
+
shapes the ragged edge (shorter lines sit left/centre/right of the widest);
|
|
363
|
+
it no longer moves the ink. Anchor is placement, so it is part of the
|
|
364
|
+
structural signature (a change rebuilds); rotation stays about the anchor
|
|
365
|
+
point. The perception boxes (`text-overlap`, `text-off-screen`) follow the
|
|
366
|
+
same rule. Absent ⇒ exactly the pre-anchor behaviour above. **Native
|
|
367
|
+
clients that do not read `anchor` render as absent (layout-box top-left).**
|
|
368
|
+
|
|
369
|
+
`font` is a CSS family and/or weight/style — `"bold monospace"`,
|
|
370
|
+
`"300 'Inter', sans-serif"` — composed with the scaled `fontSize` the way
|
|
371
|
+
the `text` sprite composes a size-less font; a **size inside it is
|
|
372
|
+
rejected** (`fontSize` owns size, so the block keeps scaling with the
|
|
373
|
+
viewport). A monospace family (`monospace`, `ui-monospace`, Menlo, Courier,
|
|
374
|
+
SF Mono, Fira Code, …) switches the line-breaker to a uniform 0.6 em
|
|
375
|
+
advance so mono wraps land where the real face puts them; every other
|
|
376
|
+
family uses the proportional table. Default `system-ui, sans-serif`.
|
|
377
|
+
**Native clients fall back to the system face** until a face table exists.
|
|
378
|
+
|
|
379
|
+
`opacity` (0–1, default 1) multiplies the block's paint alpha — the whole
|
|
380
|
+
block, on top of the layer's per-entity `alpha`. It is **paint**, not
|
|
381
|
+
carpentry: excluded from the structural signature, so
|
|
382
|
+
`setParam("h.sprite.opacity", 0, { dur: 1500 })` glides the block out
|
|
383
|
+
without a rebuild (and back in with `1`). Declare `"opacity": 1` on the
|
|
384
|
+
block to make the path steerable (a `setParam` cannot create a field that
|
|
385
|
+
is not there). Scales perceived ink in the luminance grid. **Native clients
|
|
386
|
+
without `opacity` render at 1.**
|
|
387
|
+
|
|
388
|
+
`role` (`"read"` | `"atmosphere"`, on `textBlock` **and** `text`) declares
|
|
389
|
+
what the words are *for*, and is the only thing that turns legibility
|
|
390
|
+
checking on. Absent, or `"atmosphere"`, the layer is texture — a haiku
|
|
391
|
+
fading in a corner, dim labels on a wall board, the `dev-dashboard`'s
|
|
392
|
+
2.6:1 telemetry — and `adviseSpec` says nothing about how readable it is,
|
|
393
|
+
exactly as before the field existed; keep atmospheric text undeclared, the
|
|
394
|
+
advisories would only nag. `"read"` says the words must be read from
|
|
395
|
+
across the room, and opts that layer into two advisories:
|
|
396
|
+
`text-legibility` fires when the text colour's WCAG ratio falls below
|
|
397
|
+
**4.5:1** against the background sampled at the box centre (gradient stops
|
|
398
|
+
interpolated at rest, `band` honoured) **or** against the brightest
|
|
399
|
+
additive layer (`blend: lighter` / `screen`) whose entities can reach the
|
|
400
|
+
box and are at least a glyph tall, taken at its peak alpha (base + pulse)
|
|
401
|
+
composited over that background — a glow parked under a caption is
|
|
402
|
+
measured, dust is not; both ratios appear in the message when an additive layer is beneath — a plate-less read reports only the background ratio. `text-safe-area`
|
|
403
|
+
fires when the box (a `list`'s whole extent) lies within **5 %** of any
|
|
404
|
+
viewport edge, where bezels and overscan hide it. Each is judged at the
|
|
405
|
+
viewport passed to `adviseSpec` — check the portrait one too. `role` is a
|
|
406
|
+
declaration only: it changes **no pixel anywhere, on any client**, is not
|
|
407
|
+
in the structural signature, and native players ignore it with nothing to
|
|
408
|
+
fall back to. The shipped `lobby-talk` example declares it on every text
|
|
409
|
+
layer and produces zero advisories, landscape and portrait.
|
|
410
|
+
|
|
222
411
|
textBlock also accepts `reveal` — animated typing/deleting:
|
|
223
412
|
`{ "reveal": { "progress": 1, "mode": "typewriter", "speed": 0, "caret": true } }`.
|
|
224
413
|
Layout always runs on the **full** text; reveal only masks which glyphs are
|
|
@@ -241,9 +430,13 @@ with distance, removing pop-in at the cutoff.
|
|
|
241
430
|
blink is in full cycles/sec, capped at 3 for flash safety, and is a square
|
|
242
431
|
wave of `t` like every other animation). With `align: "center"`/`"right"`
|
|
243
432
|
the revealing line stays anchored to its alignment as it types — only
|
|
244
|
-
left-aligned text reads as a classic typewriter.
|
|
245
|
-
|
|
246
|
-
|
|
433
|
+
left-aligned text reads as a classic typewriter. `text` itself is paint
|
|
434
|
+
that **steps**: a `setParam` swap or a sequence `morph` switches the string
|
|
435
|
+
whole on the first frame — nothing interpolates a string — so to "edit"
|
|
436
|
+
text live: glide `reveal.progress` to 0, swap `text` while nothing is
|
|
437
|
+
visible, glide back to 1 (or fade the block via its `color`). Inside a
|
|
438
|
+
sequence, a morph declared `text: 'crossfade'` cross-fades the old words
|
|
439
|
+
under the new ones instead (see **Transitions** below).
|
|
247
440
|
|
|
248
441
|
`circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
|
|
249
442
|
`colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
|
|
@@ -461,8 +654,11 @@ fixed-step warm-up (≤ 120 frames) from a full clear on any non-contiguous seek
|
|
|
461
654
|
## Steering
|
|
462
655
|
|
|
463
656
|
Compiled specs accept live parameter changes via dot-paths —
|
|
464
|
-
`layers.0.count`, `background.stops.0.color`,
|
|
465
|
-
`
|
|
657
|
+
`layers.0.count`, `layers.0.sprite.color`, `background.stops.0.color`,
|
|
658
|
+
`background.bands.2` (a field's band), or
|
|
659
|
+
`key`-based paths like `cpu-gauge.count` / `cpu-gauge.sprite.color` when
|
|
660
|
+
layers declare `key` (the key replaces `layers.N`; everything after it
|
|
661
|
+
mirrors the JSON, so a sprite field keeps its `sprite.` segment). Changes interpolate over a
|
|
466
662
|
control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
|
|
467
663
|
a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
|
|
468
664
|
for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
|
|
@@ -482,6 +678,13 @@ interpolation, and background drift is sampled at rest. These are documented
|
|
|
482
678
|
trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
483
679
|
|
|
484
680
|
- `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
|
|
681
|
+
- `perceiveSequenceFrame(seq, T, {viewport?, seed?, releasedBelow?})` — the
|
|
682
|
+
same bundle for one frame of a **sequence** at global time `T`: resolves the
|
|
683
|
+
segment (reported as `segment: {index, key, localT, held?}`) and, when the
|
|
684
|
+
sequence has a `bed`, composes the bed at `T` under the segment's ink at
|
|
685
|
+
`localT` with the segment's background dropped, as the renderer stacks
|
|
686
|
+
them — the grids add, dominance ranks both by raw weight with bed layers
|
|
687
|
+
keyed `bed:<key|index>`, and text/motion/form list bed layers first.
|
|
485
688
|
- `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
|
|
486
689
|
(background gradient + entities + link lines, blend-aware), with coverage,
|
|
487
690
|
visual-mass centroid, and **row/column deviation profiles** (1D transects of
|
|
@@ -520,10 +723,16 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
|
520
723
|
`perceiveScene().advisories`). Two of them are **spatial**, for static text:
|
|
521
724
|
`text-off-screen` (a text/textBlock box crosses the viewport edge by more
|
|
522
725
|
than 1%) and `text-overlap` (two static text layers share more than 10% of
|
|
523
|
-
the smaller box
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
726
|
+
the smaller box; it carries both boxes as `boxes: [{x, y, w, h}]` in
|
|
727
|
+
viewport fractions so the fix needs no re-derivation). Boxes come from the
|
|
728
|
+
same character-class width table the textBlock line-breaker uses, so they
|
|
729
|
+
are estimates of the renderer's own layout — a caption that fails these
|
|
730
|
+
will look wrong on the wall; one that passes may still sit a few px off.
|
|
731
|
+
Two more are **opt-in** through the text sprite's `role: "read"` (see the
|
|
732
|
+
`textBlock` sprite): `text-legibility` (WCAG ratio below 4.5:1 against the
|
|
733
|
+
background at the box centre or under the brightest additive layer that can
|
|
734
|
+
sit beneath the line) and `text-safe-area` (the box within 5 % of an edge,
|
|
735
|
+
with its box in `boxes`). Undeclared text is never measured for either.
|
|
527
736
|
`overlap-seams` catches the trap the other channels are blind to: a layer
|
|
528
737
|
whose entities bury each other (so it was drawn as one shape) but whose paint
|
|
529
738
|
settings make every overlap visible. It is gated to stay off deliberate
|
|
@@ -562,6 +771,9 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
|
|
|
562
771
|
"label": "My Sequence",
|
|
563
772
|
"seed": 42, // optional; forwarded to children without their own seed
|
|
564
773
|
"loop": false,
|
|
774
|
+
"sync": "mount", // optional; 'mount' (default) or 'epoch' — see **Sync**
|
|
775
|
+
"bed": { /* SaverSpec */ }, // optional; the ground under every segment — see **Bed**
|
|
776
|
+
"finish": { "grain": 0.4 }, // optional; one print pass over the composed frame — see `finish` above
|
|
565
777
|
"segments": [
|
|
566
778
|
{ "key": "intro", "scene": { /* SaverSpec */ }, "duration": 5000 },
|
|
567
779
|
{ "key": "main", "scene": { /* SaverSpec */ }, "duration": 10000, "advance": "auto" },
|
|
@@ -582,13 +794,61 @@ the presenter never freezes. A durationless final segment holds regardless of
|
|
|
582
794
|
`advance`.
|
|
583
795
|
|
|
584
796
|
**Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
|
|
585
|
-
`{ type: 'morph', dur: number }`
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
`
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
797
|
+
`{ type: 'morph', dur: number, text?: 'step' | 'crossfade' }` interpolates
|
|
798
|
+
**numbers and hex colours** (colour, alpha, pulse, `reveal.progress`, …) over
|
|
799
|
+
`dur` ms when crossing into the next segment; **every other value — strings,
|
|
800
|
+
and `textBlock.text` above all — switches on the first morph frame** under
|
|
801
|
+
the default `text: 'step'`. A caption change therefore does not cross-fade
|
|
802
|
+
under a plain morph: fade text via its colour (glide it into the background
|
|
803
|
+
and back), via `reveal.progress` — or declare **`text: 'crossfade'`**, which
|
|
804
|
+
draws each `text` / `textBlock` layer whose string(s) differ between the two
|
|
805
|
+
segments **twice** for the window: the outgoing words at alpha `1 − k` under
|
|
806
|
+
the incoming at `k`, where `k` is the morph's eased progress, both in the
|
|
807
|
+
lerped frame's paint (colour, `opacity` and layer alpha glide as usual). Only
|
|
808
|
+
the differing text layers cost a second draw, and only for `dur`; layers
|
|
809
|
+
whose words match, and every non-text layer, are untouched. It is an
|
|
810
|
+
internal paint pass, not a spec field — no `opacity` appears on the `text`
|
|
811
|
+
sprite. `text` is a morph option only (a `fade` already cross-fades whole
|
|
812
|
+
frames; a `cut` has no window) and the validator rejects it elsewhere.
|
|
813
|
+
**Default `step` is today's behaviour byte for byte** — the sequence
|
|
814
|
+
baseline pins it — and a flip to `crossfade` by default would be a major
|
|
815
|
+
change, with the baseline regenerated. **Native:** tvOS **steps** the words (it
|
|
816
|
+
ignores `text`) but already cross-fades whole frames on every segment
|
|
817
|
+
change using the transition's `dur`, so the result on the Apple TV is
|
|
818
|
+
close to the web's. Morph requires structurally
|
|
819
|
+
identical adjacent segments (same `structuralSignature`); if they differ, the
|
|
820
|
+
engine falls back to cut and the validator emits a `morph-structural-mismatch`
|
|
821
|
+
warning. When the segments are structural twins whose only differences are
|
|
822
|
+
values morph steps (a text-only change), the morph runs but every frame of it
|
|
823
|
+
shows the incoming segment — it reads as a cut — and the validator emits a
|
|
824
|
+
`morph-nothing-morphable` warning (never an error; stored sequences stay
|
|
825
|
+
valid) — unless the transition declared `text: 'crossfade'` and the words
|
|
826
|
+
differ, in which case the words are the thing that morphs and the warning is
|
|
827
|
+
withheld. During a morph, entity placement inherits the outgoing segment's
|
|
828
|
+
seed — the incoming segment's own seed is unused. `dur` must be between 200
|
|
829
|
+
and 5000 ms.
|
|
830
|
+
|
|
831
|
+
`{ type: 'fade', dur: number }` is the general cross-fade, for segments that
|
|
832
|
+
have nothing in common: the outgoing segment stays alive on a canvas of its
|
|
833
|
+
own for `dur` ms and is composited over the incoming one at
|
|
834
|
+
`1 − easeSmooth(localT / dur)`, so both keep animating through the window. The
|
|
835
|
+
outgoing segment renders at `duration + localT` — it continues rather than
|
|
836
|
+
freezing (and if it was an `advance: 'input'` hold, it resumes from its
|
|
837
|
+
`duration`, not from wherever the hold had reached). A fade is always from the
|
|
838
|
+
previous segment in the list; under `loop: true` the **last** segment's `fade`
|
|
839
|
+
is the wrap's transition into segment 0. It works through the clicker too — a
|
|
840
|
+
`sequence.segment` steer lands at `localT` 0 of the fade. Same `dur` bounds
|
|
841
|
+
as morph, no structural requirement, and a fade only smooths luminance, so the
|
|
842
|
+
flash gate is untouched; it is the remedy for a `boundary-luminance-jump`
|
|
843
|
+
advisory. **Tier gate:** two live segments for `dur` is over the budget of the
|
|
844
|
+
lowest tiers, so a host on the `basic` (canvas2d only) or `minimal` capability
|
|
845
|
+
tier — passed as `capabilityTier` on the `SequenceMountContext`, the tier
|
|
846
|
+
`computeTier` from `@idle-screens/capabilities` reports — renders `fade` as
|
|
847
|
+
`cut`; absent ⇒ fade enabled. `adviseSequence` says so once per sequence with
|
|
848
|
+
the informational `fade-degrades-on-low-tier`. **Native:** tvOS already
|
|
849
|
+
cross-fades on every segment change using the transition's `dur`, so `fade`
|
|
850
|
+
matches the native player rather than diverging from it; native reads
|
|
851
|
+
`fade.dur` where it reads the morph `dur` today.
|
|
592
852
|
|
|
593
853
|
**Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
|
|
594
854
|
sums of durations. Half-open segments: `[start, start+duration)`. With
|
|
@@ -596,15 +856,102 @@ sums of durations. Half-open segments: `[start, start+duration)`. With
|
|
|
596
856
|
a durationless final segment). An unreleased `advance: 'input'` hold blocks the
|
|
597
857
|
wrap; after a wrap every hold is armed again.
|
|
598
858
|
|
|
859
|
+
**Sync:** `sync` names what the sequence clock is anchored to. `mount`
|
|
860
|
+
(the default, and today's behaviour for every stored sequence) starts `T` at
|
|
861
|
+
0 when the viewer mounts — every joiner sees segment 0, which is what makes a
|
|
862
|
+
pre-roll a pre-roll. `epoch` lets the host seed the clock: the viewer passes
|
|
863
|
+
`sequenceBaseT` (ms already elapsed on the shared clock — idlescreens.com
|
|
864
|
+
passes `Date.now() − scene.epoch`) on the mount context
|
|
865
|
+
(`SequenceMountContext`, a `SaverContext` plus that one field), and the
|
|
866
|
+
instance starts at that `T` instead of 0, so every screen in a room resolves
|
|
867
|
+
the same segment. The trade-off: a viewer joining an `epoch` sequence lands
|
|
868
|
+
**mid-loop**, wherever the room is; `mount` keeps pre-roll semantics. Holds
|
|
869
|
+
are the same under both — `advance: 'input'` is armed for every viewer, so a
|
|
870
|
+
late joiner whose seeded clock is already past an unreleased hold lands **on**
|
|
871
|
+
the held segment (still animating), not past it, and the clicker releases it
|
|
872
|
+
from there. `epoch` without a `sequenceBaseT` starts at 0 (a host that does
|
|
873
|
+
not pass the hint loses nothing). **Native clients that anchor at their own
|
|
874
|
+
mount behave as `mount`** until they read the field and seed from the channel
|
|
875
|
+
epoch. The default is never flipped: pre-roll depends on `mount`.
|
|
876
|
+
|
|
877
|
+
**Bed:** `bed` is one SaverSpec drawn **under every segment on the
|
|
878
|
+
sequence's global clock** — the ground that does not reset. Every segment still
|
|
879
|
+
starts at its own `localT` 0 (builds replay, `emit` phases and `reveal.speed`
|
|
880
|
+
key off segment time exactly as before), but the bed's `T` runs from mount (or
|
|
881
|
+
from `sequenceBaseT` under `sync: 'epoch'`) straight through every boundary,
|
|
882
|
+
and a `sequence.segment` steer displaces the *segments'* clock only — the
|
|
883
|
+
clicker rewinds a slide, never the bed. That is the fix for the boundary
|
|
884
|
+
rewind every ambient reviewer flagged: put the motion that must be continuous
|
|
885
|
+
(the drifting field, the runner-orb, the slow gradient) in the bed and the
|
|
886
|
+
slide content in the segments. Rules:
|
|
887
|
+
|
|
888
|
+
- **The bed owns the ground.** Segments render over it *transparently*: a
|
|
889
|
+
segment's `background` is never painted while a bed exists (the validator
|
|
890
|
+
warns `bed-hides-segment-background` on each segment that declares one), and
|
|
891
|
+
a segment's `ghosting` is ignored (a smear needs an opaque ground to decay
|
|
892
|
+
into; the bed may declare its own `ghosting`, and it works as usual). A
|
|
893
|
+
segment's ink composites over the bed with its own `blend`/`alpha`.
|
|
894
|
+
- **Clock:** bed at `T`, segment at `localT`, in the same frame. Under
|
|
895
|
+
`loop: true` the bed does not wrap with the segments — it keeps counting.
|
|
896
|
+
- **Steering:** `bed.<path>` routes to the bed with the prefix stripped
|
|
897
|
+
(`setParam("bed.field.sprite.color", …)`, `bed.ghosting`, …);
|
|
898
|
+
`sequenceSteerablePaths(seq)` lists them. Without a bed, `bed.*` reaches the
|
|
899
|
+
segments unchanged, so a layer keyed `bed` keeps working. Bed steers are
|
|
900
|
+
not part of the retained segment track — the bed is never re-created.
|
|
901
|
+
- **Seed:** the bed uses its own `seed`, else `seq.seed + 24` (past every
|
|
902
|
+
segment's `seq.seed + index`, so it never shares a stream with segment 0 and
|
|
903
|
+
adding a segment does not re-seat it).
|
|
904
|
+
- **Perf accounting:** the bed is live alongside whichever segment is up, so
|
|
905
|
+
its entities count **together with the largest segment's** toward the 800
|
|
906
|
+
cap (`validateSequence` errors on `bed` when the sum is over) and toward the
|
|
907
|
+
manifest's `costTier`. A `fade` over a bed puts both segments on canvases of
|
|
908
|
+
their own for `dur` (incoming at k, outgoing at 1 − k, both over the bed):
|
|
909
|
+
three live instances on the lowest tier, which is why fade is tier-gated.
|
|
910
|
+
- **Perception:** `perceiveSequenceFrame(seq, T)` composes bed + segment (see
|
|
911
|
+
the perception API above). Bed and segments are assumed to share `units` /
|
|
912
|
+
`referenceViewport`.
|
|
913
|
+
- **Native:** tvOS **ignores `bed` initially** and renders segments with their
|
|
914
|
+
own backgrounds, exactly as it does today — so a sequence authored with a bed
|
|
915
|
+
should still carry sensible segment backgrounds until the native player
|
|
916
|
+
draws the bed (a second compiled scene drawn first, which its layer model
|
|
917
|
+
already supports). Web viewers hide those backgrounds; native shows them.
|
|
918
|
+
- **Not a default.** The "cheap form" — rendering a morph chain's root child at
|
|
919
|
+
`T − segmentStart(chainRoot)` so twins keep one continuous clock — is **not**
|
|
920
|
+
implemented and never will be as a default: stored morph-chained sequences
|
|
921
|
+
pin their per-segment `t = 0` frames (`sequence-baseline.test.ts`). A bed is
|
|
922
|
+
the supported way to keep motion continuous across segments; if a per-segment
|
|
923
|
+
`timebase: 'sequence'` is ever wanted it will be opt-in, after this.
|
|
924
|
+
|
|
925
|
+
Absent `bed` ⇒ the code path is byte for byte what it was (the sequence
|
|
926
|
+
baseline proves it): every segment paints its own ground.
|
|
927
|
+
|
|
599
928
|
**Compilation:** `compileSequence()` returns an ordinary `SaverPlugin` — the
|
|
600
|
-
viewer needs zero changes
|
|
601
|
-
|
|
602
|
-
|
|
929
|
+
viewer needs zero changes (a host that wants `sync: 'epoch'` passes a
|
|
930
|
+
`SequenceMountContext`; a plain `SaverContext` still mounts). All children
|
|
931
|
+
share a single canvas; only the active segment's `SpecInstance` is alive at
|
|
932
|
+
any time — plus the `bed`'s, when one is declared, and for the `dur` of a
|
|
933
|
+
`fade` the outgoing segment on an offscreen canvas of its own. `workerReady`
|
|
934
|
+
is `false` (the worker compile-hook does not dispatch sequences).
|
|
603
935
|
|
|
604
936
|
**Steering:** segment switching uses the `sequence.segment` delta path via
|
|
605
937
|
`applyTrack` (`setParam("sequence.segment", n)` over MCP). The
|
|
606
|
-
`SequenceInstance` intercepts this path before delegation
|
|
607
|
-
|
|
938
|
+
`SequenceInstance` intercepts this path before delegation, and `bed.<path>`
|
|
939
|
+
deltas go to the bed (see **Bed**); every other delta
|
|
940
|
+
is forwarded to the active segment's `applyTrack` **and retained** (last wins
|
|
941
|
+
per path, merged across calls). Segment instances are created lazily and
|
|
942
|
+
disposed at each boundary, so the retained set is re-applied to every segment
|
|
943
|
+
as it comes up: **a steer persists across segment changes and lands on the
|
|
944
|
+
segment that owns the path.** `bars.sprite.values` steered while the title
|
|
945
|
+
slide is up takes effect the moment the chart slide appears (by timer or by
|
|
946
|
+
clicker), and stays if the show leaves and returns; on segments without a
|
|
947
|
+
`bars` key the delta is simply a no-op, as is any delta whose value does not
|
|
948
|
+
validate on that particular segment (the rest of the set still applies). A
|
|
949
|
+
morph's two lerp endpoints carry the retained set too, so a steered colour
|
|
950
|
+
rides through the glide instead of vanishing for `dur` — but a steer that
|
|
951
|
+
lands while the morph itself is in progress takes effect immediately rather
|
|
952
|
+
than gliding over its own `dur`, since the morph's cross-fade is already the
|
|
953
|
+
active transition on that child. Pinned by `sequence.test.ts` →
|
|
954
|
+
"SequenceInstance — retained track".
|
|
608
955
|
|
|
609
956
|
The steer **moves the clock, not the frame**: it displaces the timeline so the
|
|
610
957
|
target segment starts at its own `localT` 0 (its `life.enter` build replays)
|
|
@@ -621,6 +968,30 @@ whose slides carry `advance: 'input'`, and "next" is one `setParam`. Pinned by
|
|
|
621
968
|
(offset by segment index for independence). Children with their own seed are
|
|
622
969
|
unaffected.
|
|
623
970
|
|
|
971
|
+
**Hot-swapping a republished sequence:** the instance `compileSequence().mount()`
|
|
972
|
+
returns is a `SequenceSaverInstance` with `hotSwapSequence(next): boolean`
|
|
973
|
+
(feature-detect with `hasHotSwapSequence(inst)`; an older engine's instance
|
|
974
|
+
has no such method). When the republished sequence is
|
|
975
|
+
`sequenceSwapCompatible` with the running one — the same number of
|
|
976
|
+
segments, every segment's scene a structural twin (`structuralSignature`) of
|
|
977
|
+
its counterpart with the same render seed, a `bed` on both sides or neither
|
|
978
|
+
(twins, same seed), the same `loop` and `sync`, and the same `duration`,
|
|
979
|
+
`advance` and `transition` on every segment — the swap happens in place:
|
|
980
|
+
every live segment child and the bed take their new scene, the clock, the
|
|
981
|
+
active segment, every released hold and the retained track stay exactly
|
|
982
|
+
where they were (steered paint is re-applied on top of the republished
|
|
983
|
+
scene), and the next frame resolves to the same `(segment, localT)` with
|
|
984
|
+
every entity where it was a frame ago. Paint is free: words, colours,
|
|
985
|
+
`background`, ids, labels, segment keys. A structural edit (a layer added,
|
|
986
|
+
a count or motion changed, a layer's `alpha` range — baked per entity at
|
|
987
|
+
build time, like `size`), a timing edit (a duration — it would move every
|
|
988
|
+
later boundary under a clock that keeps running — an `advance`, a
|
|
989
|
+
transition's type or `dur`), a seed, `loop` or `sync` change, or a bed added
|
|
990
|
+
or removed returns `false` and changes nothing; the host remounts then, as
|
|
991
|
+
it always has. idle-server's viewer wires this so a mid-talk caption fix does
|
|
992
|
+
not send every screen in the room back to segment 0. Pinned by
|
|
993
|
+
`sequence.test.ts` → "SequenceInstance — hotSwapSequence".
|
|
994
|
+
|
|
624
995
|
## Examples
|
|
625
996
|
|
|
626
997
|
Shipped working specs (also exposed as `EXAMPLE_SPECS` /
|
|
@@ -633,5 +1004,9 @@ showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
|
|
|
633
1004
|
`nostalghia-candle` and `haiku` (restraint and text); and the 2026-09 trio —
|
|
634
1005
|
`pings` (emit + grow + ease: one event at a time), `facets` (polygon, stroke
|
|
635
1006
|
and feathered rect) and `relay-board` (list layout + bar: a chart in five
|
|
636
|
-
layers)
|
|
1007
|
+
layers); and `lobby-talk` (one screen of a quarter-in-review, every text
|
|
1008
|
+
layer `role: "read"`, zero advisories — readable copy over additive
|
|
1009
|
+
atmosphere); and `thermal-field` (a `field` background in six riso inks,
|
|
1010
|
+
`quantize: 6`, warped and drifting, under a handful of screened motes, with
|
|
1011
|
+
a grain-and-dither `finish` — the ground is the piece and the paper shows). See [`src/examples/`](./src/examples/). The dashboard exercises the
|
|
637
1012
|
static/HUD subset at scale (34 layers of keyed, positioned text).
|