@idle-screens/schema 2.2.0 → 2.4.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
@@ -1,9 +1,9 @@
1
1
  # SaverSpec — Format Specification (version 1)
2
2
 
3
3
  The declarative, agent-authorable screensaver format compiled by
4
- `@idle-screens/schema`. A spec describes a saver as **data** — a static
5
- background plus layers of moving sprites — which `compileSaver()` turns into a
6
- seeded, deterministic, flash-safe `SaverPlugin`. There is no code in a spec: no
4
+ `@idle-screens/schema`. A spec describes a saver as **data** — a background plus
5
+ layers of moving sprites — which `compileSaver()` turns into a seeded,
6
+ deterministic, flash-safe `SaverPlugin`. There is no code in a spec: no
7
7
  scripting, no network access, no DOM access.
8
8
 
9
9
  - **Machine-readable schema:** [`saver-spec.schema.json`](./saver-spec.schema.json)
@@ -23,23 +23,38 @@ older runtimes ignore unknown fields (see Validation semantics).
23
23
  Additions to version 1 so far, all optional and backward compatible:
24
24
  `alpha`, `blend`, `pulse`, `region` (layer appearance/placement);
25
25
  `static` motion, `position`, `key` (dashboard/HUD support);
26
- `align`, `baseline`, `maxWidth` (text sprites).
26
+ `align`, `baseline`, `maxWidth` (text sprites);
27
+ `spin`, `grow`, `cycle`, `orbit`, `links`, `units`, `referenceViewport`;
28
+ `trail`, `background.drift` (2026-07-19);
29
+ `ghosting`, `wander` / `warp` / `path` motions, `ring` / `streak` / `rect`
30
+ sprites, `colorWeights`, `pulse.wave`, `layout` (grid), `life`,
31
+ `links.mode/falloff/closed`, `blend: screen|multiply`, orbit layer-parents
32
+ (2026-07-21 — "the v1 ceiling").
27
33
 
28
34
  ## Safety invariants
29
35
 
30
36
  These hold **by construction** — no spec can violate them:
31
37
 
32
- 1. **No flash primitive.** The background is static and entities are bounded
33
- sprites, so a compiled spec cannot strobe the full field. Provable by
34
- sampling any compiled spec through `@idle-screens/validator`.
35
- 2. **Pulse is bounded.** Opacity breathing amplitude is capped at 0.5 and its
36
- period floored at 500 ms (2 Hz — under the WCAG 3 Hz flash threshold), and
37
- every entity gets its own seeded phase, so a layer can never pulse in unison.
38
- 3. **Motion is bounded.** Speeds are capped at 4000 px/sec.
39
- 4. **Work is bounded.** ≤ 36 layers, ≤ 400 entities per layer, ≤ 800 total.
38
+ 1. **No flash primitive.** The background is static (drift is floored at 10 s)
39
+ and entities are bounded sprites, so a compiled spec cannot strobe the full
40
+ field. Provable by sampling any compiled spec through
41
+ `@idle-screens/validator`.
42
+ 2. **Pulse/grow/cycle are bounded.** Breathing amplitude is capped and periods
43
+ floored at 500 ms (2 Hz — under the WCAG 3 Hz flash threshold). Every entity
44
+ gets its own seeded phase — and with `pulse.wave`, a position-derived phase —
45
+ so a layer will generally not pulse in unison (edge case: if spawn positions
46
+ alias the wavelength, phases may coincide). `ghosting` only *smooths* luminance
47
+ changes (it composites frames over a faded copy of the last), never sharpens
48
+ them.
49
+ 3. **Motion is bounded.** Speeds are capped (4000 px/sec; warp at 1.5
50
+ depth-units/sec; orbit at 180 deg/sec; path laps at ≥ 2 s).
51
+ 4. **Work is bounded.** ≤ 36 layers, ≤ 400 entities per layer, ≤ 800 total,
52
+ links layers ≤ 200 entities, trails ≤ 24 samples.
40
53
  5. **Determinism.** All randomness comes from a seeded PRNG. Same spec + same
41
54
  seed ⇒ identical entity streams and identical frames at any time `t`
