@idle-screens/schema 3.4.5 → 3.6.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
@@ -32,7 +32,11 @@ sprites, `colorWeights`, `pulse.wave`, `layout` (grid), `life`,
32
32
  `links.mode/falloff/closed`, `blend: screen|multiply`, orbit layer-parents
33
33
  (2026-07-21 — "the v1 ceiling");
34
34
  `textBlock` sprite — deterministic multi-line text with viewport-unit sizing;
35
- `textBlock.reveal` — animated typing/deleting via one steerable paint param.
35
+ `textBlock.reveal` — animated typing/deleting via one steerable paint param;
36
+ `emit` (sparse events), `clock` (phase-lock), `motion.ease` (settle / buoyant)
37
+ (2026-09-05 — time structure); `polygon` / `stroke` sprites and `rect.feather`
38
+ (2026-09-05 — shape glyphs); `layout: list | table` and the `bar` sprite
39
+ (2026-09-05 — data layout).
36
40
 
37
41
  ## Safety invariants
38
42
 
@@ -48,7 +52,10 @@ These hold **by construction** — no spec can violate them:
48
52
  so a layer will generally not pulse in unison (edge case: if spawn positions
49
53
  alias the wavelength, phases may coincide). `ghosting` only *smooths* luminance
50
54
  changes (it composites frames over a faded copy of the last), never sharpens
51
- them.
55
+ them. `clock` deliberately removes the per-entity phase, so the validator
56
+ floors a clocked layer's periods at 1000 ms (1 Hz) instead — a layer in
57
+ unison breathes, it never flashes. `emit` events are floored at one per
58
+ second per entity and always ride a smooth envelope, never a cut.
52
59
  3. **Motion is bounded.** Speeds are capped (4000 px/sec; warp at 1.5
53
60
  depth-units/sec; orbit at 180 deg/sec; path laps at ≥ 2 s).
54
61
  4. **Work is bounded.** ≤ 36 layers, ≤ 400 entities per layer, ≤ 800 total,
@@ -72,7 +79,10 @@ Author against the JSON Schema; ship through the runtime validator.
72
79
 
73
80
  Rules the JSON Schema cannot fully express (the runtime enforces them):
74
81
  total entities across all layers ≤ 800; every `[min, max]` range must satisfy
75
- `min ≤ max`; `colorWeights` length must match `colors`; orbit layer-parents
82
+ `min ≤ max`; `colorWeights` length must match `colors`; `emit.life ≤
83
+ emit.every`; a `clock`ed layer's `pulse`/`grow`/`cycle` period must satisfy
84
+ `period / rate ≥ 1000`; a `polygon` takes `sides` or `points`, not both (the
85
+ JSON Schema enforces this one too); orbit layer-parents
76
86
  must exist, have `count: 1`, and not themselves orbit a layer.
77
87
 
78
88
  ## Structure
@@ -84,6 +94,7 @@ must exist, have `count: 1`, and not themselves orbit a layer.
84
94
  "label": "Snowfall",
85
95
  "seed": 42, // optional; falls back to the host's seed
86
96
  "motionIntensity": "calm", // optional: calm | moderate | energetic
97
+ "density": "normal", // optional: sparse | normal | dense — declared intent, see below
87
98
  "units": "viewport", // optional: viewport (default) | px
88
99
  "referenceViewport": 1080, // optional; design resolution for density scaling
89
100
  "ghosting": 0.9, // optional 0..0.95; frame-persistence smear
