@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 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
- and entities are bounded sprites, so a compiled spec cannot strobe the full
47
- field. Provable by sampling any compiled spec through
48
- `@idle-screens/validator`.
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. With `layout: { type: "list" }` a chart is one layer and
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 always the block's
213
- **top-left corner** regardless of `align` (align moves text within the box,
214
- not the box itself — different from the `text` sprite where `align`/`baseline`
215
- shift the meaning of `position`). Line breaks are computed from a fixed
216
- character-class metrics table so wrapping is identical across platforms; note
217
- that the table is approximate — a painted line may slightly exceed `maxWidth`
218
- when the real font is wider than the table estimates, so `maxWidth` is a
219
- layout target, not a hard clip.
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. To "edit" text live:
245
- glide `reveal.progress` to 0, swap `text` while nothing is visible, glide
246
- back to 1.
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`, or `key`-based paths like
465
- `cpu-gauge.color` when layers declare `key`. Changes interpolate over a
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). Boxes come from the same character-class width table the
524
- textBlock line-breaker uses, so they are estimates of the renderer's own
525
- layout — a caption that fails these will look wrong on the wall; one that
526
- passes may still sit a few px off.
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 }` smoothly interpolates paint properties
586
- (colors, opacity values) over `dur` ms when crossing into the next segment.
587
- Morph requires structurally identical adjacent segments (same
588
- `structuralSignature`); if they differ, the engine falls back to cut and the
589
- validator emits a `morph-structural-mismatch` warning. During a morph, entity
590
- placement inherits the outgoing segment's seed — the incoming segment's own
591
- seed is unused. `dur` must be between 200 and 5000 ms.
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. All children share a single canvas; only the active
601
- segment's `SpecInstance` is alive at any time. `workerReady` is `false` (the
602
- worker compile-hook does not dispatch sequences).
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; remaining deltas are
607
- forwarded to the active child's `applyTrack`.
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). See [`src/examples/`](./src/examples/). The dashboard exercises the
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).