42
- (compiled savers expose `renderFrame(t, seed)`).
55
+ (compiled savers expose `renderFrame(t, seed)`). With `ghosting`, seeks
56
+ replay a bounded fixed-step warm-up so even the accumulated smear is
57
+ reproducible.
43
58
 
44
59
  ## Validation semantics
45
60
 
@@ -52,9 +67,10 @@ Two validators exist with deliberately different strictness:
52
67
 
53
68
  Author against the JSON Schema; ship through the runtime validator.
54
69
 
55
- Two rules the JSON Schema cannot fully express (the runtime enforces them):
56
- total entities across all layers ≤ 800, and every `[min, max]` range must
57
- satisfy `min ≤ max`.
70
+ Rules the JSON Schema cannot fully express (the runtime enforces them):
71
+ total entities across all layers ≤ 800; every `[min, max]` range must satisfy
72
+ `min ≤ max`; `colorWeights` length must match `colors`; orbit layer-parents
73
+ must exist, have `count: 1`, and not themselves orbit a layer.
58
74
 
59
75
  ## Structure
60
76
 
@@ -65,17 +81,32 @@ satisfy `min ≤ max`.
65
81
  "label": "Snowfall",
66
82
  "seed": 42, // optional; falls back to the host's seed
67
83
  "motionIntensity": "calm", // optional: calm | moderate | energetic
84
+ "units": "viewport", // optional: viewport (default) | px
85
+ "referenceViewport": 1080, // optional; design resolution for density scaling
86
+ "ghosting": 0.9, // optional 0..0.95; frame-persistence smear
68
87
  "background": { ... }, // optional; defaults to black
69
88
  "layers": [ { ... }, ... ] // 1..36, rendered back-to-front
70
89
  }
71
90
  ```
72
91
 
92
+ With `units: "viewport"` (the default) every dimensional value — sizes, radii,
93
+ speeds, distances, wavelengths, stroke widths — is a fraction of
94
+ `min(width, height)`, so specs scale to any display. Exception: text sprites
95
+ with an explicit `px` size in their `font` string (e.g. `"bold 14px monospace"`)
96
+ use that CSS font verbatim and do not scale with the viewport.
97
+
98
+ `ghosting` paints each frame over a faded copy of the previous one instead of
99
+ clearing: moving entities leave decaying after-images (Mystify smears, Matrix
100
+ trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is off.
101
+
73
102
  ### `background`
74
103
 
75
104
  - `{ "type": "solid", "color": "#06060c" }`
76
- - `{ "type": "gradient", "stops": [{ "at": 0, "color": "#06121e" }, { "at": 1, "color": "#0d2436" }], "band": { "color": "#3a2d18", "height": 90 } }`
105
+ - `{ "type": "gradient", "stops": [{ "at": 0, "color": "#06121e" }, { "at": 1, "color": "#0d2436" }], "band": { "color": "#3a2d18", "height": 0.08 }, "drift": { "period": 30000, "amount": 0.12 } }`
77
106
  — vertical gradient (`at` 0 = top → 1 = bottom); optional solid `band` at the
78
- bottom (e.g. an aquarium seafloor). All colours are hex (`#rgb` / `#rrggbb`).
107
+ bottom (e.g. an aquarium seafloor); optional `drift` slowly oscillates the
108
+ stop positions (period ≥ 10 s) so the background breathes. All colours are
109
+ hex (`#rgb` / `#rrggbb`).
79
110
 
80
111
  ### `layers[]`
81
112
 
@@ -84,52 +115,157 @@ satisfy `min ≤ max`.
84
115
  | `count` | int 1..400 | — | entities in this layer |
85
116
  | `sprite` | SpriteSpec | — | what each entity looks like |
86
117
  | `motion` | MotionSpec | — | how entities move |
87
- | `size` | `[min,max]` px > 0 | sprite-dependent | font size for emoji/text; ignored for circle |
118
+ | `size` | `[min,max]` > 0 | sprite-dependent | font size for emoji/text; ignored for shaped sprites |
88
119
  | `wrap` | boolean | `true` | wrap to the opposite edge when leaving the viewport |
