@idle-screens/schema 3.6.0 → 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 +396 -41
- package/dist/index.d.ts +276 -8
- package/dist/index.js +881 -99
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +126 -5
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
|
|
|
@@ -105,9 +108,11 @@ must exist, have `count: 1`, and not themselves orbit a layer.
|
|
|
105
108
|
|
|
106
109
|
With `units: "viewport"` (the default) every dimensional value — sizes, radii,
|
|
107
110
|
speeds, distances, wavelengths, stroke widths — is a fraction of
|
|
108
|
-
`min(width, height)`, so specs scale to any display.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
`min(width, height)`, so specs scale to any display. Text sprites with an
|
|
112
|
+
explicit `px` size in their `font` string (e.g. `"bold 14px monospace"`) are no
|
|
113
|
+
exception: the compiler rescales that number by
|
|
114
|
+
`min(width, height) / referenceViewport` so the text holds its apparent size,
|
|
115
|
+
and only `units: "px"` specs get it verbatim. See **Scale** below.
|
|
111
116
|
|
|
112
117
|
`ghosting` paints each frame over a faded copy of the previous one instead of
|
|
113
118
|
clearing: moving entities leave decaying after-images (Mystify smears, Matrix
|
|
@@ -154,7 +159,7 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
|
|
|
154
159
|
| `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
|
|
155
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 |
|
|
156
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` |
|
|
157
|
-
| `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`) |
|
|
158
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` |
|
|
159
164
|
|
|
160
165
|
`links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
|
|
@@ -168,9 +173,12 @@ with distance, removing pop-in at the cutoff.
|
|
|
168
173
|
|
|
169
174
|
- `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
|
|
170
175
|
glyph picked per entity (seeded); optional `cycle` rotates variants over time
|
|
171
|
-
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle":
|
|
172
|
-
— a `font` **with** a px size
|
|
173
|
-
(`
|
|
176
|
+
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ..., "role": "read" }`
|
|
177
|
+
— a `font` **with** a px size keeps that shorthand, its number rescaled to the
|
|
178
|
+
viewport (verbatim only under `units: "px"`); a family/weight only
|
|
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)
|
|
174
182
|
- `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
|
|
175
183
|
`soft` renders a radial-falloff glow orb instead of a hard disc
|
|
176
184
|
- `{ "kind": "ring", "radius": [0.002, 0.005], "color": "#d8f6ff", "width": 0.001 }` —
|
|
@@ -185,8 +193,9 @@ with distance, removing pop-in at the cutoff.
|
|
|
185
193
|
- `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
|
|
186
194
|
a data bar: entity *i* draws `length × values[i] / max` (viewport units),
|
|
187
195
|
`thickness` thick, growing from its position toward `direction` (`right`
|
|
188
|
-
default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.values", […])`
|
|
189
|
-
glides every bar
|
|
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
|
|
190
199
|
its labels another — the dashboard genre stops needing one layer per number
|
|
191
200
|
- `{ "kind": "polygon", "radius": [0.02, 0.05], "sides": 3, "color": "#f2e8c9", "soft": false }` —
|
|
192
201
|
regular n-gon of the seeded circumradius, point up (`sides` 3..12, default
|
|
@@ -203,19 +212,79 @@ with distance, removing pop-in at the cutoff.
|
|
|
203
212
|
than a line); `orient: true` turns the path's +x along the entity's heading,
|
|
204
213
|
like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
|
|
205
214
|
O'Keeffe's petal, Basquiat's scrawl
|
|
206
|
-
- `{ "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 }` —
|
|
207
216
|
multi-line text block with deterministic line-breaking. All dimensions are
|
|
208
217
|
viewport fractions (of `min(w,h)`), not px — `units: "px"` specs reject
|
|
209
|
-
textBlock sprites at validation time. `position` is
|
|
210
|
-
**top-left corner
|
|
211
|
-
not the box itself — different from the
|
|
212
|
-
shift the meaning of `position`).
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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.
|
|
217
228
|
Use with `count: 1`, `motion: { type: "static" }`, and `position`.
|
|
218
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
|
+
|
|
219
288
|
textBlock also accepts `reveal` — animated typing/deleting:
|
|
220
289
|
`{ "reveal": { "progress": 1, "mode": "typewriter", "speed": 0, "caret": true } }`.
|
|
221
290
|
Layout always runs on the **full** text; reveal only masks which glyphs are
|
|
@@ -238,9 +307,13 @@ with distance, removing pop-in at the cutoff.
|
|
|
238
307
|
blink is in full cycles/sec, capped at 3 for flash safety, and is a square
|
|
239
308
|
wave of `t` like every other animation). With `align: "center"`/`"right"`
|
|
240
309
|
the revealing line stays anchored to its alignment as it types — only
|
|
241
|
-
left-aligned text reads as a classic typewriter.
|
|
242
|
-
|
|
243
|
-
|
|
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).
|
|
244
317
|
|
|
245
318
|
`circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
|
|
246
319
|
`colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
|
|
@@ -304,6 +377,134 @@ an event, where the same ring pulsing forever reads as wallpaper. Long
|
|
|
304
377
|
drift a little, settle, fade" is one layer). `clock` is the other half: two
|
|
305
378
|
layers on the same clock are a duet, not a coincidence. The shipped `pings`
|
|
306
379
|
example is the whole idea in two layers.
|
|
380
|
+
## Scale — what the numbers actually look like
|
|
381
|
+
|
|
382
|
+
Every dimensional value is a fraction of `min(width, height)`. On a 1920×1080
|
|
383
|
+
display that divisor is **1080**, so `0.01` is 10.8 px there and the same
|
|
384
|
+
fraction of the short side on a phone or a 4K wall. Nothing below is a rule —
|
|
385
|
+
they are measured landmarks, because the most common authoring failure is not
|
|
386
|
+
a bad idea, it is an idea rendered two orders of magnitude too small or too
|
|
387
|
+
large to see. The px conversions and coverage figures are measured
|
|
388
|
+
(`perceiveScene` at 1920×1080, 40 opaque white entities on black, unless a row
|
|
389
|
+
says otherwise); the "reads as" readings and the recipe advice are judgment.
|
|
390
|
+
|
|
391
|
+
### `circle.radius` (a **radius**, so a dot is twice this wide)
|
|
392
|
+
|
|
393
|
+
| radius | px radius @1080p | px across | coverage (40) | reads as |
|
|
394
|
+
|---|---|---|---|---|
|
|
395
|
+
| `0.0005` | 0.5 | 1 | 0.9 % | star grain — texture, never the subject |
|
|
396
|
+
| `0.002` | 2.2 | 4 | 1.0 % | dust, embers, distant snow |
|
|
397
|
+
| `0.005` | 5.4 | 11 | 1.6 % | a distinct dot; graph node, firefly |
|
|
398
|
+
| `0.02` | 21.6 | 43 | 6.2 % | a clear disc — bokeh, a planet |
|
|
399
|
+
| `0.05` | 54 | 108 | 21 % | a focal object, or a soft glow field |
|
|
400
|
+
| `0.1` | 108 | 216 | 52 % | dominant form — sun, moon, one per scene |
|
|
401
|
+
| `0.2` | 216 | 432 | 88 % | 40 % of the short side; only as a soft glow plate |
|
|
402
|
+
|
|
403
|
+
**Read the coverage column with its floor in mind.** `perceiveScene` samples an
|
|
404
|
+
80 × 48 grid, so a cell is ~24 × 22 px at 1080p and anything smaller registers as
|
|
405
|
+
one cell per entity. That is why the top two rungs both land near 1 % despite a
|
|
406
|
+
16 × difference in area — below `radius ≈ 0.01` the column counts entities, not
|
|
407
|
+
pixels. It becomes an area measurement from `0.02` up.
|
|
408
|
+
|
|
409
|
+
The shipped examples put entity *fields* almost entirely in `0.0003 – 0.007`
|
|
410
|
+
and large forms in `0.03 – 0.185` (the biggest is a `ring` gauge in
|
|
411
|
+
`dev-dashboard`). The gap between is thinly populated
|
|
412
|
+
but not empty, and the exception is instructive: `aurora` runs 60–100-entity
|
|
413
|
+
layers at `radius [0.045, 0.11]`, because a soft additive orb that large is
|
|
414
|
+
atmosphere rather than an object. Within one scene, `lanterns`' three parallax
|
|
415
|
+
layers step by ~2.2 × and ~2.4 × per layer; steps much smaller than that read
|
|
416
|
+
as one layer with a wide size range rather than as depth.
|
|
417
|
+
|
|
418
|
+
The bottom two rungs are texture, not content, and `adviseSpec` says so: a
|
|
419
|
+
`0.002` field stops raising `sparse-scene` at ~70 entities, while a `0.0005`
|
|
420
|
+
field would need over a thousand — far past `LIMITS.maxPerLayer` (400) — so it
|
|
421
|
+
raises `sparse-scene` at every legal count and always needs a brighter layer
|
|
422
|
+
above it.
|
|
423
|
+
|
|
424
|
+
### `size` (emoji), `fontSize` (textBlock), `size` (text sprites)
|
|
425
|
+
|
|
426
|
+
`0.02` ≈ 22 px, `0.035` ≈ 38 px, `0.05` ≈ 54 px, `0.1` ≈ 108 px at 1080p.
|
|
427
|
+
Emoji fields in the examples sit at `0.011 – 0.078`; `haiku`, the one textBlock
|
|
428
|
+
example, sets `fontSize: 0.05`.
|
|
429
|
+
|
|
430
|
+
An explicit px size inside a `font` string (`"bold 14px monospace"`) is **not**
|
|
431
|
+
absolute: under the default `viewport` units the compiler rescales it by
|
|
432
|
+
`min(w, h) / referenceViewport`, so it holds its apparent size across displays
|
|
433
|
+
(14 px at 1080p becomes 28 px at 4K and 5 px on a phone). Only `units: "px"`
|
|
434
|
+
specs get the number verbatim. Two consequences: a font pinned for a 4K wall
|
|
435
|
+
becomes illegibly small on a phone-sized viewport, and a spec that genuinely
|
|
436
|
+
wants fixed pixels must say `units: "px"`.
|
|
437
|
+
|
|
438
|
+
### `speed` (viewport fractions per second)
|
|
439
|
+
|
|
440
|
+
| speed | px/s @1080p | crosses the short side | crosses a 16:9 width |
|
|
441
|
+
|---|---|---|---|
|
|
442
|
+
| `0.001` | 1.1 | 17 min | 30 min |
|
|
443
|
+
| `0.005` | 5.4 | 3.3 min | 5.9 min |
|
|
444
|
+
| `0.02` | 21.6 | 50 s | 89 s |
|
|
445
|
+
| `0.06` | 65 | 17 s | 30 s |
|
|
446
|
+
| `0.15` | 162 | 7 s | 12 s |
|
|
447
|
+
|
|
448
|
+
An ambient scene meant to live on a wall for hours wants its background layers
|
|
449
|
+
under `0.005` — anything faster resolves as traffic rather than atmosphere.
|
|
450
|
+
Above `0.06` a layer reads as an event (rain, warp, a passing comet). Speeds
|
|
451
|
+
are ranges and each entity draws its own, so a `[0.002, 0.008]` layer already
|
|
452
|
+
carries internal parallax.
|
|
453
|
+
|
|
454
|
+
### `links.maxDist`
|
|
455
|
+
|
|
456
|
+
Link density is governed by `maxDist` relative to the mean spacing between
|
|
457
|
+
entities, `sqrt(w·h / count) / min(w, h)` — for 40 entities at 1920×1080 that
|
|
458
|
+
is `0.21` (228 px), for 100 it is `0.13` (144 px). Measured at `k: 3`, count 40,
|
|
459
|
+
averaged over 15 seeds:
|
|
460
|
+
|
|
461
|
+
| `maxDist` | edges drawn (of `k·count`) | graph |
|
|
462
|
+
|---|---|---|
|
|
463
|
+
| 0.5 × spacing | ~12 % | scattered pairs; ~45 % of nodes isolated |
|
|
464
|
+
| 1.0 × spacing | ~45 % | a constellation with a few loners |
|
|
465
|
+
| 1.5 × spacing | ~60 % | usually one connected component, rarely an isolate |
|
|
466
|
+
| 2.0 × spacing | ~62 % | saturated — `k` is now the binding cap |
|
|
467
|
+
|
|
468
|
+
Past ~2 × spacing, raising `maxDist` costs distance tests and buys nothing;
|
|
469
|
+
raise `k` instead. `describeScene` reports `linksDrawn` / `linksExpected` and
|
|
470
|
+
`isolatedNodes`, so connectivity is checkable per spec rather than guessed —
|
|
471
|
+
worth doing, since the seed moves these by several points either way.
|
|
472
|
+
|
|
473
|
+
### Recipes
|
|
474
|
+
|
|
475
|
+
**Parallax depth.** Three or four layers where size and speed rise together —
|
|
476
|
+
scaling size alone reads as a size distribution, not as distance. `lanterns`'
|
|
477
|
+
ladder, which the numbers above are drawn from: `radius [0.0005, 0.0013]` /
|
|
478
|
+
`speed [0.0005, 0.002]` / `alpha [0.35, 1]` far, `radius [0.0014, 0.0028]` /
|
|
479
|
+
`speed [0.006, 0.013]` / `alpha [0.5, 0.9]` mid, `radius [0.0032, 0.006]` /
|
|
480
|
+
`speed [0.015, 0.026]` / `alpha [0.6, 1]` near. Note that alpha *narrows*
|
|
481
|
+
toward the front rather than simply rising — the far layer spans the whole
|
|
482
|
+
range because distance is what varies it; the near layer is uniformly present.
|
|
483
|
+
|
|
484
|
+
**Glow stacking.** `soft: true` + `blend: "lighter"` + `alpha` around 0.5–0.9,
|
|
485
|
+
with `pulse: { amp: 0.2, period: 3000 }` for breathing. Glow reads only against
|
|
486
|
+
a dark plate — over a pale background use `"screen"`. Do not assume a soft orb
|
|
487
|
+
covers what a hard disc of the same radius would: `perceiveScene` models the
|
|
488
|
+
halo as reaching 2.4 × the radius, so a large soft additive layer measures far
|
|
489
|
+
*more* coverage than its hard equivalent — ~2.5 × at `radius 0.05` — while a
|
|
490
|
+
small one measures *less*, ~0.5 × at `radius 0.005`, because the faint outer
|
|
491
|
+
halo falls under the per-cell visibility threshold. The crossover sits near
|
|
492
|
+
`radius 0.01`. Re-measure after switching `soft` or `blend` rather than
|
|
493
|
+
compensating by a rule of thumb.
|
|
494
|
+
|
|
495
|
+
**Graph web.** `count 40–100`, `radius [0.002, 0.005]`, a slow `drift`
|
|
496
|
+
(`speed [0.002, 0.007]`) so the topology keeps re-forming, and `links` with
|
|
497
|
+
`k: 3`, `maxDist` at 1.5 × mean spacing (`0.3` for 40 entities, `0.2` for 100),
|
|
498
|
+
`alpha: 0.15`, `width: 0.0005`. Link alpha wants to be much lower than the
|
|
499
|
+
nodes' — at 0.15 the web is a suggestion, at 0.5 it is a diagram. Add
|
|
500
|
+
`falloff: true` to fade edges toward the cutoff instead of popping them.
|
|
501
|
+
|
|
502
|
+
**Focal pin.** One `count: 1` layer with `motion: { type: "static" }` and an
|
|
503
|
+
explicit `position`, `radius` in the `0.05 – 0.15` band, over a field of
|
|
504
|
+
`0.002`-scale entities. The size ratio between the two is what makes it read as
|
|
505
|
+
a subject rather than the largest member of the crowd — an order of magnitude
|
|
506
|
+
or more, which is the gap the ladder above shows between the field rungs and
|
|
507
|
+
the focal ones.
|
|
307
508
|
|
|
308
509
|
## Determinism contract
|
|
309
510
|
|
|
@@ -330,8 +531,10 @@ fixed-step warm-up (≤ 120 frames) from a full clear on any non-contiguous seek
|
|
|
330
531
|
## Steering
|
|
331
532
|
|
|
332
533
|
Compiled specs accept live parameter changes via dot-paths —
|
|
333
|
-
`layers.0.count`, `background.stops.0.color`, or
|
|
334
|
-
`cpu-gauge.
|
|
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
|
|
335
538
|
control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
|
|
336
539
|
a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
|
|
337
540
|
for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
|
|
@@ -351,6 +554,13 @@ interpolation, and background drift is sampled at rest. These are documented
|
|
|
351
554
|
trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
352
555
|
|
|
353
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.
|
|
354
564
|
- `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
|
|
355
565
|
(background gradient + entities + link lines, blend-aware), with coverage,
|
|
356
566
|
visual-mass centroid, and **row/column deviation profiles** (1D transects of
|
|
@@ -389,10 +599,16 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
|
389
599
|
`perceiveScene().advisories`). Two of them are **spatial**, for static text:
|
|
390
600
|
`text-off-screen` (a text/textBlock box crosses the viewport edge by more
|
|
391
601
|
than 1%) and `text-overlap` (two static text layers share more than 10% of
|
|
392
|
-
the smaller box
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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.
|
|
396
612
|
`overlap-seams` catches the trap the other channels are blind to: a layer
|
|
397
613
|
whose entities bury each other (so it was drawn as one shape) but whose paint
|
|
398
614
|
settings make every overlap visible. It is gated to stay off deliberate
|
|
@@ -431,6 +647,8 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
|
|
|
431
647
|
"label": "My Sequence",
|
|
432
648
|
"seed": 42, // optional; forwarded to children without their own seed
|
|
433
649
|
"loop": false,
|
|
650
|
+
"sync": "mount", // optional; 'mount' (default) or 'epoch' — see **Sync**
|
|
651
|
+
"bed": { /* SaverSpec */ }, // optional; the ground under every segment — see **Bed**
|
|
434
652
|
"segments": [
|
|
435
653
|
{ "key": "intro", "scene": { /* SaverSpec */ }, "duration": 5000 },
|
|
436
654
|
{ "key": "main", "scene": { /* SaverSpec */ }, "duration": 10000, "advance": "auto" },
|
|
@@ -451,13 +669,61 @@ the presenter never freezes. A durationless final segment holds regardless of
|
|
|
451
669
|
`advance`.
|
|
452
670
|
|
|
453
671
|
**Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
|
|
454
|
-
`{ type: 'morph', dur: number }`
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
`
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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.
|
|
461
727
|
|
|
462
728
|
**Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
|
|
463
729
|
sums of durations. Half-open segments: `[start, start+duration)`. With
|
|
@@ -465,15 +731,102 @@ sums of durations. Half-open segments: `[start, start+duration)`. With
|
|
|
465
731
|
a durationless final segment). An unreleased `advance: 'input'` hold blocks the
|
|
466
732
|
wrap; after a wrap every hold is armed again.
|
|
467
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
|
+
|
|
468
803
|
**Compilation:** `compileSequence()` returns an ordinary `SaverPlugin` — the
|
|
469
|
-
viewer needs zero changes
|
|
470
|
-
|
|
471
|
-
|
|
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).
|
|
472
810
|
|
|
473
811
|
**Steering:** segment switching uses the `sequence.segment` delta path via
|
|
474
812
|
`applyTrack` (`setParam("sequence.segment", n)` over MCP). The
|
|
475
|
-
`SequenceInstance` intercepts this path before delegation
|
|
476
|
-
|
|
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".
|
|
477
830
|
|
|
478
831
|
The steer **moves the clock, not the frame**: it displaces the timeline so the
|
|
479
832
|
target segment starts at its own `localT` 0 (its `life.enter` build replays)
|
|
@@ -502,5 +855,7 @@ showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
|
|
|
502
855
|
`nostalghia-candle` and `haiku` (restraint and text); and the 2026-09 trio —
|
|
503
856
|
`pings` (emit + grow + ease: one event at a time), `facets` (polygon, stroke
|
|
504
857
|
and feathered rect) and `relay-board` (list layout + bar: a chart in five
|
|
505
|
-
layers)
|
|
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
|
|
506
861
|
static/HUD subset at scale (34 layers of keyed, positioned text).
|