@idle-screens/schema 3.6.1 → 3.7.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,7 +36,10 @@ 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).
40
43
 
41
44
  ## Safety invariants
42
45
 
@@ -156,7 +159,7 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
156
159
  | `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
157
160
  | `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
161
  | `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", …)` |
162
+ | `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
163
  | `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
164
 
162
165
  `links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
@@ -170,10 +173,12 @@ with distance, removing pop-in at the cutoff.
170
173
 
171
174
  - `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
172
175
  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": ... }`
176
+ - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ..., "role": "read" }`
174
177
  — a `font` **with** a px size keeps that shorthand, its number rescaled to the
175
178
  viewport (verbatim only under `units: "px"`); a family/weight only
176
- (`"bold monospace"`) composes with the seeded per-entity `size`
179
+ (`"bold monospace"`) composes with the seeded per-entity `size`; `role` is
180
+ the legibility opt-in described under `textBlock` below (same field, same
181
+ meaning on both text sprites)
177
182
  - `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
178
183
  `soft` renders a radial-falloff glow orb instead of a hard disc
179
184
  - `{ "kind": "ring", "radius": [0.002, 0.005], "color": "#d8f6ff", "width": 0.001 }` —
@@ -188,8 +193,9 @@ with distance, removing pop-in at the cutoff.
188
193
  - `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
189
194
  a data bar: entity *i* draws `length × values[i] / max` (viewport units),
190
195
  `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
196
+ default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.sprite.values", […])`
197
+ glides every bar (the field lives under `sprite`, so the path does too —
198
+ `bars.values` resolves nothing). With `layout: { type: "list" }` a chart is one layer and
193
199
  its labels another — the dashboard genre stops needing one layer per number
194
200
  - `{ "kind": "polygon", "radius": [0.02, 0.05], "sides": 3, "color": "#f2e8c9", "soft": false }` —
195
201
  regular n-gon of the seeded circumradius, point up (`sides` 3..12, default
@@ -206,19 +212,79 @@ with distance, removing pop-in at the cutoff.
206
212
  than a line); `orient: true` turns the path's +x along the entity's heading,
207
213
  like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
208
214
  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" }` —
