@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 +159 -8
- package/dist/index.d.ts +367 -14
- package/dist/index.js +723 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +121 -0
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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`,
|
|
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)
|
|
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).
|