@idle-screens/schema 3.11.1 → 3.12.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 CHANGED
@@ -192,8 +192,11 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
192
192
 
193
193
  **Steering.** Every numeric knob and every band is a paint path —
194
194
  `background.scale`, `background.warp`, `background.quantize`,
195
- `background.bands.2`, `background.drift.amount` — and glides (a fractional
196
- `quantize` or `octaves` mid-glide rounds). `background.seed` is not
195
+ `background.bands.2`, `background.drift.amount` — and glides. `quantize`
196
+ and `octaves` are level counts, so a glide (`setParam`, a `timeline` key, a
197
+ `morph`) steps through **whole** levels: the in-between value rounds, and a
198
+ `quantize` under 1.5 reads `0` (smooth) — so `quantize` 0 → 6 eases smooth,
199
+ then 2, 3 … 6 bands. Authored values must still be whole. `background.seed` is not
197
200
  steerable. The background is paint, not structure, so a `morph` between
198
201
  two field segments glides the field; a gradient → field morph steps
199
202
  (different shape).
@@ -269,7 +272,7 @@ own `finish` is not steerable; steer a segment's, or republish.
269
272
 
270
273
  | Field | Type | Default | Meaning |
271
274
  |---|---|---|---|
272
- | `count` | int 1..400 | — | entities in this layer |
275
+ | `count` | int 1..400 | — | entities in this layer. **At most 800 entities across all layers** (and ≤ 36 layers): the validator rejects a spec whose counts sum past 800, and density scaling never takes a screen past it |
273
276
  | `sprite` | SpriteSpec | — | what each entity looks like |
274
277
  | `motion` | MotionSpec | — | how entities move |
275
278
  | `size` | `[min,max]` > 0 | sprite-dependent | font size for emoji/text; ignored for shaped sprites |
@@ -278,13 +281,13 @@ own `finish` is not steerable; steer a segment's, or republish.
278
281
  | `alpha` | `[min,max]` 0..1 | `[1,1]` | per-entity opacity range |
279
282
  | `blend` | `lighter` \| `screen` \| `multiply` | source-over | additive glow / gentle additive / darkening |
280
283
  | `region` | `{x?, y?}` ranges 0..1 | full viewport | fractional spawn window (placement only, not travel) |
281
- | `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing; `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
282
- | `spin` | number \| `[min,max]` ±360 deg/sec | none | per-entity rotation (seeded start angle); a range gives each entity a seeded speed (confetti, tumbling debris). `[0, 0]` does **not** hold the seeded angle — a zero speed renders at 0; use `rotate` |
284
+ | `pulse` | `{amp ≤ 0.5, period ≥ 500, wave?}` | none | opacity breathing: `amp` is **added** to the entity's `alpha` (alpha ± amp, clamped to 0..1), not a fraction of it — a dim layer (`alpha` 0.2, `amp` 0.3) swings through 0 and sits fully dark for part of every period; keep `amp` ≤ the alpha floor for a breath that never blacks out. `wave: {wavelength, angle?}` turns it into a traveling wave across the field |
285
+ | `spin` | number \| `[min,max]` ±360 deg/sec | none | per-entity rotation (seeded start angle — drawn from the scene's one shared seeded stream, so it is reproducible for a given spec, seed **and viewport** but not a fixed per-entity value: density scaling changes how many entities draw before it, and declaring a `rotate` range adds a draw per entity, so either one changes the angles; use `rotate` for an angle you need to pin); a range gives each entity a seeded speed (confetti, tumbling debris). `[0, 0]` does **not** hold the seeded angle — a zero speed renders at 0; use `rotate` |
283
286
  | `rotate` | number \| `[min,max]` ±360 deg | none | **static** per-entity rotation: a scalar turns the whole layer, a range gives each entity a seeded angle (thrown blades, scattered glyphs, a tilted grid). Adds to `spin`'s start angle. Structural. **Native:** tvOS reads `spin` only ⇒ 0 |
284
287
  | `grow` | `{amp ≤ 0.8, period ≥ 500}` | none | size breathing (seeded phase) |
285
- | `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) |
288
+ | `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). Echoes of a shaped sprite (`polygon`, `stroke`, `rect`, `ring`, `streak`, `bar`) are the sprite's own shape as it was then, shrinking toward the tail; `circle`, `emoji` and text trail discs. A hard `circle`'s trail is one continuous ribbon tapering behind it, so a quick path draws a line rather than a string of dots (a layer whose ribbons would cover more than a tenth of the screen draws its echo discs instead, to bound the cost); a `soft` circle's echoes are filled in where they would part, within a small per-layer budget |
286
289
  | `links` | see below | none | inter-entity lines |