215
+ - `{ "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
216
  multi-line text block with deterministic line-breaking. All dimensions are
211
217
  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.
218
+ textBlock sprites at validation time. Without `anchor`, `position` is the
219
+ **top-left corner of the `maxWidth` layout box** regardless of `align`
220
+ (align moves text within the box, not the box itself — different from the
221
+ `text` sprite where `align`/`baseline` shift the meaning of `position`).
222
+ `maxWidth` may reach `2.0` — wider than the frame is legal (a wide caption
223
+ on a portrait screen); the `text-off-screen` advisory reports what runs
224
+ out. Line breaks are computed from a fixed character-class metrics table so
225
+ wrapping is identical across platforms; note that the table is approximate
226
+ — a painted line may slightly exceed `maxWidth` when the real font is wider
227
+ than the table estimates, so `maxWidth` is a layout target, not a hard clip.
220
228
  Use with `count: 1`, `motion: { type: "static" }`, and `position`.
221
229
 
230
+ `anchor` (one of nine compass points: `top-left`, `top`, `top-right`,
231
+ `left`, `center`, `right`, `bottom-left`, `bottom`, `bottom-right`) says
232
+ which point of the **rendered text** `position` names — the widest line
233
+ wide, as `align` lays it inside `maxWidth`, and `lines × lineHeight` tall,
234
+ measured after line-breaking. With `anchor: "center"` and
235
+ `position: { "x": 0.5, "y": 0.5 }` the block is centred on **every** aspect
236
+ ratio — `position` is in viewport fractions while `maxWidth` is in
237
+ `min(w,h)`, so hand-computing a centred `x` for one screen misses on the
238
+ next; the anchor removes the arithmetic. Under an anchor `align` only
239
+ shapes the ragged edge (shorter lines sit left/centre/right of the widest);
240
+ it no longer moves the ink. Anchor is placement, so it is part of the
241
+ structural signature (a change rebuilds); rotation stays about the anchor
242
+ point. The perception boxes (`text-overlap`, `text-off-screen`) follow the
243
+ same rule. Absent ⇒ exactly the pre-anchor behaviour above. **Native
244
+ clients that do not read `anchor` render as absent (layout-box top-left).**
245
+
246
+ `font` is a CSS family and/or weight/style — `"bold monospace"`,
247
+ `"300 'Inter', sans-serif"` — composed with the scaled `fontSize` the way
248
+ the `text` sprite composes a size-less font; a **size inside it is
249
+ rejected** (`fontSize` owns size, so the block keeps scaling with the
250
+ viewport). A monospace family (`monospace`, `ui-monospace`, Menlo, Courier,
251
+ SF Mono, Fira Code, …) switches the line-breaker to a uniform 0.6 em
252
+ advance so mono wraps land where the real face puts them; every other
253
+ family uses the proportional table. Default `system-ui, sans-serif`.
254
+ **Native clients fall back to the system face** until a face table exists.
255
+
256
+ `opacity` (0–1, default 1) multiplies the block's paint alpha — the whole
257
+ block, on top of the layer's per-entity `alpha`. It is **paint**, not
258
+ carpentry: excluded from the structural signature, so
259
+ `setParam("h.sprite.opacity", 0, { dur: 1500 })` glides the block out
260
+ without a rebuild (and back in with `1`). Declare `"opacity": 1` on the
261
+ block to make the path steerable (a `setParam` cannot create a field that
262
+ is not there). Scales perceived ink in the luminance grid. **Native clients
263
+ without `opacity` render at 1.**
264
+
265
+ `role` (`"read"` | `"atmosphere"`, on `textBlock` **and** `text`) declares
266
+ what the words are *for*, and is the only thing that turns legibility
267
+ checking on. Absent, or `"atmosphere"`, the layer is texture — a haiku
268
+ fading in a corner, dim labels on a wall board, the `dev-dashboard`'s
269
+ 2.6:1 telemetry — and `adviseSpec` says nothing about how readable it is,
270
+ exactly as before the field existed; keep atmospheric text undeclared, the
271
+ advisories would only nag. `"read"` says the words must be read from
272
+ across the room, and opts that layer into two advisories:
273
+ `text-legibility` fires when the text colour's WCAG ratio falls below
274
+ **4.5:1** against the background sampled at the box centre (gradient stops
275
+ interpolated at rest, `band` honoured) **or** against the brightest
276
+ additive layer (`blend: lighter` / `screen`) whose entities can reach the
277
+ box and are at least a glyph tall, taken at its peak alpha (base + pulse)
278
+ composited over that background — a glow parked under a caption is
279
+ 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`
280
+ fires when the box (a `list`'s whole extent) lies within **5 %** of any
281
+ viewport edge, where bezels and overscan hide it. Each is judged at the
282
+ viewport passed to `adviseSpec` — check the portrait one too. `role` is a
283
+ declaration only: it changes **no pixel anywhere, on any client**, is not
284
+ in the structural signature, and native players ignore it with nothing to
285
+ fall back to. The shipped `lobby-talk` example declares it on every text
286
+ layer and produces zero advisories, landscape and portrait.
287
+
222
288
  textBlock also accepts `reveal` — animated typing/deleting:
223
289
  `{ "reveal": { "progress": 1, "mode": "typewriter", "speed": 0, "caret": true } }`.
224
290
  Layout always runs on the **full** text; reveal only masks which glyphs are
@@ -241,9 +307,13 @@ with distance, removing pop-in at the cutoff.
241
307
  blink is in full cycles/sec, capped at 3 for flash safety, and is a square
242
308
  wave of `t` like every other animation). With `align: "center"`/`"right"`
243
309
  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.
310
+ left-aligned text reads as a classic typewriter. `text` itself is paint
311
+ that **steps**: a `setParam` swap or a sequence `morph` switches the string
312
+ whole on the first frame — nothing interpolates a string — so to "edit"
313
+ text live: glide `reveal.progress` to 0, swap `text` while nothing is
314
+ visible, glide back to 1 (or fade the block via its `color`). Inside a
315
+ sequence, a morph declared `text: 'crossfade'` cross-fades the old words
316
+ under the new ones instead (see **Transitions** below).
247
317
 