89
120
  | `flip` | boolean | `false` | mirror the sprite to face its heading |
90
121
  | `alpha` | `[min,max]` 0..1 | `[1,1]` | per-entity opacity range |
91
- | `blend` | `"lighter"` | source-over | additive compositing (glow stacking) |
122
+ | `blend` | `lighter` \| `screen` \| `multiply` | source-over | additive glow / gentle additive / darkening |
92
123
  | `region` | `{x?, y?}` ranges 0..1 | full viewport | fractional spawn window (placement only, not travel) |
93
- | `pulse` | `{amp ≤ 0.5, period ≥ 500}` | none | seeded-phase opacity breathing |
124
+ | `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing; `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
125
+ | `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) |
126
+ | `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
127
+ | `trail` | `{length ≤ 5000, fade?}` | none | analytic afterglow trail (ms of history) |
128
+ | `links` | see below | none | inter-entity lines |
129
+ | `layout` | `{type: "grid", columns?, jitter?}` | scatter | grid placement; `jitter` scalar or `{x?, y?}` 0..1 per axis |
130
+ | `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
94
131
  | `key` | string | none | addressable name → `setParam("key.field", …)` |
95
- | `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1`**; overrides `region` |
132
+ | `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1`**; overrides `region`/`layout` |
133
+
134
+ `links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
135
+ `mode: "nearest"` (default) wires each entity to its k nearest neighbors within
136
+ `maxDist`; `"chain"` wires entities in order (Mystify polygons — ignores
137
+ `k`/`maxDist`, `closed: true` closes the loop); `"random"` uses a fixed
138
+ deterministic wiring filtered by `maxDist`. `falloff: true` fades link alpha
139
+ with distance, removing pop-in at the cutoff.
96
140
 
97
141
  ### `sprite` (one of)
98
142
 