@@ -102,6 +113,16 @@ use that CSS font verbatim and do not scale with the viewport.
102
113
  clearing: moving entities leave decaying after-images (Mystify smears, Matrix
103
114
  trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is off.
104
115
 
116
+ `density` declares what the emptiness or the crowding means. `sparse` says
117
+ the scene is *meant* to be almost empty — one faint mark on a dark ground,
118
+ long silences — so `adviseSpec` withholds `sparse-scene` and a scorer that
119
+ gates on coverage should read the declaration before calling a faithful
120
+ scene broken. `dense` withholds `dense-scene` the same way. The declaration
121
+ is checked, not trusted: a `sparse` scene whose alpha-weighted coverage
122
+ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
123
+ `density-mismatch` advisory instead. Omitted means `normal`. Like
124
+ `motionIntensity`, it is a hint — it changes no pixel.
125
+
105
126
  ### `background`
106
127
 
107
128
  - `{ "type": "solid", "color": "#06060c" }`
@@ -127,12 +148,14 @@ trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is of
127
148
  | `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing; `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
128
149
  | `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) |
129
150
  | `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
130
- | `trail` | `{length ≤ 5000, fade?}` | none | analytic afterglow trail (ms of history) |
151
+ | `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) |
131
152
  | `links` | see below | none | inter-entity lines |
132
- | `layout` | `{type: "grid", columns?, jitter?}` | scatter | grid placement; `jitter` scalar or `{x?, y?}` 0..1 per axis |
153
+ | `layout` | `{type: "grid", columns?, jitter?}` \| `{type: "list", gap?}` \| `{type: "table", columns, gap?}` | scatter | `grid`: cells fill `region`, `jitter` scalar or `{x?, y?}` 0..1 per axis. **`list` / `table`: data layouts** — entities in reading order from `position` (any count) or centred in `region`, `gap` apart (viewport units of `min(w,h)`, default 0.06); text/emoji take `strings[i]` / `glyphs[i]` and palettes `colors[i]` **in order**, so N labels are one layer |
133
154
  | `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
155
+ | `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
+ | `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` |
134
157
  | `key` | string | none | addressable name → `setParam("key.field", …)` |
135
- | `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1`**; overrides `region`/`layout` |
158
+ | `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` |
136
159
 
137
160
  `links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
138
161
  `mode: "nearest"` (default) wires each entity to its k nearest neighbors within
@@ -155,8 +178,31 @@ with distance, removing pop-in at the cutoff.
155
178
  - `{ "kind": "streak", "length": [0.01, 0.03], "color": "#cfd8ff", "width": 0.002 }` —
156
179
  a line oriented along the entity's analytic heading with a faded tail
157
180
  (rain that reads as rain, shooting stars, warp stars)
158
- - `{ "kind": "rect", "width": [0.012, 0.02], "aspect": [1.3, 1.7], "color": "#ffb347" }` —
159
- rectangle; `aspect` is the height/width ratio range (rotates with `spin`)
181
+ - `{ "kind": "rect", "width": [0.012, 0.02], "aspect": [1.3, 1.7], "color": "#ffb347", "feather": 0.6 }` —
182
+ rectangle; `aspect` is the height/width ratio range (rotates with `spin`);
183
+ `feather` 0..1 softens the edges — that fraction of the half-size fades out
184
+ toward the border (Rothko's block at 0.5–0.8)
185
+ - `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
186
+ a data bar: entity *i* draws `length × values[i] / max` (viewport units),
187
+ `thickness` thick, growing from its position toward `direction` (`right`
188
+ default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.values", […])`
189
+ glides every bar. With `layout: { type: "list" }` a chart is one layer and
190
+ its labels another — the dashboard genre stops needing one layer per number
191
+ - `{ "kind": "polygon", "radius": [0.02, 0.05], "sides": 3, "color": "#f2e8c9", "soft": false }` —
192
+ regular n-gon of the seeded circumradius, point up (`sides` 3..12, default
193
+ 6); or `"points": [[-1, 0.9], [0.2, -1], [1, 0.4]]` — 3..24 unit
194
+ coordinates in −1..1 scaled by the radius — for any facet (a Picasso shard,
195
+ a Kandinsky triangle, a Mondrian plane). `soft` feathers the fill from the
196
+ centre. Rotates with `spin`. `points` are paint, so two polygons with the
197
+ same point count `morph` into each other
198
+ - `{ "kind": "stroke", "length": [0.08, 0.18], "points": [[-1, 0.3], [-0.5, -0.6], [0.3, -0.7], [1, 0.1]], "width": 0.004, "taper": true, "color": "#f2e8c9" }` —
199
+ a freehand mark: a path through 2..24 unit-coordinate points, scaled so the
200
+ unit box spans the seeded `length`, stroked `width` wide with round joins.
201
+ `curve: "smooth"` (default) is a Catmull-Rom spline, `"linear"` a polyline;
202
+ `taper` thins the mark to almost nothing at both ends (a brush stroke rather
203
+ than a line); `orient: true` turns the path's +x along the entity's heading,
204
+ like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
205
+ O'Keeffe's petal, Basquiat's scrawl
160
206
  - `{ "kind": "textBlock", "text": "Multi-line text with wrapping.", "maxWidth": 0.8, "fontSize": 0.04, "lineHeight": 1.4, "align": "left", "color": "#e6e8ef" }` —
161
207
  multi-line text block with deterministic line-breaking. All dimensions are
162
208
  viewport fractions (of `min(w,h)`), not px — `units: "px"` specs reject
@@ -180,7 +226,14 @@ with distance, removing pop-in at the cutoff.
180
226
  frontier per grapheme (`typewriter`, default), per `word`, or per `line`.
181
227
  `speed` (graphemes/sec, max 120) makes the block type itself from scene
182
228
  start: effective progress is `min(progress, speed·t/total)`, so a steered
183
- `progress` can still hold or delete a self-typing block. `caret` draws a
229
+ `progress` can still hold or delete a self-typing block. A fourth mode,
230
+ `glyphFade`, fades each glyph in over a staggered alpha ramp instead of
231
+ popping it whole (the caption look); `fade` (0–1 exclusive of 0, default
232
+ 0.15, `glyphFade` only) is each glyph's fade window as a fraction of
233
+ progress — small reads as a soft typewriter, large as a wave of
234
+ overlapping fades. Still a pure function of `progress`, so it glides and
235
+ deletes exactly like the other modes. Native clients render `glyphFade`
236
+ as `typewriter` until they gain per-glyph alpha. `caret` draws a
184
237
  blinking caret at the frontier (`true`, or `{ "blink": 1.2, "color": "#fff" }`;
185
238
  blink is in full cycles/sec, capped at 3 for flash safety, and is a square
186
239
  wave of `t` like every other animation). With `align: "center"`/`"right"`
@@ -189,9 +242,20 @@ with distance, removing pop-in at the cutoff.
189
242
  glide `reveal.progress` to 0, swap `text` while nothing is visible, glide
190
243
  back to 1.
191
244
 
192
- `circle`, `ring`, `streak`, and `rect` all accept `colors: [...]` (seeded
193
- per-entity palette pick) and `colorWeights: [...]` (relative weights, same
194
- length — "mostly cool tones, occasional ember").
245
+ `circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
246
+ `colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
247
+ (relative weights, same length — "mostly cool tones, occasional ember").
248
+
249
+ **Drawing a shape out of sprites.** Overlapping sprites merge into one seamless
250
+ silhouette under exactly one set of conditions: a single flat colour, `alpha`
251
+ of `[1, 1]`, no `blend`, no `pulse`, and hard edges (no `soft`, no
252
+ `rect.feather`). Anything else leaves every overlap visible as an internal edge,
253
+ so the layer reads as a pile of sprites rather than a form — `soft` in
254
+ particular reads as smoke, not mass. Depth then comes from *layer order* and a
255
+ different flat colour per layer, painting back to front, and a later layer can
256
+ be used to cut a clean edge across an earlier one. `layerCohesion` measures
257
+ which of the two you got; `overlap-seams` warns when you asked for the first
258
+ and configured the second.
195
259
 
196
260
  ### `motion` (one of)
197
261
 
@@ -221,6 +285,26 @@ length — "mostly cool tones, occasional ember").
221
285
 
222
286
  All speeds are ranges; each entity draws its own value (seeded).
223
287
 
288
+ `drift`, `rise` and `wander` also accept **`ease`** — closed-form velocity
289
+ shaping, no integration state:
290
+ `{ "ease": { "type": "settle", "tau": 2500 } }` starts at the entity's speed
291
+ and decelerates to rest with time constant `tau` ms (it travels `speed × tau`
292
+ and stops — a mark flung and coming to rest); `"buoyant"` starts at rest and
293
+ approaches the speed over `tau` (a bubble reaching terminal velocity). With
294
+ `emit`, the eased travel restarts from the spawn point on every event. For
295
+ `wander` only the base velocity eases — the harmonic meander keeps breathing,
296
+ so a settled wanderer hovers rather than freezes.
297
+
298
+ **Time structure, composed.** `emit` is how a scene does *almost nothing,
299
+ almost never*: one ring every twelve seconds that expands and fades
300
+ (`emit: { every: 12000, life: 5000, jitter: 0, grow: [0.15, 2.4] }`) reads as
301
+ an event, where the same ring pulsing forever reads as wallpaper. Long
302
+ `every`, low `count`, `jitter: 0` for a metronome, `1` for weather; pair with
303
+ `ease: settle` so a mark is flung and comes to rest before it fades ("expand,
304
+ drift a little, settle, fade" is one layer). `clock` is the other half: two
305
+ layers on the same clock are a duet, not a coincidence. The shipped `pings`
306
+ example is the whole idea in two layers.
307
+
224
308
  ## Determinism contract
225
309
 
226
310
  Entity construction consumes the seeded RNG in a **fixed draw order** per layer.
@@ -286,10 +370,39 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
286
370
  line-salience boost so they aren't crushed by filled discs.
287
371
  - `motionStats(spec, opts)` — per-layer mean/max on-screen speed from analytic
288
372
  displacement — choreography as numbers.
373
+ - `layerCohesion(spec, opts)` — **does a layer read as one form or as N marks?**
374
+ The luminance maps measure ink, not edges, so a layer whose sprites merged
375
+ into a silhouette and one that stayed a pile of discs are identical in every
376
+ other channel. Two independent facts, both reported (also on
377
+ `perceiveScene().form`): `overlap` is geometry — the mean fraction of an
378
+ entity's outline buried inside a sibling (shipped particle fields measure
379
+ ≤ 0.154; a packed silhouette measures 0.79–0.85; `null` for lines, glyphs and
380
+ text, where a merged silhouette is not a meaningful idea). `seamless` is
381
+ paint — whether those overlaps vanish or draw an internal edge, with
382
+ `seamCause` naming the field to change. `reads` combines them into `mass`,
383
+ `seamed` or `marks`.
289
384
  - `textSprites(spec, opts)` — the literal strings and rendered sizes of every
290
385
  text layer. Glyphs are invisible in the luminance maps, so this is the only
291
386
  way to confirm *what words* are on screen and how big. (Also on
292
387
  `perceiveScene().text`.)
388
+ - `adviseSpec(spec)` — non-blocking advisories (also on
389
+ `perceiveScene().advisories`). Two of them are **spatial**, for static text:
390
+ `text-off-screen` (a text/textBlock box crosses the viewport edge by more
391
+ than 1%) and `text-overlap` (two static text layers share more than 10% of
392
+ the smaller box). Boxes come from the same character-class width table the
393
+ textBlock line-breaker uses, so they are estimates of the renderer's own
394
+ layout — a caption that fails these will look wrong on the wall; one that
395
+ passes may still sit a few px off.
396
+ `overlap-seams` catches the trap the other channels are blind to: a layer
397
+ whose entities bury each other (so it was drawn as one shape) but whose paint
398
+ settings make every overlap visible. It is gated to stay off deliberate
399
+ washes — it needs at least 6 entities and a mean base alpha of 0.45, and
400
+ never fires under `blend: lighter` / `screen`. No shipped example trips it.
401
+ The two density advisories read the spec's declared `density` first:
402
+ `sparse-scene` is withheld under `density: "sparse"` and `dense-scene`
403
+ under `"dense"`, and `density-mismatch` fires instead when the measured
404
+ coverage contradicts the declaration (a "sparse" scene covering more than
405
+ 2 % of the frame, a "dense" one that would have read as empty).
293
406
  - `diffScenes(a, b, opts)` — **relative sight**: coverage/luminance deltas,
294
407
  visual-balance shift, 3×3 region deltas, dominance-rank movement, and
295
408
  advisory codes added/removed. Agents judge "is B better than A" far more
@@ -330,8 +443,12 @@ timeline. Discriminated from SaverSpec by `format: 'idle-sequence'`.
330
443
  be unique. Duration is in milliseconds (minimum 1000 ms for flash safety). Only
331
444
  the final segment may omit duration (holds indefinitely).
332
445
 
333
- **Advance mode:** `auto` (default), `input`, or `either`. Validated but not
334
- wired to runtime behavior — timer/input drivers are planned for a follow-up.
446
+ **Advance mode:** `auto` (default) and `either` advance on the timer. `input`
447
+ makes a timed segment **hold** at the end of its `duration` until a
448
+ `sequence.segment` steer releases it — the clicker (see Steering below). The
449
+ held scene keeps animating (its `localT` keeps growing), so a slide waiting for
450
+ the presenter never freezes. A durationless final segment holds regardless of
451
+ `advance`.
335
452
 
336
453
  **Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
337
454
  `{ type: 'morph', dur: number }` smoothly interpolates paint properties
@@ -345,7 +462,8 @@ seed is unused. `dur` must be between 200 and 5000 ms.
345
462
  **Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
346
463
  sums of durations. Half-open segments: `[start, start+duration)`. With
347
464
  `loop: true`, `T` wraps at the sum of all durations (loop is incompatible with
348
- a durationless final segment).
465
+ a durationless final segment). An unreleased `advance: 'input'` hold blocks the
466
+ wrap; after a wrap every hold is armed again.
349
467
 