248
318
  `circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
249
319
  `colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
@@ -461,8 +531,10 @@ fixed-step warm-up (≤ 120 frames) from a full clear on any non-contiguous seek
461
531
  ## Steering
462
532
 
463
533
  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
534
+ `layers.0.count`, `layers.0.sprite.color`, `background.stops.0.color`, or
535
+ `key`-based paths like `cpu-gauge.count` / `cpu-gauge.sprite.color` when
536
+ layers declare `key` (the key replaces `layers.N`; everything after it
537
+ mirrors the JSON, so a sprite field keeps its `sprite.` segment). Changes interpolate over a
466
538
  control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
467
539
  a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
468
540
  for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
@@ -482,6 +554,13 @@ interpolation, and background drift is sampled at rest. These are documented
482
554
  trade-offs for a zero-dependency, renderer-free analysis tool.
483
555
 
484
556
  - `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
557
+ - `perceiveSequenceFrame(seq, T, {viewport?, seed?, releasedBelow?})` — the
558
+ same bundle for one frame of a **sequence** at global time `T`: resolves the
559
+ segment (reported as `segment: {index, key, localT, held?}`) and, when the
560
+ sequence has a `bed`, composes the bed at `T` under the segment's ink at
561
+ `localT` with the segment's background dropped, as the renderer stacks
562
+ them — the grids add, dominance ranks both by raw weight with bed layers
563
+ keyed `bed:<key|index>`, and text/motion/form list bed layers first.
485
564
  - `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
486
565
  (background gradient + entities + link lines, blend-aware), with coverage,
487
566
  visual-mass centroid, and **row/column deviation profiles** (1D transects of
@@ -520,10 +599,16 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
520
599
  `perceiveScene().advisories`). Two of them are **spatial**, for static text:
521
600
  `text-off-screen` (a text/textBlock box crosses the viewport edge by more
522
601
  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.
602
+ the smaller box; it carries both boxes as `boxes: [{x, y, w, h}]` in
603
+ viewport fractions so the fix needs no re-derivation). Boxes come from the
604
+ same character-class width table the textBlock line-breaker uses, so they
605
+ are estimates of the renderer's own layout — a caption that fails these
606
+ will look wrong on the wall; one that passes may still sit a few px off.
607
+ Two more are **opt-in** through the text sprite's `role: "read"` (see the
608
+ `textBlock` sprite): `text-legibility` (WCAG ratio below 4.5:1 against the
609
+ background at the box centre or under the brightest additive layer that can
610
+ sit beneath the line) and `text-safe-area` (the box within 5 % of an edge,
611
+ with its box in `boxes`). Undeclared text is never measured for either.
527
612
  `overlap-seams` catches the trap the other channels are blind to: a layer
528
613
  whose entities bury each other (so it was drawn as one shape) but whose paint
529
614
  settings make every overlap visible. It is gated to stay off deliberate
@@ -562,6 +647,8 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
562
647
  "label": "My Sequence",
563
648
  "seed": 42, // optional; forwarded to children without their own seed
564
649
  "loop": false,
