@idle-screens/schema 3.7.0 → 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
@@ -39,16 +39,20 @@ sprites, `colorWeights`, `pulse.wave`, `layout` (grid), `life`,
39
39
  (2026-09-05 — data layout); `textBlock.anchor` / `font` / `opacity`, the
40
40
  `maxWidth` cap lifted to 2.0, `sync` and `bed` on the sequence envelope,
41
41
  the `fade` transition, `role` on text sprites, and `text: 'crossfade'` on
42
- `morph` (2026-09-08 — ambient presentations).
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).
43
46
 
44
47
  ## Safety invariants
45
48
 
46
49
  These hold **by construction** — no spec can violate them:
47
50
 
48
- 1. **No flash primitive.** The background is static (drift is floored at 10 s)
49
- and entities are bounded sprites, so a compiled spec cannot strobe the full
50
- field. Provable by sampling any compiled spec through
51
- `@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`.
52
56
  2. **Pulse/grow/cycle are bounded.** Breathing amplitude is capped and periods
53
57
  floored at 500 ms (2 Hz — under the WCAG 3 Hz flash threshold). Every entity
54
58
  gets its own seeded phase — and with `pulse.wave`, a position-derived phase —
@@ -101,6 +105,7 @@ must exist, have `count: 1`, and not themselves orbit a layer.
101
105
  "units": "viewport", // optional: viewport (default) | px
102
106
  "referenceViewport": 1080, // optional; design resolution for density scaling
103
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
104
109
  "background": { ... }, // optional; defaults to black
105
110
  "layers": [ { ... }, ... ] // 1..36, rendered back-to-front
106
111
  }
@@ -136,6 +141,123 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
136
141
  bottom (e.g. an aquarium seafloor); optional `drift` slowly oscillates the
137
142
  stop positions (period ≥ 10 s) so the background breathes. All colours are
138
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.
139
261
 
140
262
  ### `layers[]`
141
263
 
@@ -151,7 +273,8 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
151
273
  | `blend` | `lighter` \| `screen` \| `multiply` | source-over | additive glow / gentle additive / darkening |
152
274
  | `region` | `{x?, y?}` ranges 0..1 | full viewport | fractional spawn window (placement only, not travel) |
153
275
  | `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing; `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
154
- | `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 |
155
278
  | `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
156
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) |
157
280
  | `links` | see below | none | inter-entity lines |
@@ -531,7 +654,8 @@ fixed-step warm-up (≤ 120 frames) from a full clear on any non-contiguous seek
531
654
  ## Steering
532
655
 
533
656
  Compiled specs accept live parameter changes via dot-paths —
534
- `layers.0.count`, `layers.0.sprite.color`, `background.stops.0.color`, or
657
+ `layers.0.count`, `layers.0.sprite.color`, `background.stops.0.color`,
658
+ `background.bands.2` (a field's band), or
535
659
  `key`-based paths like `cpu-gauge.count` / `cpu-gauge.sprite.color` when
536
660
  layers declare `key` (the key replaces `layers.N`; everything after it
537
661
  mirrors the JSON, so a sprite field keeps its `sprite.` segment). Changes interpolate over a
@@ -649,6 +773,7 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
649
773
  "loop": false,
650
774
  "sync": "mount", // optional; 'mount' (default) or 'epoch' — see **Sync**
651
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
652
777
  "segments": [
653
778
  { "key": "intro", "scene": { /* SaverSpec */ }, "duration": 5000 },
654
779
  { "key": "main", "scene": { /* SaverSpec */ }, "duration": 10000, "advance": "auto" },
@@ -843,6 +968,30 @@ whose slides carry `advance: 'input'`, and "next" is one `setParam`. Pinned by
843
968
  (offset by segment index for independence). Children with their own seed are
844
969
  unaffected.
845
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
+
846
995
  ## Examples
847
996
 
848
997
  Shipped working specs (also exposed as `EXAMPLE_SPECS` /
@@ -857,5 +1006,7 @@ showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
857
1006
  and feathered rect) and `relay-board` (list layout + bar: a chart in five
858
1007
  layers); and `lobby-talk` (one screen of a quarter-in-review, every text
859
1008
  layer `role: "read"`, zero advisories — readable copy over additive
860
- atmosphere). See [`src/examples/`](./src/examples/). The dashboard exercises the
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
861
1012
  static/HUD subset at scale (34 layers of keyed, positioned text).