99
- - `{ "kind": "emoji", "glyphs": ["🐟", "🐠"] }` — glyph picked per entity (seeded)
100
- - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold 24px monospace", "align": "center", "baseline": "middle", "maxWidth": 300 }`
101
- - `{ "kind": "circle", "radius": [1, 3], "color": "#ffffff", "soft": true }` —
143
+ - `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
144
+ glyph picked per entity (seeded); optional `cycle` rotates variants over time
145
+ - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ... }`
146
+ — a `font` **with** a px size is used verbatim; a family/weight only
147
+ (`"bold monospace"`) composes with the seeded per-entity `size`
148
+ - `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
102
149
  `soft` renders a radial-falloff glow orb instead of a hard disc
150
+ - `{ "kind": "ring", "radius": [0.002, 0.005], "color": "#d8f6ff", "width": 0.001 }` —
151
+ unfilled circle (bubbles, portals, sonar)
152
+ - `{ "kind": "streak", "length": [0.01, 0.03], "color": "#cfd8ff", "width": 0.002 }` —
153
+ a line oriented along the entity's analytic heading with a faded tail
154
+ (rain that reads as rain, shooting stars, warp stars)
155
+ - `{ "kind": "rect", "width": [0.012, 0.02], "aspect": [1.3, 1.7], "color": "#ffb347" }` —
156
+ rectangle; `aspect` is the height/width ratio range (rotates with `spin`)
157
+
158
+ `circle`, `ring`, `streak`, and `rect` all accept `colors: [...]` (seeded
159
+ per-entity palette pick) and `colorWeights: [...]` (relative weights, same
160
+ length — "mostly cool tones, occasional ember").
103
161
 
104
162
  ### `motion` (one of)
105
163
 
106
- - `{ "type": "drift", "speed": [20, 60], "angle": 0, "bidirectional": true, "bob": 4 }`
164
+ - `{ "type": "drift", "speed": [0.02, 0.06], "angle": 0, "bidirectional": true, "bob": 0.004 }`
107
165
  — heading in degrees (0 = right, 90 = down); `bidirectional` flips horizontal
108
- direction per entity (fish); `bob` adds vertical wobble (px)
109
- - `{ "type": "rise", "speed": [10, 30], "sway": 6 }` — upward, with horizontal sway (bubbles)
110
- - `{ "type": "bounce", "speed": [120, 240] }` — diagonal, reflecting off edges
166
+ direction per entity (fish); `bob` adds vertical wobble
167
+ - `{ "type": "rise", "speed": [0.01, 0.03], "sway": 0.006 }` — upward, with horizontal sway (bubbles)
168
+ - `{ "type": "bounce", "speed": [0.12, 0.24] }` — diagonal, reflecting off edges
111
169
  - `{ "type": "static" }` — stays exactly where placed (use with `position` for HUD text)
170
+ - `{ "type": "orbit", "speed": [10, 40], "radius": [0.05, 0.2], "center": { "x": 0.5, "y": 0.5 } }`
171
+ — angular speed in deg/sec; `center` may instead be `{ "layer": "planet" }`
172
+ to orbit a single-entity parent layer (moons around a wandering planet;
173
+ strictly one level deep)
174
+ - `{ "type": "wander", "speed": [0.004, 0.012], "angle": 0, "meander": 0.1, "coherence": 0.7 }`
175
+ — organic harmonic drift: base velocity plus 3 seeded sine octaves per axis.
176
+ Flowing curved paths with zero simulation state. `meander` scales the
177
+ curvature; `coherence` 0..1 blends every entity toward a shared harmonic set
178
+ (1 = the layer undulates as one body — fake flocking)
179
+ - `{ "type": "warp", "speed": [0.1, 0.5], "center": { "x": 0.5, "y": 0.5 } }`
180
+ — perspective starfield: entities stream toward the viewer on a depth axis,
181
+ size/alpha/velocity scaling with 1/z. Pair with `streak` sprites for the
182
+ classic warp tunnel
183
+ - `{ "type": "path", "points": [{"x":0.1,"y":0.7}, ...], "duration": 30000, "curve": "smooth", "closed": true, "scatter": 0.02 }`
184
+ — choreographed spline (Catmull-Rom, or `linear`); 2..24 waypoints, lap time
185
+ ≥ 2 s; `closed` loops (default), open paths ping-pong; `scatter` offsets
186
+ entities sharing the path; each entity gets a seeded phase along it
112
187
 
113
- All speeds are px/sec ranges; each entity draws its own value (seeded).
188
+ All speeds are ranges; each entity draws its own value (seeded).
114
189
 
115
190
  ## Determinism contract
116
191
 
117
192
  Entity construction consumes the seeded RNG in a **fixed draw order** per layer.
118
193
  New optional fields only consume RNG draws when present, so adding features to
119
194
  the format never changes the entity stream of an existing spec — a spec authored
120
- against an older runtime renders bit-identically on a newer one.
195
+ against an older runtime renders bit-identically on a newer one
196
+ (`determinism-baseline.test.ts` guards this with snapshot streams).
197
+
198
+ Positions are **analytic**: `positionAt(entity, t)` is a pure function, so
199
+ `renderFrame(t, seed)` can seek anywhere instantly, trails sample the past for
200
+ free, and streak headings derive from a finite difference. `ghosting` is the
201
+ one frame-coupled effect, and it preserves seekability by replaying a bounded
202
+ fixed-step warm-up (≤ 120 frames) from a full clear on any non-contiguous seek.
121
203
 
122
204
  ## Steering
123
205
 
124
206
  Compiled specs accept live parameter changes via dot-paths —
125
207
  `layers.0.count`, `background.stops.0.color`, or `key`-based paths like
126
208
  `cpu-gauge.color` when layers declare `key`. Changes interpolate over a
127
- control-track (`step` | `linear` | `smooth`). See `@idle-screens/core` for
128
- `ControlTrack` and the idlescreens.com MCP `setParam` tool.
209
+ control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
210
+ a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
211
+ for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
212
+
213
+ ## Seeing without eyes — the perception API
214
+
215
+ `src/perceive.ts` translates a spec into modalities a **non-vision agent** can
216
+ reason about, computed analytically from the entity model (no canvas, no
217
+ renderer, deterministic, Node-safe). Note: the analytical model approximates
218
+ but does not perfectly match canvas rendering — blend modes are simplified
219
+ (`screen` ≈ `lighter`), wrapped link segments use straight interpolation, and
220
+ background drift is sampled at rest. These are documented trade-offs for a
221
+ zero-dependency, renderer-free analysis tool.
222
+
223
+ - `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
224
+ - `luminanceGrid(spec, opts)` — an 80×48 luminance image of the composed frame
225
+ (background gradient + entities + link lines, blend-aware), with coverage,
226
+ visual-mass centroid, and **row/column deviation profiles** (1D transects of
227
+ the composition).
228
+ - `renderBrailleMap(grid)` — the compact picture: 12 lines × 40 braille chars,
229
+ each char a 2×4 dot cell, ordered-dithered with auto-exposure. An agent reads
230
+ the whole frame at once instead of reconstructing it entity by entity.
231
+ - `renderDensityMap(grid)` — a **higher-fidelity** picture: one ASCII density
232
+ char (` .:-=+*#%@`) per grid cell, so it's 1:1 with the grid. Pair with a
233
+ larger `cols`/`rows` (e.g. `{cols: 120, rows: 48}`) to resolve structure the
234
+ braille map smears — concentric rings, grids, text blocks. This is the sharper
235
+ read when composition detail matters.
236
+ - `dominanceRanking(spec, opts)` — layers ranked by estimated visual weight
237
+ (area × alpha × contrast × glow/motion boosts; links count) — *where the eye
238
+ goes*, normalized to shares. Thin-but-bright structures (rings, streaks, link
239
+ lines) get a line-salience boost so they aren't crushed by filled discs.
240
+ - `motionStats(spec, opts)` — per-layer mean/max on-screen speed from analytic
241
+ displacement — choreography as numbers.
242
+ - `textSprites(spec, opts)` — the literal strings and rendered sizes of every
243
+ text layer. Glyphs are invisible in the luminance maps, so this is the only
244
+ way to confirm *what words* are on screen and how big. (Also on
245
+ `perceiveScene().text`.)
246
+ - `diffScenes(a, b, opts)` — **relative sight**: coverage/luminance deltas,
247
+ visual-balance shift, 3×3 region deltas, dominance-rank movement, and
248
+ advisory codes added/removed. Agents judge "is B better than A" far more
249
+ reliably than "is A good"; this is the edit loop's primary instrument.
250
+
251
+ Coverage is **additive-glow-calibrated**: soft circles drawn with
252
+ `blend: lighter`/`screen` bloom past their radius (as they do on the real
253
+ canvas), so `coverage` tracks what the audience sees instead of under-reporting
254
+ glow-heavy scenes by an order of magnitude.
255
+
256
+ Known approximations (deliberate): trails and ghosting are not sampled, text ink
257
+ is estimated from font size × string length. Good enough to perceive
258
+ composition, focus, balance, and motion — not a substitute for a human (or VLM)
259
+ judgement of beauty.
129
260
 
130
261
  ## Examples
131
262
 
132
- Shipped working specs (also exposed as `EXAMPLE_SPECS`): `aquarium`, `rain`,
133
- `snowfall`, `lanterns`, `sakura`, `dev-dashboard` — see
134
- [`src/examples.ts`](./src/examples.ts). The dashboard exercises the static/HUD
263
+ Shipped working specs (also exposed as `EXAMPLE_SPECS` /
264
+ `SCHEMA_EXAMPLES`): `aquarium`, `rain`, `snowfall`, `lanterns`, `sakura`,
265
+ `dev-dashboard`, `orrery`, `constellation`, `comets`, plus the v1-ceiling
266
+ showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
267
+ `warp-tunnel` (warp + streaks), `polygons` (chain links + heavy ghosting),
268
+ `matrix-rain` (grid layout + glyph cycle + ghosting), and `procession`
269
+ (path + layer-parented orbit + life staging + ring/rect sprites). See
270
+ [`src/examples/`](./src/examples/). The dashboard exercises the static/HUD
135
271
  subset at scale (34 layers of keyed, positioned text).