650
+ "sync": "mount", // optional; 'mount' (default) or 'epoch' — see **Sync**
651
+ "bed": { /* SaverSpec */ }, // optional; the ground under every segment — see **Bed**
565
652
  "segments": [
566
653
  { "key": "intro", "scene": { /* SaverSpec */ }, "duration": 5000 },
567
654
  { "key": "main", "scene": { /* SaverSpec */ }, "duration": 10000, "advance": "auto" },
@@ -582,13 +669,61 @@ the presenter never freezes. A durationless final segment holds regardless of
582
669
  `advance`.
583
670
 
584
671
  **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.
672
+ `{ type: 'morph', dur: number, text?: 'step' | 'crossfade' }` interpolates
673
+ **numbers and hex colours** (colour, alpha, pulse, `reveal.progress`, …) over
674
+ `dur` ms when crossing into the next segment; **every other value — strings,
675
+ and `textBlock.text` above all — switches on the first morph frame** under
676
+ the default `text: 'step'`. A caption change therefore does not cross-fade
677
+ under a plain morph: fade text via its colour (glide it into the background
678
+ and back), via `reveal.progress` — or declare **`text: 'crossfade'`**, which
679
+ draws each `text` / `textBlock` layer whose string(s) differ between the two
680
+ segments **twice** for the window: the outgoing words at alpha `1 − k` under
681
+ the incoming at `k`, where `k` is the morph's eased progress, both in the
682
+ lerped frame's paint (colour, `opacity` and layer alpha glide as usual). Only
683
+ the differing text layers cost a second draw, and only for `dur`; layers
684
+ whose words match, and every non-text layer, are untouched. It is an
685
+ internal paint pass, not a spec field — no `opacity` appears on the `text`
686
+ sprite. `text` is a morph option only (a `fade` already cross-fades whole
687
+ frames; a `cut` has no window) and the validator rejects it elsewhere.
688
+ **Default `step` is today's behaviour byte for byte** — the sequence
689
+ baseline pins it — and a flip to `crossfade` by default would be a major
690
+ change, with the baseline regenerated. **Native:** tvOS **steps** the words (it
691
+ ignores `text`) but already cross-fades whole frames on every segment
692
+ change using the transition's `dur`, so the result on the Apple TV is
693
+ close to the web's. Morph requires structurally
694
+ identical adjacent segments (same `structuralSignature`); if they differ, the
695
+ engine falls back to cut and the validator emits a `morph-structural-mismatch`
696
+ warning. When the segments are structural twins whose only differences are
697
+ values morph steps (a text-only change), the morph runs but every frame of it
698
+ shows the incoming segment — it reads as a cut — and the validator emits a
699
+ `morph-nothing-morphable` warning (never an error; stored sequences stay
700
+ valid) — unless the transition declared `text: 'crossfade'` and the words
701
+ differ, in which case the words are the thing that morphs and the warning is
702
+ withheld. During a morph, entity placement inherits the outgoing segment's
703
+ seed — the incoming segment's own seed is unused. `dur` must be between 200
704
+ and 5000 ms.
705
+
706
+ `{ type: 'fade', dur: number }` is the general cross-fade, for segments that
707
+ have nothing in common: the outgoing segment stays alive on a canvas of its
708
+ own for `dur` ms and is composited over the incoming one at
709
+ `1 − easeSmooth(localT / dur)`, so both keep animating through the window. The
710
+ outgoing segment renders at `duration + localT` — it continues rather than
711
+ freezing (and if it was an `advance: 'input'` hold, it resumes from its
712
+ `duration`, not from wherever the hold had reached). A fade is always from the
713
+ previous segment in the list; under `loop: true` the **last** segment's `fade`
714
+ is the wrap's transition into segment 0. It works through the clicker too — a
715
+ `sequence.segment` steer lands at `localT` 0 of the fade. Same `dur` bounds
716
+ as morph, no structural requirement, and a fade only smooths luminance, so the
717
+ flash gate is untouched; it is the remedy for a `boundary-luminance-jump`
718
+ advisory. **Tier gate:** two live segments for `dur` is over the budget of the
719
+ lowest tiers, so a host on the `basic` (canvas2d only) or `minimal` capability
720
+ tier — passed as `capabilityTier` on the `SequenceMountContext`, the tier
721
+ `computeTier` from `@idle-screens/capabilities` reports — renders `fade` as
722
+ `cut`; absent ⇒ fade enabled. `adviseSequence` says so once per sequence with
723
+ the informational `fade-degrades-on-low-tier`. **Native:** tvOS already
724
+ cross-fades on every segment change using the transition's `dur`, so `fade`
725
+ matches the native player rather than diverging from it; native reads
726
+ `fade.dur` where it reads the morph `dur` today.
592
727
 
593
728
  **Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
594
729
  sums of durations. Half-open segments: `[start, start+duration)`. With
@@ -596,15 +731,102 @@ sums of durations. Half-open segments: `[start, start+duration)`. With
596
731
  a durationless final segment). An unreleased `advance: 'input'` hold blocks the