287
- | `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 |
290
+ | `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; a number or `{x?, y?}`). **`gap` is the pitch between cell anchors, not the space between cells** — a label wider than the column pitch runs into its neighbour (the `layout-text-overlap` advisory names the gap that clears it), so size `gap.x` to the widest label. Text/emoji take `strings[i]` / `glyphs[i]` **in order**, so N labels are one layer; a shaped sprite's palette (`colors[i]` on `bar`, `rect`, `circle`…) is read in order too. **Text and emoji have no `colors`**: a `text` layer paints every label in its one `color` (a `colors` on it is an unknown property, ignored with a warning) — use one layer per label colour |
288
291
  | `life` | `{enter?, exit?, fade?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit` |
289
292
  | `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 |
290
293
  | `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` |
@@ -308,20 +311,24 @@ with distance, removing pop-in at the cutoff.
308
311
  - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ..., "role": "read" }`
309
312
  — a `font` **with** a px size keeps that shorthand, its number rescaled to the
310
313
  viewport (verbatim only under `units: "px"`); a family/weight only
311
- (`"bold monospace"`) composes with the seeded per-entity `size`; `role` is
314
+ (`"bold monospace"`) composes with the seeded per-entity `size` (faces
315
+ installed on the viewer only — see `font` under `textBlock`); `role` is
312
316
  the legibility opt-in described under `textBlock` below (same field, same
313
317
  meaning on both text sprites)
