@idle-screens/schema 3.5.0 → 3.6.1
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 +249 -17
- package/dist/index.d.ts +318 -3
- package/dist/index.js +1041 -62
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +285 -36
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`;
|
|
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
|
|
@@ -94,14 +105,26 @@ must exist, have `count: 1`, and not themselves orbit a layer.
|
|
|
94
105
|
|
|
95
106
|
With `units: "viewport"` (the default) every dimensional value — sizes, radii,
|
|
96
107
|
speeds, distances, wavelengths, stroke widths — is a fraction of
|
|
97
|
-
`min(width, height)`, so specs scale to any display.
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
`min(width, height)`, so specs scale to any display. Text sprites with an
|
|
109
|
+
explicit `px` size in their `font` string (e.g. `"bold 14px monospace"`) are no
|
|
110
|
+
exception: the compiler rescales that number by
|
|
111
|
+
`min(width, height) / referenceViewport` so the text holds its apparent size,
|
|
112
|
+
and only `units: "px"` specs get it verbatim. See **Scale** below.
|
|
100
113
|
|
|
101
114
|
`ghosting` paints each frame over a faded copy of the previous one instead of
|
|
102
115
|
clearing: moving entities leave decaying after-images (Mystify smears, Matrix
|
|
103
116
|
trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is off.
|
|
104
117
|
|
|
118
|
+
`density` declares what the emptiness or the crowding means. `sparse` says
|
|
119
|
+
the scene is *meant* to be almost empty — one faint mark on a dark ground,
|
|
120
|
+
long silences — so `adviseSpec` withholds `sparse-scene` and a scorer that
|
|
121
|
+
gates on coverage should read the declaration before calling a faithful
|
|
122
|
+
scene broken. `dense` withholds `dense-scene` the same way. The declaration
|
|
123
|
+
is checked, not trusted: a `sparse` scene whose alpha-weighted coverage
|
|
124
|
+
exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
|
|
125
|
+
`density-mismatch` advisory instead. Omitted means `normal`. Like
|
|
126
|
+
`motionIntensity`, it is a hint — it changes no pixel.
|
|
127
|
+
|
|
105
128
|
### `background`
|
|
106
129
|
|
|
107
130
|
- `{ "type": "solid", "color": "#06060c" }`
|
|
@@ -129,10 +152,12 @@ trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is of
|
|
|
129
152
|
| `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
|
|
130
153
|
| `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
154
|
| `links` | see below | none | inter-entity lines |
|
|
132
|
-
| `layout` | `{type: "grid", columns?, jitter?}` | scatter | grid
|
|
155
|
+
| `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
156
|
| `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
|
|
157
|
+
| `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 |
|
|
158
|
+
| `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
159
|
| `key` | string | none | addressable name → `setParam("key.field", …)` |
|
|
135
|
-
| `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1
|
|
160
|
+
| `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
161
|
|
|
137
162
|
`links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
|
|
138
163
|
`mode: "nearest"` (default) wires each entity to its k nearest neighbors within
|
|
@@ -146,7 +171,8 @@ with distance, removing pop-in at the cutoff.
|
|
|
146
171
|
- `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
|
|
147
172
|
glyph picked per entity (seeded); optional `cycle` rotates variants over time
|
|
148
173
|
- `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ... }`
|
|
149
|
-
— a `font` **with** a px size
|
|
174
|
+
— a `font` **with** a px size keeps that shorthand, its number rescaled to the
|
|
175
|
+
viewport (verbatim only under `units: "px"`); a family/weight only
|
|
150
176
|
(`"bold monospace"`) composes with the seeded per-entity `size`
|
|
151
177
|
- `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
|
|
152
178
|
`soft` renders a radial-falloff glow orb instead of a hard disc
|
|
@@ -155,8 +181,31 @@ with distance, removing pop-in at the cutoff.
|
|
|
155
181
|
- `{ "kind": "streak", "length": [0.01, 0.03], "color": "#cfd8ff", "width": 0.002 }` —
|
|
156
182
|
a line oriented along the entity's analytic heading with a faded tail
|
|
157
183
|
(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`)
|
|
184
|
+
- `{ "kind": "rect", "width": [0.012, 0.02], "aspect": [1.3, 1.7], "color": "#ffb347", "feather": 0.6 }` —
|
|
185
|
+
rectangle; `aspect` is the height/width ratio range (rotates with `spin`);
|
|
186
|
+
`feather` 0..1 softens the edges — that fraction of the half-size fades out
|
|
187
|
+
toward the border (Rothko's block at 0.5–0.8)
|
|
188
|
+
- `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
|
|
189
|
+
a data bar: entity *i* draws `length × values[i] / max` (viewport units),
|
|
190
|
+
`thickness` thick, growing from its position toward `direction` (`right`
|
|
191
|
+
default, `left`, `up`, `down`). `values` are **paint**: `setParam("bars.values", […])`
|
|
192
|
+
glides every bar. With `layout: { type: "list" }` a chart is one layer and
|
|
193
|
+
its labels another — the dashboard genre stops needing one layer per number
|
|
194
|
+
- `{ "kind": "polygon", "radius": [0.02, 0.05], "sides": 3, "color": "#f2e8c9", "soft": false }` —
|
|
195
|
+
regular n-gon of the seeded circumradius, point up (`sides` 3..12, default
|
|
196
|
+
6); or `"points": [[-1, 0.9], [0.2, -1], [1, 0.4]]` — 3..24 unit
|
|
197
|
+
coordinates in −1..1 scaled by the radius — for any facet (a Picasso shard,
|
|
198
|
+
a Kandinsky triangle, a Mondrian plane). `soft` feathers the fill from the
|
|
199
|
+
centre. Rotates with `spin`. `points` are paint, so two polygons with the
|
|
200
|
+
same point count `morph` into each other
|
|
201
|
+
- `{ "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" }` —
|
|
202
|
+
a freehand mark: a path through 2..24 unit-coordinate points, scaled so the
|
|
203
|
+
unit box spans the seeded `length`, stroked `width` wide with round joins.
|
|
204
|
+
`curve: "smooth"` (default) is a Catmull-Rom spline, `"linear"` a polyline;
|
|
205
|
+
`taper` thins the mark to almost nothing at both ends (a brush stroke rather
|
|
206
|
+
than a line); `orient: true` turns the path's +x along the entity's heading,
|
|
207
|
+
like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
|
|
208
|
+
O'Keeffe's petal, Basquiat's scrawl
|
|
160
209
|
- `{ "kind": "textBlock", "text": "Multi-line text with wrapping.", "maxWidth": 0.8, "fontSize": 0.04, "lineHeight": 1.4, "align": "left", "color": "#e6e8ef" }` —
|
|
161
210
|
multi-line text block with deterministic line-breaking. All dimensions are
|
|
162
211
|
viewport fractions (of `min(w,h)`), not px — `units: "px"` specs reject
|
|
@@ -196,9 +245,20 @@ with distance, removing pop-in at the cutoff.
|
|
|
196
245
|
glide `reveal.progress` to 0, swap `text` while nothing is visible, glide
|
|
197
246
|
back to 1.
|
|
198
247
|
|
|
199
|
-
`circle`, `ring`, `streak`,
|
|
200
|
-
per-entity palette pick) and `colorWeights: [...]`
|
|
201
|
-
length — "mostly cool tones, occasional ember").
|
|
248
|
+
`circle`, `ring`, `streak`, `rect`, `bar`, `polygon` and `stroke` all accept
|
|
249
|
+
`colors: [...]` (seeded per-entity palette pick) and `colorWeights: [...]`
|
|
250
|
+
(relative weights, same length — "mostly cool tones, occasional ember").
|
|
251
|
+
|
|
252
|
+
**Drawing a shape out of sprites.** Overlapping sprites merge into one seamless
|
|
253
|
+
silhouette under exactly one set of conditions: a single flat colour, `alpha`
|
|
254
|
+
of `[1, 1]`, no `blend`, no `pulse`, and hard edges (no `soft`, no
|
|
255
|
+
`rect.feather`). Anything else leaves every overlap visible as an internal edge,
|
|
256
|
+
so the layer reads as a pile of sprites rather than a form — `soft` in
|
|
257
|
+
particular reads as smoke, not mass. Depth then comes from *layer order* and a
|
|
258
|
+
different flat colour per layer, painting back to front, and a later layer can
|
|
259
|
+
be used to cut a clean edge across an earlier one. `layerCohesion` measures
|
|
260
|
+
which of the two you got; `overlap-seams` warns when you asked for the first
|
|
261
|
+
and configured the second.
|
|
202
262
|
|
|
203
263
|
### `motion` (one of)
|
|
204
264
|
|
|
@@ -228,6 +288,154 @@ length — "mostly cool tones, occasional ember").
|
|
|
228
288
|
|
|
229
289
|
All speeds are ranges; each entity draws its own value (seeded).
|
|
230
290
|
|
|
291
|
+
`drift`, `rise` and `wander` also accept **`ease`** — closed-form velocity
|
|
292
|
+
shaping, no integration state:
|
|
293
|
+
`{ "ease": { "type": "settle", "tau": 2500 } }` starts at the entity's speed
|
|
294
|
+
and decelerates to rest with time constant `tau` ms (it travels `speed × tau`
|
|
295
|
+
and stops — a mark flung and coming to rest); `"buoyant"` starts at rest and
|
|
296
|
+
approaches the speed over `tau` (a bubble reaching terminal velocity). With
|
|
297
|
+
`emit`, the eased travel restarts from the spawn point on every event. For
|
|
298
|
+
`wander` only the base velocity eases — the harmonic meander keeps breathing,
|
|
299
|
+
so a settled wanderer hovers rather than freezes.
|
|
300
|
+
|
|
301
|
+
**Time structure, composed.** `emit` is how a scene does *almost nothing,
|
|
302
|
+
almost never*: one ring every twelve seconds that expands and fades
|
|
303
|
+
(`emit: { every: 12000, life: 5000, jitter: 0, grow: [0.15, 2.4] }`) reads as
|
|
304
|
+
an event, where the same ring pulsing forever reads as wallpaper. Long
|
|
305
|
+
`every`, low `count`, `jitter: 0` for a metronome, `1` for weather; pair with
|
|
306
|
+
`ease: settle` so a mark is flung and comes to rest before it fades ("expand,
|
|
307
|
+
drift a little, settle, fade" is one layer). `clock` is the other half: two
|
|
308
|
+
layers on the same clock are a duet, not a coincidence. The shipped `pings`
|
|
309
|
+
example is the whole idea in two layers.
|
|
310
|
+
## Scale — what the numbers actually look like
|
|
311
|
+
|
|
312
|
+
Every dimensional value is a fraction of `min(width, height)`. On a 1920×1080
|
|
313
|
+
display that divisor is **1080**, so `0.01` is 10.8 px there and the same
|
|
314
|
+
fraction of the short side on a phone or a 4K wall. Nothing below is a rule —
|
|
315
|
+
they are measured landmarks, because the most common authoring failure is not
|
|
316
|
+
a bad idea, it is an idea rendered two orders of magnitude too small or too
|
|
317
|
+
large to see. The px conversions and coverage figures are measured
|
|
318
|
+
(`perceiveScene` at 1920×1080, 40 opaque white entities on black, unless a row
|
|
319
|
+
says otherwise); the "reads as" readings and the recipe advice are judgment.
|
|
320
|
+
|
|
321
|
+
### `circle.radius` (a **radius**, so a dot is twice this wide)
|
|
322
|
+
|
|
323
|
+
| radius | px radius @1080p | px across | coverage (40) | reads as |
|
|
324
|
+
|---|---|---|---|---|
|
|
325
|
+
| `0.0005` | 0.5 | 1 | 0.9 % | star grain — texture, never the subject |
|
|
326
|
+
| `0.002` | 2.2 | 4 | 1.0 % | dust, embers, distant snow |
|
|
327
|
+
| `0.005` | 5.4 | 11 | 1.6 % | a distinct dot; graph node, firefly |
|
|
328
|
+
| `0.02` | 21.6 | 43 | 6.2 % | a clear disc — bokeh, a planet |
|
|
329
|
+
| `0.05` | 54 | 108 | 21 % | a focal object, or a soft glow field |
|
|
330
|
+
| `0.1` | 108 | 216 | 52 % | dominant form — sun, moon, one per scene |
|
|
331
|
+
| `0.2` | 216 | 432 | 88 % | 40 % of the short side; only as a soft glow plate |
|
|
332
|
+
|
|
333
|
+
**Read the coverage column with its floor in mind.** `perceiveScene` samples an
|
|
334
|
+
80 × 48 grid, so a cell is ~24 × 22 px at 1080p and anything smaller registers as
|
|
335
|
+
one cell per entity. That is why the top two rungs both land near 1 % despite a
|
|
336
|
+
16 × difference in area — below `radius ≈ 0.01` the column counts entities, not
|
|
337
|
+
pixels. It becomes an area measurement from `0.02` up.
|
|
338
|
+
|
|
339
|
+
The shipped examples put entity *fields* almost entirely in `0.0003 – 0.007`
|
|
340
|
+
and large forms in `0.03 – 0.185` (the biggest is a `ring` gauge in
|
|
341
|
+
`dev-dashboard`). The gap between is thinly populated
|
|
342
|
+
but not empty, and the exception is instructive: `aurora` runs 60–100-entity
|
|
343
|
+
layers at `radius [0.045, 0.11]`, because a soft additive orb that large is
|
|
344
|
+
atmosphere rather than an object. Within one scene, `lanterns`' three parallax
|
|
345
|
+
layers step by ~2.2 × and ~2.4 × per layer; steps much smaller than that read
|
|
346
|
+
as one layer with a wide size range rather than as depth.
|
|
347
|
+
|
|
348
|
+
The bottom two rungs are texture, not content, and `adviseSpec` says so: a
|
|
349
|
+
`0.002` field stops raising `sparse-scene` at ~70 entities, while a `0.0005`
|
|
350
|
+
field would need over a thousand — far past `LIMITS.maxPerLayer` (400) — so it
|
|
351
|
+
raises `sparse-scene` at every legal count and always needs a brighter layer
|
|
352
|
+
above it.
|
|
353
|
+
|
|
354
|
+
### `size` (emoji), `fontSize` (textBlock), `size` (text sprites)
|
|
355
|
+
|
|
356
|
+
`0.02` ≈ 22 px, `0.035` ≈ 38 px, `0.05` ≈ 54 px, `0.1` ≈ 108 px at 1080p.
|
|
357
|
+
Emoji fields in the examples sit at `0.011 – 0.078`; `haiku`, the one textBlock
|
|
358
|
+
example, sets `fontSize: 0.05`.
|
|
359
|
+
|
|
360
|
+
An explicit px size inside a `font` string (`"bold 14px monospace"`) is **not**
|
|
361
|
+
absolute: under the default `viewport` units the compiler rescales it by
|
|
362
|
+
`min(w, h) / referenceViewport`, so it holds its apparent size across displays
|
|
363
|
+
(14 px at 1080p becomes 28 px at 4K and 5 px on a phone). Only `units: "px"`
|
|
364
|
+
specs get the number verbatim. Two consequences: a font pinned for a 4K wall
|
|
365
|
+
becomes illegibly small on a phone-sized viewport, and a spec that genuinely
|
|
366
|
+
wants fixed pixels must say `units: "px"`.
|
|
367
|
+
|
|
368
|
+
### `speed` (viewport fractions per second)
|
|
369
|
+
|
|
370
|
+
| speed | px/s @1080p | crosses the short side | crosses a 16:9 width |
|
|
371
|
+
|---|---|---|---|
|
|
372
|
+
| `0.001` | 1.1 | 17 min | 30 min |
|
|
373
|
+
| `0.005` | 5.4 | 3.3 min | 5.9 min |
|
|
374
|
+
| `0.02` | 21.6 | 50 s | 89 s |
|
|
375
|
+
| `0.06` | 65 | 17 s | 30 s |
|
|
376
|
+
| `0.15` | 162 | 7 s | 12 s |
|
|
377
|
+
|
|
378
|
+
An ambient scene meant to live on a wall for hours wants its background layers
|
|
379
|
+
under `0.005` — anything faster resolves as traffic rather than atmosphere.
|
|
380
|
+
Above `0.06` a layer reads as an event (rain, warp, a passing comet). Speeds
|
|
381
|
+
are ranges and each entity draws its own, so a `[0.002, 0.008]` layer already
|
|
382
|
+
carries internal parallax.
|
|
383
|
+
|
|
384
|
+
### `links.maxDist`
|
|
385
|
+
|
|
386
|
+
Link density is governed by `maxDist` relative to the mean spacing between
|
|
387
|
+
entities, `sqrt(w·h / count) / min(w, h)` — for 40 entities at 1920×1080 that
|
|
388
|
+
is `0.21` (228 px), for 100 it is `0.13` (144 px). Measured at `k: 3`, count 40,
|
|
389
|
+
averaged over 15 seeds:
|
|
390
|
+
|
|
391
|
+
| `maxDist` | edges drawn (of `k·count`) | graph |
|
|
392
|
+
|---|---|---|
|
|
393
|
+
| 0.5 × spacing | ~12 % | scattered pairs; ~45 % of nodes isolated |
|
|
394
|
+
| 1.0 × spacing | ~45 % | a constellation with a few loners |
|
|
395
|
+
| 1.5 × spacing | ~60 % | usually one connected component, rarely an isolate |
|
|
396
|
+
| 2.0 × spacing | ~62 % | saturated — `k` is now the binding cap |
|
|
397
|
+
|
|
398
|
+
Past ~2 × spacing, raising `maxDist` costs distance tests and buys nothing;
|
|
399
|
+
raise `k` instead. `describeScene` reports `linksDrawn` / `linksExpected` and
|
|
400
|
+
`isolatedNodes`, so connectivity is checkable per spec rather than guessed —
|
|
401
|
+
worth doing, since the seed moves these by several points either way.
|
|
402
|
+
|
|
403
|
+
### Recipes
|
|
404
|
+
|
|
405
|
+
**Parallax depth.** Three or four layers where size and speed rise together —
|
|
406
|
+
scaling size alone reads as a size distribution, not as distance. `lanterns`'
|
|
407
|
+
ladder, which the numbers above are drawn from: `radius [0.0005, 0.0013]` /
|
|
408
|
+
`speed [0.0005, 0.002]` / `alpha [0.35, 1]` far, `radius [0.0014, 0.0028]` /
|
|
409
|
+
`speed [0.006, 0.013]` / `alpha [0.5, 0.9]` mid, `radius [0.0032, 0.006]` /
|
|
410
|
+
`speed [0.015, 0.026]` / `alpha [0.6, 1]` near. Note that alpha *narrows*
|
|
411
|
+
toward the front rather than simply rising — the far layer spans the whole
|
|
412
|
+
range because distance is what varies it; the near layer is uniformly present.
|
|
413
|
+
|
|
414
|
+
**Glow stacking.** `soft: true` + `blend: "lighter"` + `alpha` around 0.5–0.9,
|
|
415
|
+
with `pulse: { amp: 0.2, period: 3000 }` for breathing. Glow reads only against
|
|
416
|
+
a dark plate — over a pale background use `"screen"`. Do not assume a soft orb
|
|
417
|
+
covers what a hard disc of the same radius would: `perceiveScene` models the
|
|
418
|
+
halo as reaching 2.4 × the radius, so a large soft additive layer measures far
|
|
419
|
+
*more* coverage than its hard equivalent — ~2.5 × at `radius 0.05` — while a
|
|
420
|
+
small one measures *less*, ~0.5 × at `radius 0.005`, because the faint outer
|
|
421
|
+
halo falls under the per-cell visibility threshold. The crossover sits near
|
|
422
|
+
`radius 0.01`. Re-measure after switching `soft` or `blend` rather than
|
|
423
|
+
compensating by a rule of thumb.
|
|
424
|
+
|
|
425
|
+
**Graph web.** `count 40–100`, `radius [0.002, 0.005]`, a slow `drift`
|
|
426
|
+
(`speed [0.002, 0.007]`) so the topology keeps re-forming, and `links` with
|
|
427
|
+
`k: 3`, `maxDist` at 1.5 × mean spacing (`0.3` for 40 entities, `0.2` for 100),
|
|
428
|
+
`alpha: 0.15`, `width: 0.0005`. Link alpha wants to be much lower than the
|
|
429
|
+
nodes' — at 0.15 the web is a suggestion, at 0.5 it is a diagram. Add
|
|
430
|
+
`falloff: true` to fade edges toward the cutoff instead of popping them.
|
|
431
|
+
|
|
432
|
+
**Focal pin.** One `count: 1` layer with `motion: { type: "static" }` and an
|
|
433
|
+
explicit `position`, `radius` in the `0.05 – 0.15` band, over a field of
|
|
434
|
+
`0.002`-scale entities. The size ratio between the two is what makes it read as
|
|
435
|
+
a subject rather than the largest member of the crowd — an order of magnitude
|
|
436
|
+
or more, which is the gap the ladder above shows between the field rungs and
|
|
437
|
+
the focal ones.
|
|
438
|
+
|
|
231
439
|
## Determinism contract
|
|
232
440
|
|
|
233
441
|
Entity construction consumes the seeded RNG in a **fixed draw order** per layer.
|
|
@@ -293,6 +501,17 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
|
293
501
|
line-salience boost so they aren't crushed by filled discs.
|
|
294
502
|
- `motionStats(spec, opts)` — per-layer mean/max on-screen speed from analytic
|
|
295
503
|
displacement — choreography as numbers.
|
|
504
|
+
- `layerCohesion(spec, opts)` — **does a layer read as one form or as N marks?**
|
|
505
|
+
The luminance maps measure ink, not edges, so a layer whose sprites merged
|
|
506
|
+
into a silhouette and one that stayed a pile of discs are identical in every
|
|
507
|
+
other channel. Two independent facts, both reported (also on
|
|
508
|
+
`perceiveScene().form`): `overlap` is geometry — the mean fraction of an
|
|
509
|
+
entity's outline buried inside a sibling (shipped particle fields measure
|
|
510
|
+
≤ 0.154; a packed silhouette measures 0.79–0.85; `null` for lines, glyphs and
|
|
511
|
+
text, where a merged silhouette is not a meaningful idea). `seamless` is
|
|
512
|
+
paint — whether those overlaps vanish or draw an internal edge, with
|
|
513
|
+
`seamCause` naming the field to change. `reads` combines them into `mass`,
|
|
514
|
+
`seamed` or `marks`.
|
|
296
515
|
- `textSprites(spec, opts)` — the literal strings and rendered sizes of every
|
|
297
516
|
text layer. Glyphs are invisible in the luminance maps, so this is the only
|
|
298
517
|
way to confirm *what words* are on screen and how big. (Also on
|
|
@@ -305,6 +524,16 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
|
|
|
305
524
|
textBlock line-breaker uses, so they are estimates of the renderer's own
|
|
306
525
|
layout — a caption that fails these will look wrong on the wall; one that
|
|
307
526
|
passes may still sit a few px off.
|
|
527
|
+
`overlap-seams` catches the trap the other channels are blind to: a layer
|
|
528
|
+
whose entities bury each other (so it was drawn as one shape) but whose paint
|
|
529
|
+
settings make every overlap visible. It is gated to stay off deliberate
|
|
530
|
+
washes — it needs at least 6 entities and a mean base alpha of 0.45, and
|
|
531
|
+
never fires under `blend: lighter` / `screen`. No shipped example trips it.
|
|
532
|
+
The two density advisories read the spec's declared `density` first:
|
|
533
|
+
`sparse-scene` is withheld under `density: "sparse"` and `dense-scene`
|
|
534
|
+
under `"dense"`, and `density-mismatch` fires instead when the measured
|
|
535
|
+
coverage contradicts the declaration (a "sparse" scene covering more than
|
|
536
|
+
2 % of the frame, a "dense" one that would have read as empty).
|
|
308
537
|
- `diffScenes(a, b, opts)` — **relative sight**: coverage/luminance deltas,
|
|
309
538
|
visual-balance shift, 3×3 region deltas, dominance-rank movement, and
|
|
310
539
|
advisory codes added/removed. Agents judge "is B better than A" far more
|
|
@@ -400,6 +629,9 @@ Shipped working specs (also exposed as `EXAMPLE_SPECS` /
|
|
|
400
629
|
showcases — `aurora` (wander + coherence + ghosting + pulse.wave),
|
|
401
630
|
`warp-tunnel` (warp + streaks), `polygons` (chain links + heavy ghosting),
|
|
402
631
|
`matrix-rain` (grid layout + glyph cycle + ghosting), and `procession`
|
|
403
|
-
(path + layer-parented orbit + life staging + ring/rect sprites)
|
|
404
|
-
|
|
405
|
-
|
|
632
|
+
(path + layer-parented orbit + life staging + ring/rect sprites);
|
|
633
|
+
`nostalghia-candle` and `haiku` (restraint and text); and the 2026-09 trio —
|
|
634
|
+
`pings` (emit + grow + ease: one event at a time), `facets` (polygon, stroke
|
|
635
|
+
and feathered rect) and `relay-board` (list layout + bar: a chart in five
|
|
636
|
+
layers). See [`src/examples/`](./src/examples/). The dashboard exercises the
|
|
637
|
+
static/HUD subset at scale (34 layers of keyed, positioned text).
|