597
732
  wrap; after a wrap every hold is armed again.
598
733
 
734
+ **Sync:** `sync` names what the sequence clock is anchored to. `mount`
735
+ (the default, and today's behaviour for every stored sequence) starts `T` at
736
+ 0 when the viewer mounts — every joiner sees segment 0, which is what makes a
737
+ pre-roll a pre-roll. `epoch` lets the host seed the clock: the viewer passes
738
+ `sequenceBaseT` (ms already elapsed on the shared clock — idlescreens.com
739
+ passes `Date.now() − scene.epoch`) on the mount context
740
+ (`SequenceMountContext`, a `SaverContext` plus that one field), and the
741
+ instance starts at that `T` instead of 0, so every screen in a room resolves
742
+ the same segment. The trade-off: a viewer joining an `epoch` sequence lands
743
+ **mid-loop**, wherever the room is; `mount` keeps pre-roll semantics. Holds
744
+ are the same under both — `advance: 'input'` is armed for every viewer, so a
745
+ late joiner whose seeded clock is already past an unreleased hold lands **on**
746
+ the held segment (still animating), not past it, and the clicker releases it
747
+ from there. `epoch` without a `sequenceBaseT` starts at 0 (a host that does
748
+ not pass the hint loses nothing). **Native clients that anchor at their own
749
+ mount behave as `mount`** until they read the field and seed from the channel
750
+ epoch. The default is never flipped: pre-roll depends on `mount`.
751
+
752
+ **Bed:** `bed` is one SaverSpec drawn **under every segment on the
753
+ sequence's global clock** — the ground that does not reset. Every segment still
754
+ starts at its own `localT` 0 (builds replay, `emit` phases and `reveal.speed`
755
+ key off segment time exactly as before), but the bed's `T` runs from mount (or
756
+ from `sequenceBaseT` under `sync: 'epoch'`) straight through every boundary,
757
+ and a `sequence.segment` steer displaces the *segments'* clock only — the
758
+ clicker rewinds a slide, never the bed. That is the fix for the boundary
759
+ rewind every ambient reviewer flagged: put the motion that must be continuous
760
+ (the drifting field, the runner-orb, the slow gradient) in the bed and the
761
+ slide content in the segments. Rules:
762
+
763
+ - **The bed owns the ground.** Segments render over it *transparently*: a
764
+ segment's `background` is never painted while a bed exists (the validator
765
+ warns `bed-hides-segment-background` on each segment that declares one), and
766
+ a segment's `ghosting` is ignored (a smear needs an opaque ground to decay
767
+ into; the bed may declare its own `ghosting`, and it works as usual). A
768
+ segment's ink composites over the bed with its own `blend`/`alpha`.
769
+ - **Clock:** bed at `T`, segment at `localT`, in the same frame. Under
770
+ `loop: true` the bed does not wrap with the segments — it keeps counting.
771
+ - **Steering:** `bed.<path>` routes to the bed with the prefix stripped
772
+ (`setParam("bed.field.sprite.color", …)`, `bed.ghosting`, …);
773
+ `sequenceSteerablePaths(seq)` lists them. Without a bed, `bed.*` reaches the
774
+ segments unchanged, so a layer keyed `bed` keeps working. Bed steers are
775
+ not part of the retained segment track — the bed is never re-created.
776
+ - **Seed:** the bed uses its own `seed`, else `seq.seed + 24` (past every
777
+ segment's `seq.seed + index`, so it never shares a stream with segment 0 and
778
+ adding a segment does not re-seat it).
779
+ - **Perf accounting:** the bed is live alongside whichever segment is up, so
780
+ its entities count **together with the largest segment's** toward the 800
781
+ cap (`validateSequence` errors on `bed` when the sum is over) and toward the
782
+ manifest's `costTier`. A `fade` over a bed puts both segments on canvases of
783
+ their own for `dur` (incoming at k, outgoing at 1 − k, both over the bed):
784
+ three live instances on the lowest tier, which is why fade is tier-gated.
785
+ - **Perception:** `perceiveSequenceFrame(seq, T)` composes bed + segment (see
786
+ the perception API above). Bed and segments are assumed to share `units` /
787
+ `referenceViewport`.
788
+ - **Native:** tvOS **ignores `bed` initially** and renders segments with their
789
+ own backgrounds, exactly as it does today — so a sequence authored with a bed
790
+ should still carry sensible segment backgrounds until the native player
791
+ draws the bed (a second compiled scene drawn first, which its layer model
792
+ already supports). Web viewers hide those backgrounds; native shows them.
793
+ - **Not a default.** The "cheap form" — rendering a morph chain's root child at
794
+ `T − segmentStart(chainRoot)` so twins keep one continuous clock — is **not**
795
+ implemented and never will be as a default: stored morph-chained sequences
796
+ pin their per-segment `t = 0` frames (`sequence-baseline.test.ts`). A bed is
797
+ the supported way to keep motion continuous across segments; if a per-segment
798
+ `timebase: 'sequence'` is ever wanted it will be opt-in, after this.
799
+
800
+ Absent `bed` ⇒ the code path is byte for byte what it was (the sequence
801
+ baseline proves it): every segment paints its own ground.
802
+
599
803
  **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).