350
468
  **Compilation:** `compileSequence()` returns an ordinary `SaverPlugin` — the
351
469
  viewer needs zero changes. All children share a single canvas; only the active
@@ -353,8 +471,20 @@ segment's `SpecInstance` is alive at any time. `workerReady` is `false` (the
353
471
  worker compile-hook does not dispatch sequences).
354
472
 
355
473
  **Steering:** segment switching uses the `sequence.segment` delta path via
356
- `applyTrack`. The `SequenceInstance` intercepts this path before delegation.
357
- Remaining deltas are forwarded to the active child's `applyTrack`.
474
+ `applyTrack` (`setParam("sequence.segment", n)` over MCP). The
475
+ `SequenceInstance` intercepts this path before delegation; remaining deltas are
476
+ forwarded to the active child's `applyTrack`.
477
+
478
+ The steer **moves the clock, not the frame**: it displaces the timeline so the
479
+ target segment starts at its own `localT` 0 (its `life.enter` build replays)
480
+ and then runs on the timer from there — the next animation frame resolves to
481
+ the same segment instead of snapping back to the wall clock. A steer to
482
+ segment `n` also counts as the presenter clicking past every `advance: 'input'`
483
+ hold before `n`; holds at and after `n` stay armed, so steering backwards
484
+ re-arms the ones in between. This is the whole clicker: a deck is a sequence
485
+ whose slides carry `advance: 'input'`, and "next" is one `setParam`. Pinned by
486
+ `sequence.test.ts` → "sequence.segment steering is sticky" and
487
+ "advance: 'input' holds until released".
358
488
 
359
489
  **Seed:** `seq.seed` is forwarded to children that lack a scene-level seed
360
490
  (offset by segment index for independence). Children with their own seed are
@@ -368,6 +498,9 @@ Shipped working specs (also exposed as `EXAMPLE_SPECS` /
368
498
  showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
369
499
  `warp-tunnel` (warp + streaks), `polygons` (chain links + heavy ghosting),
370
500
  `matrix-rain` (grid layout + glyph cycle + ghosting), and `procession`
371
- (path + layer-parented orbit + life staging + ring/rect sprites). See
372
- [`src/examples/`](./src/examples/). The dashboard exercises the static/HUD
373
- subset at scale (34 layers of keyed, positioned text).
501
+ (path + layer-parented orbit + life staging + ring/rect sprites);
502
+ `nostalghia-candle` and `haiku` (restraint and text); and the 2026-09 trio —
503
+ `pings` (emit + grow + ease: one event at a time), `facets` (polygon, stroke
504
+ and feathered rect) and `relay-board` (list layout + bar: a chart in five
505
+ layers). See [`src/examples/`](./src/examples/). The dashboard exercises the
506
+ static/HUD subset at scale (34 layers of keyed, positioned text).