314
318
  - `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
315
319
  `soft` renders a radial-falloff glow orb instead of a hard disc
316
320
  - `{ "kind": "ring", "radius": [0.002, 0.005], "color": "#d8f6ff", "width": 0.001 }` —
317
- unfilled circle (bubbles, portals, sonar)
321
+ unfilled circle (bubbles, portals, sonar). The line is **centred on the
322
+ radius**: half of `width` falls inside, half outside, so the outer edge is
323
+ `radius + width / 2`
318
324
  - `{ "kind": "streak", "length": [0.01, 0.03], "color": "#cfd8ff", "width": 0.002 }` —
319
325
  a line oriented along the entity's analytic heading with a faded tail
320
326
  (rain that reads as rain, shooting stars, warp stars)
321
327
  - `{ "kind": "rect", "width": [0.012, 0.02], "aspect": [1.3, 1.7], "color": "#ffb347", "feather": 0.6 }` —
322
328
  rectangle; `aspect` is the height/width ratio range (rotates with `spin`);
323
329
  `feather` 0..1 softens the edges — that fraction of the half-size fades out
324
- toward the border (Rothko's block at 0.5–0.8)
330
+ toward the border in one smooth linear falloff on each axis (Rothko's block
331
+ at 0.5–0.8)
325
332
  - `{ "kind": "bar", "values": [82, 64, 91], "max": 100, "length": 0.5, "thickness": 0.02, "color": "#17e8c8", "direction": "right" }` —
326
333
  a data bar: entity *i* draws `length × values[i] / max` (viewport units),
327
334
  `thickness` thick, growing from its position toward `direction` (`right`
@@ -334,14 +341,22 @@ with distance, removing pop-in at the cutoff.
334
341
  6); or `"points": [[-1, 0.9], [0.2, -1], [1, 0.4]]` — 3..24 unit
335
342
  coordinates in −1..1 scaled by the radius — for any facet (a Picasso shard,
336
343
  a Kandinsky triangle, a Mondrian plane). `soft` feathers the fill from the
337
- centre. Rotates with `spin`. `points` are paint, so two polygons with the
338
- same point count `morph` into each other
344
+ centre. Rotates with `spin`. The outline fills with the **nonzero** winding
345
+ rule: a self-crossing outline paints both lobes, and a hole needs its inner
346
+ loop traced in the **opposite** direction (a keyhole: outer ring clockwise,
347
+ then back round the hole counter-clockwise, joined by a zero-width slit). `points` are paint, so two polygons with the
348
+ same point count `morph` into each other — and a `setParam`, `timeline` key
349
+ or `morph` target for `sprite.points` is in the **same unit coordinates**,
350
+ scaled by each entity's seeded radius (not by the keyed shape's own extent):
351
+ keep every keyed point inside −1..1 (the validator rejects one outside), and
352
+ shrink a target shape by scaling its points, not by exceeding the box
339
353
  - `{ "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" }` —
340
354
  a freehand mark: a path through 2..24 unit-coordinate points, scaled so the
341
355
  unit box spans the seeded `length`, stroked `width` wide with round joins.
342
356
  `curve: "smooth"` (default) is a Catmull-Rom spline, `"linear"` a polyline;
343
357
  `taper` thins the mark to almost nothing at both ends (a brush stroke rather
344
- than a line); `orient: true` turns the path's +x along the entity's heading,
358
+ than a line; it paints as one filled mark, so a translucent stroke has no
359
+ overlap beads); `orient: true` turns the path's +x along the entity's heading,
345
360
  like `streak`. Rotates with `spin`. Van Gogh's stroke, Hokusai's contour,
346
361
  O'Keeffe's petal, Basquiat's scrawl
347
362
  - `{ "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 }` —
@@ -384,6 +399,12 @@ with distance, removing pop-in at the cutoff.
384
399
  advance so mono wraps land where the real face puts them; every other
385
400
  family uses the proportional table. Default `system-ui, sans-serif`.
386
401
  **Native clients fall back to the system face** until a face table exists.
402
+ `font` names faces **installed on the viewing device** — nothing is
403
+ bundled and no web font is fetched, so `'Inter'` only appears where Inter
404
+ is installed and every other screen falls through the stack. End every
405
+ stack in a generic `sans-serif`, `serif` or `monospace`; avoid ending on
406
+ `cursive` or `fantasy`, which resolve to a different (sometimes absurd)
407
+ face on every platform.
387
408
 
388
409
  `opacity` (0–1, default 1) multiplies the block's paint alpha — the whole
389
410
  block, on top of the layer's per-entity `alpha`. It is **paint**, not
@@ -471,13 +492,18 @@ and configured the second.
471
492
  - `{ "type": "bounce", "speed": [0.12, 0.24] }` — diagonal, reflecting off edges
472
493
  - `{ "type": "static" }` — stays exactly where placed (use with `position` for HUD text)
473
494
  - `{ "type": "orbit", "speed": [10, 40], "radius": [0.05, 0.2], "center": { "x": 0.5, "y": 0.5 } }`
474
- — angular speed in deg/sec; `center` may instead be `{ "layer": "planet" }`
475
- to orbit a single-entity parent layer (moons around a wandering planet;
476
- strictly one level deep)
495
+ — angular speed in deg/sec; a **negative** speed orbits the other way
496
+ (counter-rotating rings, meshing gears); `center` may instead be
497
+ `{ "layer": "planet" }` to orbit a single-entity parent layer (moons around a
498
+ wandering planet; strictly one level deep — and the parent must have
499
+ `count: 1`)
477
500
  - `{ "type": "wander", "speed": [0.004, 0.012], "angle": 0, "meander": 0.1, "coherence": 0.7 }`
478
501
  — organic harmonic drift: base velocity plus 3 seeded sine octaves per axis.
479
502
  Flowing curved paths with zero simulation state. `meander` scales the
480
- curvature; `coherence` 0..1 blends every entity toward a shared harmonic set
503
+ curvature (it is the harmonics' amplitude, so it also sets how fast an
504
+ entity sways — keep it ≤ ~0.015 for a slow drift); it is capped at
505
+ **0.463** (500 px ÷ `referenceViewport`, 1080 by default — 500 under
506
+ `units: "px"`), and the validator rejects anything above; `coherence` 0..1 blends every entity toward a shared harmonic set
481
507
  (1 = the layer undulates as one body — fake flocking)
482
508
  - `{ "type": "warp", "speed": [0.1, 0.5], "center": { "x": 0.5, "y": 0.5 } }`
483
509
  — perspective starfield: entities stream toward the viewer on a depth axis,
@@ -486,7 +512,10 @@ and configured the second.
486
512
  - `{ "type": "path", "points": [{"x":0.1,"y":0.7}, ...], "duration": 30000, "curve": "smooth", "closed": true, "scatter": 0.02 }`
487
513
  — choreographed spline (Catmull-Rom, or `linear`); 2..24 waypoints, lap time
488
514
  ≥ 2 s; `closed` loops (default), open paths ping-pong; `scatter` offsets
489
- entities sharing the path; each entity gets a seeded phase along it
515
+ entities sharing the path (**must be > 0** — omit it for an exact path
516
+ rather than writing `0`); each entity gets a seeded phase along it. A single
517
+ entity cannot show a curve — use `count` ≥ 12 (plus `trail`/`ghosting`) to
518
+ make the whole path read
490
519
 
491
520
  All speeds are ranges; each entity draws its own value (seeded).
492
521
 
@@ -697,7 +726,9 @@ its drift, orbit and pulse run on (from mount; a sequence segment's `localT`):
697
726
  ```
698
727
 
699
728
  - A key glides `path` from wherever it is at `t` to `value` over `dur` ms
700
- (default 1000, the live-steer default; `0` is a cut), eased `smooth`
729
+ (default 1000, the live-steer default; `0` is a cut; at most 30000 — a
730
+ longer `dur` fails validation for the whole spec, so split a slower glide
731
+ into consecutive keys of ≤ 30 s each), eased `smooth`
701
732
  (default), `linear` or `step` (holds, then switches at the glide's end).
702
733
  Keys address the same dot-paths as `setParam` — index or `key` form — and a
703
734
  path must already exist on the spec (a key cannot add a field). The spec must
@@ -853,11 +884,14 @@ moves and scales its positions and sizes (sprite rotation is not modelled).
853
884
  its own × its group's — is 0 in the base scene and at every key boundary of
854
885
  its `timeline` never shows, so it is not judged and its entities do not count
855
886
  toward `dense-scene`; coverage is weighed by paint opacity) (also on
856
- `perceiveScene().advisories`). Two of them are **spatial**, for static text:
887
+ `perceiveScene().advisories`). Three of them are **spatial**, for static text:
857
888
  `text-off-screen` (a text/textBlock box crosses the viewport edge by more
858
- than 1%) and `text-overlap` (two static text layers share more than 10% of
889
+ than 1%), `text-overlap` (two static text layers share more than 10% of
859
890
  the smaller box; it carries both boxes as `boxes: [{x, y, w, h}]` in
860
- viewport fractions so the fix needs no re-derivation). Boxes come from the
891
+ viewport fractions so the fix needs no re-derivation) and
892
+ `layout-text-overlap` (two cells of one `list`/`table` layer do — its
893
+ `gap` pitch is narrower than its labels; the message names the `gap` that
894
+ clears them, and it carries the two boxes too). Boxes come from the
861
895
  same character-class width table the textBlock line-breaker uses, so they
862
896
  are estimates of the renderer's own layout — a caption that fails these
863
897
  will look wrong on the wall; one that passes may still sit a few px off.
@@ -891,6 +925,27 @@ is estimated from font size × string length. Good enough to perceive
891
925
  composition, focus, balance, and motion — not a substitute for a human (or VLM)
892
926
  judgement of beauty.
893
927
 
928
+ **Motion comes in both units.** `motion[].meanSpeed` / `maxSpeed` are px per
929
+ second at the perceived size; `meanSpeedVp` / `maxSpeedVp` are the same speeds
930
+ in viewport units per second (fractions of `min(w,h)` — the unit specs are
931
+ authored in), which do not change with the preview size; `1.0` crosses the
932
+ short side of the screen every second. `moving` is judged in viewport units,
933
+ so it gives the same answer at every size. Each layer also carries `heading` (screen degrees: 0 right, 90 down) and
934
+ `headingSpread` (0 = a conveyor, everything moving the same way — the
935
+ "screen-wipe" to avoid; 1 = scattered). Sampling a centroid over time gives the
936
+ WRONG sign for a wrapping full-viewport scatter, which is why heading is
937
+ reported directly.
938
+
939
+ **Colour is its own channel.** `perceiveScene(...).colour` and
940
+ `diffScenes(...).colour` carry the hue / saturation / warmth (red minus blue,
941
+ −1 cold … 1 warm) of the alpha × area weighted sprite mass and of the
942
+ background plate. Judge "warmer / cooler" on `colour.warmthDelta`, not on
943
+ `meanLuminance` — plausible cool tones weigh MORE under luma than the reds they
944
+ replace, so a calming edit pushes luminance up. `diffScenes` also reports a
945
+ `motion` channel (overall and per-layer speed deltas with headings) and
946
+ advises at the diff's own viewport, so its `advisories.added/removed` agree
947
+ with `adviseSpec` at that size.
948
+
894
949
  ## Sequence envelope (`idle-sequence`)
895
950
 
896
951
  A second top-level format that composes multiple SaverSpecs into a sequenced
package/dist/index.d.ts CHANGED
@@ -992,6 +992,25 @@ declare function assertValidSpec(spec: unknown): SaverSpec;
992
992
  declare function validateSequence(seq: unknown): ValidationResult;
993
993
  declare function assertValidSequence(seq: unknown): IdleSequence;
994
994
 
995
+ /**
996
+ * The two scale factors every entity builder must agree on, computed in ONE
997
+ * place. `scale` turns viewport-unit dimensions into px (1 for `units: "px"`,
998
+ * min(w,h) otherwise — the default is viewport, so an omitted `units` scales);
999
+ * `countScale` thins or thickens scatter density against `referenceViewport`
1000
+ * and is clamped so the projected total never exceeds LIMITS.maxTotal.
1001
+ *
1002
+ * Four callers (compile, perceive, advise, describe) each carried their own
1003
+ * copy of this arithmetic and they drifted twice: describeScene inverted the
1004
+ * units default and built every shipped example at scale 1 (F48), and
1005
+ * advise/describe skipped the maxTotal clamp so above ~4.6K they measured a
1006
+ * scene the renderer never draws — and because the RNG stream is shared across
1007
+ * layers, one early layer's different count shifts every later layer's
1008
+ * positions too (F49).
1009
+ */
1010
+ declare function entityScales(spec: SaverSpec, w: number, h: number): {
1011
+ scale: number;
1012
+ countScale: number;
1013
+ };
995
1014
  /** Near plane for warp motion. z lives in [WARP_NEAR, 1]; screen scale is 1/z. */
996
1015
  declare const WARP_NEAR = 0.08;
997
1016
  /** Cap on the 1/z size multiplier so near stars don't blow out. */
@@ -1360,7 +1379,8 @@ declare function applyDeltasToSpec(spec: SaverSpec, deltas: SteerDelta[]): Saver
1360
1379
  /**
1361
1380
  * Interpolate between two structurally-similar specs at progress k (0..1).
1362
1381
  * Numbers lerp, hex colours lerp per channel, everything else steps to the
1363
- * target immediately (k > 0). Layer counts round to integers.
1382
+ * target immediately (k > 0). Layer counts, and a field background's
1383
+ * `octaves` / `quantize`, round to whole values.
1364
1384
  */
1365
1385
  declare function lerpSpec(from: SaverSpec, to: SaverSpec, k: number): SaverSpec;
1366
1386
  /**
@@ -1694,10 +1714,32 @@ declare function layerCohesion(spec: SaverSpec, opts?: PerceiveOptions): LayerCo
1694
1714
  interface LayerMotionStats {
1695
1715
  layerIndex: number;
1696
1716
  key: string | undefined;
1697
- /** Mean / max on-screen speed in px per second (wrap jumps excluded). */
1717
+ /** Mean / max on-screen speed in px per second at the perceived size (wrap jumps excluded). */
1698
1718
  meanSpeed: number;
1699
1719
  maxSpeed: number;
1720
+ /**
1721
+ * The same speeds in **viewport units per second** — fractions of
1722
+ * min(w, h), the unit a spec's `speed` is authored in — so they do not
1723
+ * change with the size the scene is perceived at. 1.0 crosses the short side
1724
+ * of the screen every second.
1725
+ */
1726
+ meanSpeedVp: number;
1727
+ maxSpeedVp: number;
1728
+ /** meanSpeedVp above MOVING_FLOOR — the same answer at every preview size. */
1700
1729
  moving: boolean;
1730
+ /**
1731
+ * Direction the layer's mass is going, in screen degrees (0 = right,
1732
+ * 90 = down, 180 = left, 270 = up), or null when nothing moves. Sampling a
1733
+ * centroid over time gives the WRONG sign for a wrapping full-viewport
1734
+ * scatter, which is why this is reported directly.
1735
+ */
1736
+ heading: number | null;
1737
+ /**
1738
+ * 0 = every entity moves the same way (a conveyor — the "screen-wipe" the
1739
+ * schema warns about), 1 = headings cancel out (a scatter, an orbit, a
1740
+ * bounce field). |mean unit vector| inverted.
1741
+ */
1742
+ headingSpread: number | null;
1701
1743
  }
1702
1744
  /**
1703
1745
  * Per-layer displacement between t and t+dt — choreography as numbers.
@@ -1711,6 +1753,34 @@ interface LayerMotionStats {
1711
1753
  declare function motionStats(spec: SaverSpec, opts?: PerceiveOptions & {
1712
1754
  dt?: number;
1713
1755
  }): LayerMotionStats[];
1756
+ interface SceneColour {
1757
+ /** Hue of the visible sprite mass in degrees (0 red, 120 green, 240 blue), null when unsaturated. */
1758
+ hue: number | null;
1759
+ /** HSL saturation 0..1 of the sprite mass. */
1760
+ saturation: number;
1761
+ /** red minus blue of the mean sprite colour, -1 (cold) .. 1 (warm). The "cooler/warmer" axis. */
1762
+ warmth: number;
1763
+ /** Mean sprite colour as hex, for a model that reads hex better than angles. */
1764
+ hex: string;
1765
+ /** The same three numbers for the background plate. */
1766
+ background: {
1767
+ hue: number | null;
1768
+ saturation: number;
1769
+ warmth: number;
1770
+ hex: string;
1771
+ };
1772
+ }
1773
+ /**
1774
+ * What colour the scene IS — the alpha × area weighted mean of every hex a
1775
+ * sprite paints with, beside the background plate. A luminance map cannot
1776
+ * carry this: a luma-matched red→cyan swap moves every other channel by
1777
+ * ~0.001 (comprehension eval 2026-07-28, P2 F3), and `meanLuminance` reads
1778
+ * plausible cool tones as *brighter* than the reds they replace, so "make it
1779
+ * cooler" pushed the only number an agent had in the wrong direction. Emoji
1780
+ * carry their own palette and are excluded; a scene of only emoji reports the
1781
+ * background.
1782
+ */
1783
+ declare function sceneColour(spec: SaverSpec, opts?: PerceiveOptions): SceneColour;
1714
1784
  declare const REGION_NAMES: readonly ["top-left", "top-center", "top-right", "mid-left", "center", "mid-right", "bottom-left", "bottom-center", "bottom-right"];
1715
1785
  interface SceneDiff {
1716
1786
  t: number;
@@ -1744,6 +1814,44 @@ interface SceneDiff {
1744
1814
  shareA: number;
1745
1815
  shareB: number;
1746
1816
  }>;
1817
+ /**
1818
+ * The hue channel. `hueShift` is the signed shortest rotation A→B in
1819
+ * degrees; `warmthDelta` is the cooler/warmer axis (negative = B is cooler)
1820
+ * — read THIS for "cooler", not `meanLuminance`, which weights cool tones
1821
+ * brighter than the reds they replace.
1822
+ */
1823
+ colour: {
1824
+ a: SceneColour;
1825
+ b: SceneColour;
1826
+ hueShift: number | null;
1827
+ saturationDelta: number;
1828
+ warmthDelta: number;
1829
+ background: {
1830
+ hueShift: number | null;
1831
+ warmthDelta: number;
1832
+ };
1833
+ };
1834
+ /**
1835
+ * The motion channel, in viewport units per second. `overall` is the
1836
+ * entity-weighted mean across layers; `layers` lists every layer whose speed
1837
+ * changed, largest change first. Halving every speed used to leave the diff
1838
+ * bit-identical (comprehension eval 2026-07-28, P2 F4).
1839
+ */
1840
+ motion: {
1841
+ overall: {
1842
+ a: number;
1843
+ b: number;
1844
+ delta: number;
1845
+ };
1846
+ layers: Array<{
1847
+ key: string;
1848
+ a: number;
1849
+ b: number;
1850
+ delta: number;
1851
+ headingA: number | null;
1852
+ headingB: number | null;
1853
+ }>;
1854
+ };
1747
1855
  advisories: {
1748
1856
  added: string[];
1749
1857
  removed: string[];
@@ -1778,6 +1886,8 @@ interface ScenePerception {
1778
1886
  motion: LayerMotionStats[];
1779
1887
  /** Per layer: merged silhouette, seamed overlap, or separate marks. */
1780
1888
  form: LayerCohesion[];
1889
+ /** What colour the scene is — hue/saturation/warmth of the sprite mass and the plate. */
1890
+ colour: SceneColour;
1781
1891
  /** Literal strings + sizes of any text layers (glyphs don't show in the maps). */
1782
1892
  text: TextSpriteInfo[];
1783
1893
  advisories: ReturnType<typeof adviseSpec>;
@@ -2568,4 +2678,4 @@ declare const EXAMPLE_SPECS: SaverSpec[];
2568
2678
  /** Lookup by saver id. */
2569
2679
  declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
2570
2680
 
2571
- export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, type CapabilityTier, type CohesionRead, type CycleSpec, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, type Entity, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, type FieldBackground, type FieldDrift, type FinishPass, type FinishSpec, GRAIN_ALPHA, GRAIN_TILE, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, type LayerCohesion, type LayerGroup, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LayerTransform, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, MURMUR_SPEC, type MotionEase, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type PerceiveSequenceOptions, type Placed, RAIN_SPEC, RELAY_BOARD_SPEC, type ResolveOptions, type ResolvedSegment, type RevealState, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, type SaverSpec, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type ScreenOp, type SequenceExample, type SequenceFramePerception, type SequenceMountContext, type SequenceSaverInstance, type SequenceSaverPlugin, type SequenceSegment, type SequenceTransition, type SpecError, type SpecMountContext, type SpecWarning, type SpriteSpec, type SteerDelta, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, type TextBlockAnchor, type TextBlockLine, type TextMetricsClass, type TextRevealInput, type TextRevealSpec, type TextRole, type TextSpriteInfo, type Timeline, type TimelineKey, type ValidationResult, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, type WanderOsc, type WarningBox, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, easeSmooth, emitEnvelope, emitWindow, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, latticeHash, layerCohesion, lerpSpec, lerpValue, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, perceiveScene, perceiveSequenceFrame, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textBlockAnchorOffset, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, validateSequence, validateSpec, validateSpecPaths, withoutTimeline };
2681
+ export { AQUARIUM_SPEC, AURORA_SPEC, type BackgroundDrift, type BackgroundSpec, type BandSpec, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, type CapabilityTier, type CohesionRead, type CycleSpec, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, type DominanceEntry, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, type Entity, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, type FieldBackground, type FieldDrift, type FinishPass, type FinishSpec, GRAIN_ALPHA, GRAIN_TILE, type GradientStop, HAIKU_SPEC, type IdleSequence, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, type LayerCohesion, type LayerGroup, type LayerMotionStats, type LayerSnapshot, type LayerSpec, type LayerTransform, type LinkEdge, type LuminanceGrid, type LuminanceGridOptions, MATRIX_RAIN_SPEC, MURMUR_SPEC, type MotionEase, type MotionSpec, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, type PerceiveOptions, type PerceiveSequenceOptions, type Placed, RAIN_SPEC, RELAY_BOARD_SPEC, type ResolveOptions, type ResolvedSegment, type RevealState, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, type SaverSpec, type SceneColour, type SceneDescription, type SceneDiff, type ScenePerception, type SceneSnapshot, type SchemaExample, type ScreenOp, type SequenceExample, type SequenceFramePerception, type SequenceMountContext, type SequenceSaverInstance, type SequenceSaverPlugin, type SequenceSegment, type SequenceTransition, type SpecError, type SpecMountContext, type SpecWarning, type SpriteSpec, type SteerDelta, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, type TextBlockAnchor, type TextBlockLine, type TextMetricsClass, type TextRevealInput, type TextRevealSpec, type TextRole, type TextSpriteInfo, type Timeline, type TimelineKey, type ValidationResult, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, type WanderOsc, type WarningBox, adviseSequence, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, easeSmooth, emitEnvelope, emitWindow, entityScales, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, latticeHash, layerCohesion, lerpSpec, lerpValue, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, perceiveScene, perceiveSequenceFrame, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, sceneColour, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textBlockAnchorOffset, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, validateSequence, validateSpec, validateSpecPaths, withoutTimeline };