804
+ viewer needs zero changes (a host that wants `sync: 'epoch'` passes a
805
+ `SequenceMountContext`; a plain `SaverContext` still mounts). All children
806
+ share a single canvas; only the active segment's `SpecInstance` is alive at
807
+ any time — plus the `bed`'s, when one is declared, and for the `dur` of a
808
+ `fade` the outgoing segment on an offscreen canvas of its own. `workerReady`
809
+ is `false` (the worker compile-hook does not dispatch sequences).
603
810
 
604
811
  **Steering:** segment switching uses the `sequence.segment` delta path via
605
812
  `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`.
813
+ `SequenceInstance` intercepts this path before delegation, and `bed.<path>`
814
+ deltas go to the bed (see **Bed**); every other delta
815
+ is forwarded to the active segment's `applyTrack` **and retained** (last wins
816
+ per path, merged across calls). Segment instances are created lazily and
817
+ disposed at each boundary, so the retained set is re-applied to every segment
818
+ as it comes up: **a steer persists across segment changes and lands on the
819
+ segment that owns the path.** `bars.sprite.values` steered while the title
820
+ slide is up takes effect the moment the chart slide appears (by timer or by
821
+ clicker), and stays if the show leaves and returns; on segments without a
822
+ `bars` key the delta is simply a no-op, as is any delta whose value does not
823
+ validate on that particular segment (the rest of the set still applies). A
824
+ morph's two lerp endpoints carry the retained set too, so a steered colour
825
+ rides through the glide instead of vanishing for `dur` — but a steer that
826
+ lands while the morph itself is in progress takes effect immediately rather
827
+ than gliding over its own `dur`, since the morph's cross-fade is already the
828
+ active transition on that child. Pinned by `sequence.test.ts` →
829
+ "SequenceInstance — retained track".
608
830
 
609
831
  The steer **moves the clock, not the frame**: it displaces the timeline so the
610
832
  target segment starts at its own `localT` 0 (its `life.enter` build replays)
@@ -633,5 +855,7 @@ showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
633
855
  `nostalghia-candle` and `haiku` (restraint and text); and the 2026-09 trio —
634
856
  `pings` (emit + grow + ease: one event at a time), `facets` (polygon, stroke
635
857
  and feathered rect) and `relay-board` (list layout + bar: a chart in five
636
- layers). See [`src/examples/`](./src/examples/). The dashboard exercises the
858
+ layers); and `lobby-talk` (one screen of a quarter-in-review, every text
859
+ layer `role: "read"`, zero advisories — readable copy over additive
860
+ atmosphere). See [`src/examples/`](./src/examples/). The dashboard exercises the
637
861
  static/HUD subset at scale (34 layers of keyed, positioned text).