@idle-screens/schema 3.9.1 → 3.11.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
@@ -42,7 +42,11 @@ the `fade` transition, `role` on text sprites, and `text: 'crossfade'` on
42
42
  `morph` (2026-09-08 — ambient presentations); `background: { type: 'field' }`
43
43
  (seeded, warped noise quantised into bands), the static per-entity
44
44
  `rotate`, and the print `finish` (grain + dither screen, spec- and
45
- sequence-level) (2026-09-10 — the print look).
45
+ sequence-level) (2026-09-10 — the print look); `timeline` (keyframes on the
46
+ scene's own clock), paint-level layer `opacity` and `transform`,
47
+ `position.dx` / `dy`, `wrapMorph` on the sequence envelope and
48
+ `text: 'dip'` on `morph` (2026-09-25 — timed scenes); layer `groups` and
49
+ `transform.origin` (2026-09-26 — groups and origin).
46
50
 
47
51
  ## Safety invariants
48
52
 
@@ -107,7 +111,9 @@ must exist, have `count: 1`, and not themselves orbit a layer.
107
111
  "ghosting": 0.9, // optional 0..0.95; frame-persistence smear
108
112
  "finish": { "grain": 0.5, "dither": 0.3 }, // optional; print screen over the finished frame
109
113
  "background": { ... }, // optional; defaults to black
110
- "layers": [ { ... }, ... ] // 1..36, rendered back-to-front
114
+ "layers": [ { ... }, ... ], // 1..36, rendered back-to-front
115
+ "timeline": { ... }, // optional; authored keyframes — see **Timeline**
116
+ "groups": { "moon": { ... } } // optional; named paint groups — see **Groups**
111
117
  }
112
118
  ```
113
119
 
@@ -283,7 +289,10 @@ own `finish` is not steerable; steer a segment's, or republish.
283
289
  | `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 |
284
290
  | `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` |
285
291
  | `key` | string | none | addressable name → `setParam("key.count", …)` for a layer field, `setParam("key.sprite.color", …)` for a sprite field (the path mirrors the JSON: sprite fields sit under `sprite`) |
286
- | `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` |
292
+ | `position` | `{x, y, dx?, dy?}` — `x`/`y` 0..1, `dx`/`dy` ±2 (±17280 px under `units: "px"`) | none | exact placement; **requires `count: 1`** — except with a `list`/`table` layout, where it anchors the block's top-left; overrides `region`. `dx`/`dy` offset the point in **`min(w, h)` units** (px under `units: "px"`): `x`/`y` are fractions of width and height while every size is a fraction of `min(w, h)`, so parts placed at computed fractions only register at the aspect they were computed for — anchor a compound form's parts on one shared fraction (the centre is the only aspect-safe one) and offset each with `dx`/`dy`. Placement (structural). **Native:** tvOS ignores the offset |
293
+ | `opacity` | number 0..1 | `1` | **paint-level** layer opacity: multiplies every entity's `alpha` and the `life` envelope at draw time. Unlike `alpha` (a range baked into each entity), it is outside the structural signature, so `setParam`, a `timeline` key or a `morph` **glides** it instead of re-seeding the layer. `0` skips the layer. **Native:** ignored ⇒ 1 |
294
+ | `transform` | `{x?, y?, scale?, scaleX?, rotate?, origin?}` | identity | **paint-level** transform of the whole layer, about the viewport centre — or, with `origin: "anchor"`, about the layer's own anchor (its `position` point with `dx`/`dy`; a `list`/`table` block's anchor; without `position`, the centre of its `region`), so a word scales **in place** on every aspect (`origin` is an enum: not a steer or key target, and it steps across a morph — keep one origin per layer): translate `x`/`y` (±2 in `min(w, h)` units; px under `units: "px"`, ±17280), `rotate` (degrees, ±3600), `scale` (0..8) and `scaleX` (±8, × `scale` horizontally — toward 0 reads as a turn about the vertical axis, negative mirrors). Outside the structural signature: a camera for a timed piece, a slow drift for an ambient one, and it glides under steering. Sprites are not re-rasterised, so a large `scale` enlarges pixels of soft sprites, not detail. **Native:** ignored ⇒ identity |
295
+ | `group` | string | none | the name of a `groups` entry this layer paints through — see **Groups**. **Native:** ignored |
287
296
 
288
297
  `links`: `{ k: 1..8, maxDist, color?, alpha?, width?, mode?, falloff?, closed? }`.
289
298
  `mode: "nearest"` (default) wires each entity to its k nearest neighbors within
@@ -663,6 +672,119 @@ control-track (`step` | `linear` | `smooth`). Placement/motion changes trigger
663
672
  a deterministic rebuild (same seed → same stream). See `@idle-screens/core`
664
673
  for `ControlTrack` and the idlescreens.com MCP `setParam` tool.
665
674
 
675
+ Paths that are **paint** (colours, `alpha` inside a sprite's paint, layer
676
+ `opacity` / `transform`, `textBlock.opacity`, `reveal.progress`, polygon
677
+ `points`) glide; paths that are **structure** (`count`, `position`, `motion`,
678
+ `size`, `layer.alpha`, …) rebuild — a deterministic re-seed, which reads as a
679
+ pop. Prefer `opacity` over `alpha` and `transform` over `position` for
680
+ anything that should move smoothly.
681
+
682
+ ## Timeline — keyframes on the scene's clock
683
+
684
+ A SaverSpec can carry authored change on its **own** clock — the same clock
685
+ its drift, orbit and pulse run on (from mount; a sequence segment's `localT`):
686
+
687
+ ```jsonc
688
+ "timeline": {
689
+ "loop": true, // optional; repeat every `duration` ms
690
+ "duration": 16000, // required with loop; > the latest key's t
691
+ "keys": [ // 1..256
692
+ { "t": 400, "path": "sun.transform.y", "value": 0, "dur": 5200 },
693
+ { "t": 6000, "path": "title.opacity", "value": 1, "dur": 1400, "ease": "linear" },
694
+ { "t": 11000, "path": "title.opacity", "value": 0, "dur": 1200 }
695
+ ]
696
+ }
697
+ ```
698
+
699
+ - 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`
701
+ (default), `linear` or `step` (holds, then switches at the glide's end).
702
+ Keys address the same dot-paths as `setParam` — index or `key` form — and a
703
+ path must already exist on the spec (a key cannot add a field). The spec must
704
+ still validate with each key's value applied.
705
+ - Overlapping keys on one path chain: the later glide starts from the earlier
706
+ one's in-flight value.
707
+ - Without `loop`, a path holds the base spec's value before its first key and
708
+ its last key's value after it. With `loop`, the value depends only on
709
+ `t mod duration`, and **before a path's first key it has its last key's
710
+ value** — each lap is one closed cycle, lap 7 looks exactly like lap 1.
711
+ - **Flash safety:** distinct key times on one path sit ≥ 200 ms apart
712
+ (across the loop wrap too). Keys on different paths may share a beat.
713
+ Keys are also checked **together**: two keys that are each valid can
714
+ combine past a floor (a `clock.rate` and a `pulse.period` share one), so
715
+ the validator resolves the scene at every key's start, end and glide
716
+ quarter-points and rejects it there.
717
+ - A key on a **structural** path rebuilds the scene at that key — a pop, and
718
+ the validator warns `timeline-structural-key`. Animate paint: colours,
719
+ `opacity`, `transform`, polygon `points` (a whole equal-length array
720
+ lerps point by point), `reveal.progress`. A glide that runs past a loop's
721
+ lap warns `timeline-glide-overruns-lap`.
722
+ - A timeline is **frame-addressable**: the scene at `t` is a pure function of
723
+ `(spec, t)`, so seeks, ghosting warm-ups and perception all resolve it at
724
+ their own time. `resolveTimelineAt(spec, t)` returns the plain spec at `t`
725
+ (the same object when there is no timeline).
726
+
727
+ **Steering a scene with a timeline** — live steering layers on top, and the
728
+ rule falls out of the scene itself, no mode flag:
729
+
730
+ - a steer on a path **no key touches** is a sticky override — exactly today's
731
+ behaviour on an ambient scene;
732
+ - a steer on a path the **timeline animates** holds until that path's next
733
+ key, then glides back to the timeline over that key's `dur` — from
734
+ wherever the steer's own glide had reached, if the key arrives mid-glide.
735
+
736
+ A steer is rejected, as on any scene, when the scene it makes is invalid —
737
+ judged with every live steer applied, now and at each key still ahead (a
738
+ sticky steer outlives the keys).
739
+
740
+ A channel's control track is not a timeline: its `t` is a wall-clock stamp the
741
+ server writes, so it is never read as a schedule. Authored time lives here.
742
+
743
+ Each steer is applied once per server stamp, so a host re-sending the whole
744
+ track (idle-server does, on every steer) never re-arms a hold the timeline has
745
+ taken back. A viewer that mounts later replays the stored track at its own
746
+ mount: a steer on an animated path shows there until that path's next key
747
+ after the mount, while screens that saw it live have already moved on. Until
748
+ viewers share a clock (`sync: 'epoch'`, still unwired on the site), screens
749
+ mounted at different times can briefly disagree on such a path.
750
+
751
+ **One format for both use cases.** An ambient scene is a SaverSpec without a
752
+ timeline — nothing about it changes. A timed piece (an ident, a film, a
753
+ pre-roll) is a scene whose evolution is a timeline, and a sequence only where
754
+ it genuinely cuts to a different set of layers.
755
+
756
+ **Native:** tvOS ignores `timeline` and shows the base spec — so **author the
757
+ base as the piece's rest state** (its end card), and a timed piece degrades
758
+ to a valid ambient scene.
759
+
760
+ ## Groups — one handle for a multi-layer subject
761
+
762
+ A subject drawn from several layers (a character's body, eyes and mouth; a
763
+ record's grooves and label) can be moved, scaled and faded as one thing:
764
+
765
+ ```jsonc
766
+ "groups": { "moon": { "transform": { "scale": 1, "y": 0 }, "opacity": 1 } },
767
+ "layers": [ { "key": "face", "group": "moon", ... }, { "key": "eyes", "group": "moon", ... } ]
768
+ ```
769
+
770
+ - A member paints through its group: the group's `transform` wraps the
771
+ layer's own (a point lands at *group(layer(p))*), and the group's `opacity`
772
+ multiplies the layer's. A group's transform always turns about the viewport
773
+ centre — it is a camera; `origin: "anchor"` is for a layer's own transform.
774
+ - Up to 16 groups; names are 1–32 letters, digits, `-` or `_`, starting with a
775
+ letter. A layer's `group` must name one of them.
776
+ - **Paint**, outside the structural signature: `setParam("groups.moon.opacity", 0)`,
777
+ a `timeline` key on `groups.moon.transform.scale`, or a sequence `morph`
778
+ glides the whole subject — one path instead of one per layer. A group present
779
+ on only one end of a morph glides from the identity (no transform, opacity 1).
780
+ - Membership is fixed per scene: `layers.N.group` is not a steer or `timeline`
781
+ target. A sequence `morph` where a layer joins or leaves a group glides (the
782
+ end without the group paints it at the identity); moving a layer from one
783
+ group to another steps on the morph's first frame.
784
+
785
+ **Native:** ignored ⇒ identity transform, opacity 1 — author the base so the
786
+ subject reads without the group's paint.
787
+
666
788
  ## Seeing without eyes — the perception API
667
789
 
668
790
  `src/perceive.ts` translates a spec into modalities a **non-vision agent** can
@@ -677,6 +799,11 @@ simplified (`screen` ≈ `lighter`), wrapped link segments use straight
677
799
  interpolation, and background drift is sampled at rest. These are documented
678
800
  trade-offs for a zero-dependency, renderer-free analysis tool.
679
801
 
802
+ A `timeline` is resolved at the sample time `t` before anything is measured,
803
+ so every output below describes the scene as it is at that moment of the
804
+ piece. Layer `opacity` scales a layer's ink and weight; a layer `transform`
805
+ moves and scales its positions and sizes (sprite rotation is not modelled).
806
+
680
807
  - `perceiveScene(spec, {t?, viewport?, seed?})` — one-call bundle: everything below.
681
808
  **`t` is in milliseconds** (the MCP `previewScene` tool takes seconds and
682
809
  converts; passing seconds here reads a self-typing block as one that never
@@ -722,7 +849,10 @@ trade-offs for a zero-dependency, renderer-free analysis tool.
722
849
  text layer. Glyphs are invisible in the luminance maps, so this is the only
723
850
  way to confirm *what words* are on screen and how big. (Also on
724
851
  `perceiveScene().text`.)
725
- - `adviseSpec(spec)` — non-blocking advisories (also on
852
+ - `adviseSpec(spec)` — non-blocking advisories (a layer whose paint opacity —
853
+ its own × its group's — is 0 in the base scene and at every key boundary of
854
+ its `timeline` never shows, so it is not judged and its entities do not count
855
+ toward `dense-scene`; coverage is weighed by paint opacity) (also on
726
856
  `perceiveScene().advisories`). Two of them are **spatial**, for static text:
727
857
  `text-off-screen` (a text/textBlock box crosses the viewport edge by more
728
858
  than 1%) and `text-overlap` (two static text layers share more than 10% of
@@ -797,7 +927,7 @@ the presenter never freezes. A durationless final segment holds regardless of
797
927
  `advance`.
798
928
 
799
929
  **Transitions:** `{ type: 'cut' }` (default) performs a hard switch.
800
- `{ type: 'morph', dur: number, text?: 'step' | 'crossfade' }` interpolates
930
+ `{ type: 'morph', dur: number, text?: 'step' | 'crossfade' | 'dip' }` interpolates
801
931
  **numbers and hex colours** (colour, alpha, pulse, `reveal.progress`, …) over
802
932
  `dur` ms when crossing into the next segment; **every other value — strings,
803
933
  and `textBlock.text` above all — switches on the first morph frame** under
@@ -811,7 +941,11 @@ lerped frame's paint (colour, `opacity` and layer alpha glide as usual). Only
811
941
  the differing text layers cost a second draw, and only for `dur`; layers
812
942
  whose words match, and every non-text layer, are untouched. It is an
813
943
  internal paint pass, not a spec field — no `opacity` appears on the `text`
814
- sprite. `text` is a morph option only (a `fade` already cross-fades whole
944
+ sprite. **`text: 'dip'`** draws the same two passes one after the other
945
+ instead: the outgoing words fade out over the first half of the morph
946
+ (alpha `1 − 2k`) and the incoming fade in over the second (`2k − 1`), so two
947
+ captions never overlap — the right choice when the words sit in the same
948
+ place. `text` is a morph option only (a `fade` already cross-fades whole
815
949
  frames; a `cut` has no window) and the validator rejects it elsewhere.
816
950
  **Default `step` is today's behaviour byte for byte** — the sequence
817
951
  baseline pins it — and a flip to `crossfade` by default would be a major
@@ -825,7 +959,7 @@ warning. When the segments are structural twins whose only differences are
825
959
  values morph steps (a text-only change), the morph runs but every frame of it
826
960
  shows the incoming segment — it reads as a cut — and the validator emits a
827
961
  `morph-nothing-morphable` warning (never an error; stored sequences stay
828
- valid) — unless the transition declared `text: 'crossfade'` and the words
962
+ valid) — unless the transition declared `text: 'crossfade'` or `'dip'` and the words
829
963
  differ, in which case the words are the thing that morphs and the warning is
830
964
  withheld. During a morph, entity placement inherits the outgoing segment's
831
965
  seed — the incoming segment's own seed is unused. `dur` must be between 200
@@ -853,6 +987,28 @@ cross-fades on every segment change using the transition's `dur`, so `fade`
853
987
  matches the native player rather than diverging from it; native reads
854
988
  `fade.dur` where it reads the morph `dur` today.
855
989
 
990
+ **Wrap morph:** under `loop: true` a morph declared on the **last** segment
991
+ is ignored at the wrap — the lap cuts back to segment 0, and every stored
992
+ sequence expects that. `wrapMorph: true` on the envelope honours it: the lap
993
+ morphs back into segment 0 (from the second lap on; the first mount at
994
+ `T = 0` is not a wrap). It takes effect only when the whole lap is **one
995
+ morph chain** — every segment morphs into the next and the last is a
996
+ structural twin of the first — so every lap keeps the chain root's seed and
997
+ entity placement; otherwise it is still a cut and the validator warns
998
+ `wrap-morph-inactive` with the reason. With it, a looping ident is a closed
999
+ cycle of poses. A clicker jump to segment 0 (a `sequence.segment` steer)
1000
+ still cuts — it arrives from wherever the presenter was, not from the last
1001
+ segment. **Native:** tvOS cross-fades the wrap like any segment change.
1002
+
1003
+ **Timelines in segments:** a segment's scene may carry a `timeline`; it runs
1004
+ on the segment's `localT`. A morph resolves each side on its own clock — the
1005
+ outgoing segment at `duration + localT`, the incoming at `localT` — and lerps
1006
+ the results, so a looping timeline keeps cycling through the morph. The
1007
+ retained steers follow the steering rule on both ends (a steer the outgoing
1008
+ timeline has already taken back stays taken back), and a layer `opacity` or
1009
+ `transform` present on only one end glides from its identity (opacity 1,
1010
+ no transform) instead of stepping.
1011
+
856
1012
  **Time mapping:** global clock `T` maps to `(segmentIndex, localT)` via prefix
857
1013
  sums of durations. Half-open segments: `[start, start+duration)`. With
858
1014
  `loop: true`, `T` wraps at the sum of all durations (loop is incompatible with
package/dist/index.d.ts CHANGED
@@ -46,6 +46,14 @@ interface SaverSpec {
46
46
  * Absent ⇒ the frame is presented exactly as drawn.
47
47
  */
48
48
  finish?: FinishSpec;
49
+ /**
50
+ * Authored keyframes on the scene's own clock — see `Timeline`. Absent ⇒
51
+ * the scene is exactly what it was before the field existed. **Native:**
52
+ * ignored ⇒ the base spec (author it as the piece's rest state).
53
+ */
54
+ timeline?: Timeline;
55
+ /** Named layer groups — see `LayerGroup`. Absent ⇒ no layer is grouped (today's behaviour). */
56
+ groups?: Record<string, LayerGroup>;
49
57
  /** Dimensional unit system. 'viewport' (default) = all sizes/speeds/distances are fractions of min(w,h). */
50
58
  units?: 'viewport' | 'px';
51
59
  /**
@@ -139,6 +147,73 @@ interface BackgroundDrift {
139
147
  /** How far stops shift (fraction of the 0..1 range). Default 0.15, max 0.3. */
140
148
  amount?: number;
141
149
  }
150
+ /**
151
+ * A paint-level transform applied to a whole layer at draw time, about the
152
+ * viewport centre: translate by (`x`, `y`) in `min(w, h)` units, rotate by
153
+ * `rotate` degrees, scale by `scale` (× `scaleX` horizontally — `scaleX`
154
+ * toward 0 reads as a turn about the vertical axis). Kept out of the
155
+ * structural signature, so it glides under steering, a `timeline` key or a
156
+ * `morph` — a camera for timed pieces, a slow drift for ambient ones. Every
157
+ * field optional; an empty transform is the identity. **Native:** ignored ⇒
158
+ * identity.
159
+ */
160
+ interface LayerTransform {
161
+ x?: number;
162
+ y?: number;
163
+ scale?: number;
164
+ scaleX?: number;
165
+ rotate?: number;
166
+ /**
167
+ * What `scale` / `scaleX` / `rotate` turn about. `'viewport'` (default) is
168
+ * the viewport centre — a camera move. `'anchor'` is the layer's own
169
+ * `position` point (with `dx`/`dy`; for a `list`/`table` layout, the block's
170
+ * anchor; without `position`, the centre of its `region`) — a slam word
171
+ * punches in place, on every aspect. Group transforms always turn about the
172
+ * viewport centre. **Native:** ignored with the rest of `transform`.
173
+ */
174
+ origin?: 'viewport' | 'anchor';
175
+ }
176
+ /**
177
+ * A named set of layers painted through one shared `transform` and
178
+ * `opacity` (`SaverSpec.groups`, joined by `LayerSpec.group`): the group's
179
+ * transform wraps each member's own (it turns about the viewport centre, a
180
+ * camera), and its opacity multiplies theirs. Paint — outside the structural
181
+ * signature — so a steer (`groups.<name>.transform.scale`), a `timeline` key
182
+ * or a `morph` glides a whole multi-layer subject as one thing. **Native:**
183
+ * ignored ⇒ identity, opacity 1.
184
+ */
185
+ interface LayerGroup {
186
+ transform?: LayerTransform;
187
+ opacity?: number;
188
+ }
189
+ /** One authored key of a `Timeline`: at scene time `t` (ms), glide `path` to `value` over `dur` ms. */
190
+ interface TimelineKey {
191
+ /** Scene time in ms (the instance's own clock — a sequence segment's `localT`). */
192
+ t: number;
193
+ /** A steering path (`layers.0.sprite.color`, `key.sprite.color`, `background.stops.1.color`, …). It must already exist on the spec, as for `setParam`. */
194
+ path: string;
195
+ /** The target value; the key must still validate when applied. */
196
+ value: unknown;
197
+ /** Default `smooth`. */
198
+ ease?: 'step' | 'linear' | 'smooth';
199
+ /** Glide length in ms, starting at `t`. Default 1000 (as live steering); 0 = a cut. */
200
+ dur?: number;
201
+ }
202
+ /**
203
+ * Authored change on the scene's own clock — keyframes. A scene without a
204
+ * timeline is unchanged (ambient, today's behaviour); with one, each key
205
+ * glides its path at its time, and the scene can loop. Distinct from the
206
+ * channel's live `track`, whose `t` is a wall-clock stamp: live steering still
207
+ * layers on top, and a steer on a path the timeline animates holds until that
208
+ * path's next key.
209
+ */
210
+ interface Timeline {
211
+ /** Repeat every `duration` ms. A looping path's value before its first key is its last key's value (the loop wraps). */
212
+ loop?: boolean;
213
+ /** Loop length in ms; required with `loop`, and at least the latest key's `t`. */
214
+ duration?: number;
215
+ keys: TimelineKey[];
216
+ }
142
217
  interface LayerSpec {
143
218
  count: number;
144
219
  sprite: SpriteSpec;
@@ -227,7 +302,30 @@ interface LayerSpec {
227
302
  position?: {
228
303
  x: number;
229
304
  y: number;
305
+ /**
306
+ * An offset from `x`/`y` in `min(w, h)` units (px under `units: 'px'`).
307
+ * `x`/`y` are fractions of width and height while every size is a fraction
308
+ * of `min(w, h)`, so parts placed at computed fractions only register at
309
+ * the aspect they were computed for; anchor on a shared fraction (the
310
+ * centre is the only aspect-safe one) and offset in size units instead.
311
+ * Placement, like `x`/`y` (structural). Absent ⇒ 0. **Native:** tvOS
312
+ * ignores the offset until it reads the field.
313
+ */
314
+ dx?: number;
315
+ dy?: number;
230
316
  };
317
+ /**
318
+ * Paint-level layer opacity, 0..1 (default 1): multiplies the per-entity
319
+ * `alpha` and the `life` envelope at draw time. Unlike `alpha` (a range baked
320
+ * into every entity at build), it is **paint** — kept out of the structural
321
+ * signature — so `setParam`, a `timeline` key or a sequence `morph` glides it
322
+ * instead of re-seeding the layer. **Native:** ignored ⇒ 1.
323
+ */
324
+ opacity?: number;
325
+ /** Paint-level transform of the whole layer — see `LayerTransform`. */
326
+ transform?: LayerTransform;
327
+ /** The name of a `SaverSpec.groups` entry this layer is painted through (see `LayerGroup`). */
328
+ group?: string;
231
329
  /**
232
330
  * Inter-entity links. `mode` picks the wiring:
233
331
  * - 'nearest' (default): each entity's k nearest neighbors within maxDist.
@@ -755,6 +853,15 @@ declare const LIMITS: {
755
853
  readonly maxTransitionDur: 5000;
756
854
  readonly minTransitionDur: 200;
757
855
  readonly minEmitEvery: 1000;
856
+ readonly maxTimelineKeys: 256;
857
+ readonly minTimelineKeyInterval: 200;
858
+ readonly maxTimelineKeyDur: 30000;
859
+ readonly maxTimelineDuration: 86400000;
860
+ readonly maxLayerTransformOffset: 2;
861
+ readonly maxLayerTransformOffsetPx: 17280;
862
+ readonly maxLayerTransformScale: 8;
863
+ readonly maxLayerTransformRotate: 3600;
864
+ readonly maxGroups: 16;
758
865
  readonly maxEmitEvery: 600000;
759
866
  readonly minEmitLife: 500;
760
867
  readonly maxEmitGrow: 8;
@@ -777,13 +884,16 @@ type SequenceTransition = {
777
884
  * identical) segment over `dur` ms. Strings step on the first frame —
778
885
  * unless `text: 'crossfade'`, which draws each `text` / `textBlock` layer
779
886
  * whose string(s) differ twice for the window: the outgoing string at
780
- * `1 − k`, the incoming at `k` (k = the morph's eased progress). Default
781
- * `'step'`, today's behaviour, byte for byte.
887
+ * `1 − k`, the incoming at `k` (k = the morph's eased progress). `'dip'`
888
+ * draws the same two passes one after the other instead — the outgoing
889
+ * words fade out over the first half, the incoming fade in over the second —
890
+ * so the two strings never overlap. Default `'step'`, today's behaviour,
891
+ * byte for byte.
782
892
  */
783
893
  | {
784
894
  type: 'morph';
785
895
  dur: number;
786
- text?: 'step' | 'crossfade';
896
+ text?: 'step' | 'crossfade' | 'dip';
787
897
  }
788
898
  /**
789
899
  * Cross-fade into the next segment over `dur` ms: the outgoing segment
@@ -821,6 +931,16 @@ interface IdleSequence {
821
931
  * lands mid-loop. `advance: 'input'` holds stay armed under either mode.
822
932
  */
823
933
  sync?: 'mount' | 'epoch';
934
+ /**
935
+ * Honour a `morph` declared on the last segment at the loop wrap (the lap
936
+ * morphs back into segment 0 instead of cutting). Opt-in: without it a
937
+ * last-segment morph stays a cut at the wrap, as every stored sequence
938
+ * expects. Takes effect only when the whole loop is one morph chain (every
939
+ * segment morphs into the next, and the last is a structural twin of the
940
+ * first) — so every lap reuses the same child and seed; otherwise it is a
941
+ * cut and the validator says why. Needs `loop: true`.
942
+ */
943
+ wrapMorph?: boolean;
824
944
  /**
825
945
  * A scene drawn under every segment on the sequence's **global** clock —
826
946
  * the ground that does not reset at a boundary or under a `sequence.segment`
@@ -850,6 +970,18 @@ declare function validateSpec(spec: unknown): ValidationResult;
850
970
  * `validateSpec` it never normalises colours into the caller's spec.
851
971
  */
852
972
  declare function ignoredPropertyPaths(spec: unknown): string[];
973
+ /**
974
+ * Validity of only the parts of a spec that `paths` (index-form dot-paths,
975
+ * as `canonicalSpecPath` spells them) live in: each touched layer, the
976
+ * background, the finish — plus the cross-layer entity cap. A layer's rules
977
+ * (the `clock`/`pulse`/`grow` flash floors, `emit`, orbit parents) are all
978
+ * judged inside that layer, so a change confined to layer N can only break
979
+ * layer N or the total. Any other top-level path falls back to the full
980
+ * `validateSpec`. For the timeline's composed-state checks, which run at every
981
+ * key boundary and on every live steer: the whole-spec check costs a
982
+ * full validation per sample, this one a layer or two.
983
+ */
984
+ declare function validateSpecPaths(spec: SaverSpec, paths: Iterable<string>): boolean;
853
985
  /** Narrowing helper: validate + cast. Throws with a joined message when invalid. */
854
986
  declare function assertValidSpec(spec: unknown): SaverSpec;
855
987
  /**
@@ -1206,6 +1338,15 @@ interface PathTarget {
1206
1338
  }
1207
1339
  /** Resolve a dot-path (key-aware) to its parent + final key; null if absent. */
1208
1340
  declare function resolveSpecPath(spec: unknown, path: string): PathTarget | null;
1341
+ /**
1342
+ * The index form of a key-aware dot-path (`fireflies.sprite.color` →
1343
+ * `layers.3.sprite.color`), or null when it does not resolve. Two paths that
1344
+ * address the same field compare equal after this — how a live steer finds
1345
+ * out whether a `timeline` key animates the field it touches.
1346
+ */
1347
+ declare function canonicalSpecPath(spec: unknown, path: string): string | null;
1348
+ /** Read the value at a dot-path, or undefined when it does not resolve. */
1349
+ declare function readSpecPath(spec: unknown, path: string): unknown;
1209
1350
  /** A steering delta as carried on a channel control-track. */
1210
1351
  interface SteerDelta {
1211
1352
  t: number;
@@ -1222,6 +1363,14 @@ declare function applyDeltasToSpec(spec: SaverSpec, deltas: SteerDelta[]): Saver
1222
1363
  * target immediately (k > 0). Layer counts round to integers.
1223
1364
  */
1224
1365
  declare function lerpSpec(from: SaverSpec, to: SaverSpec, k: number): SaverSpec;
1366
+ /**
1367
+ * The walk behind `lerpSpec`, for any value: numbers lerp, hex colours lerp
1368
+ * per channel, equal-length arrays and objects recurse, everything else steps
1369
+ * to the target at k > 0 (`count` rounds). At a spec's root a `timeline` steps
1370
+ * too — two timelines' key times must never blend. No existing spec carries a
1371
+ * timeline, so every stored morph and glide is unchanged.
1372
+ */
1373
+ declare function lerpValue(from: unknown, to: unknown, k: number, specRoot?: boolean, rootKey?: string): unknown;
1225
1374
  /**
1226
1375
  * The string(s) a text layer paints — `strings` of a `text` sprite, `text`
1227
1376
  * of a `textBlock` — or null for every other sprite.
@@ -1472,10 +1621,11 @@ interface LuminanceGridOptions extends PerceiveOptions {
1472
1621
  * of (seed, t), `ghosting` is a decayed sum of past-frame splats (weight g^m
1473
1622
  * for ink m frames old, mirroring the renderer's warm-up replay) and `trail`
1474
1623
  * re-uses drawTrail's sampling — past positions with decaying alpha and
1475
- * shrinking radius. Remaining approximations (documented, deliberate): soft
1476
- * circles use a linear falloff, background drift is sampled at its rest
1477
- * position. Good enough to perceive composition, focus, balance — and now
1478
- * smear.
1624
+ * shrinking radius. Polygons larger than POLYGON_OUTLINE_CELLS are scanned by
1625
+ * their real (rotated) outline; smaller ones splat as a fill-weighted disc.
1626
+ * Remaining approximations (documented, deliberate): soft circles use a linear
1627
+ * falloff, background drift is sampled at its rest position. Good enough to
1628
+ * perceive composition, focus, balance — and now smear.
1479
1629
  */
1480
1630
  declare function luminanceGrid(spec: SaverSpec, opts?: LuminanceGridOptions): LuminanceGrid;
1481
1631
  /**
@@ -1549,7 +1699,15 @@ interface LayerMotionStats {
1549
1699
  maxSpeed: number;
1550
1700
  moving: boolean;
1551
1701
  }
1552
- /** Per-layer displacement between t and t+dt — choreography as numbers. */
1702
+ /**
1703
+ * Per-layer displacement between t and t+dt — choreography as numbers.
1704
+ *
1705
+ * Samples the scene at both ends of the window separately (not just each
1706
+ * entity's own position within one scene) so a `timeline`-driven layer
1707
+ * `transform` — a camera pan or zoom with every entity otherwise static —
1708
+ * registers as motion instead of vanishing between two reads of the same
1709
+ * frozen transform.
1710
+ */
1553
1711
  declare function motionStats(spec: SaverSpec, opts?: PerceiveOptions & {
1554
1712
  dt?: number;
1555
1713
  }): LayerMotionStats[];
@@ -1890,6 +2048,15 @@ declare function canMorph(seq: IdleSequence, from: number): boolean;
1890
2048
  * never its own index's.
1891
2049
  */
1892
2050
  declare function morphChainRoot(seq: IdleSequence, index: number): number;
2051
+ /**
2052
+ * Whether a looping sequence's wrap (last segment → segment 0) plays the last
2053
+ * segment's `morph`. Opt-in via `wrapMorph: true`, and only when the whole lap
2054
+ * is one morph chain — every boundary morphs and the last segment is a
2055
+ * structural twin of segment 0 — so the wrap keeps the chain root's seed and
2056
+ * entity placement and nothing pops. Otherwise the wrap is the hard cut it has
2057
+ * always been (the validator warns when `wrapMorph` asks for one it can't do).
2058
+ */
2059
+ declare function canWrapMorph(seq: IdleSequence): boolean;
1893
2060
  /** `SpecInstance`'s seed normalization: 0 is falsy, so a valid `seed: 0` lands on 1. */
1894
2061
  declare function normalizeSeed(seed: number): number;
1895
2062
  /**
@@ -1959,6 +2126,73 @@ declare function sequenceWantsFinish(seq: IdleSequence): boolean;
1959
2126
  */
1960
2127
  declare function sequenceSwapCompatible(prev: IdleSequence, next: IdleSequence): boolean;
1961
2128
 
2129
+ /**
2130
+ * `timeline` — authored keyframes on a scene's own clock.
2131
+ *
2132
+ * Everything here is a pure function of `(spec, t)`: resolving a scene at a
2133
+ * time never depends on what was rendered before, so a timeline scene is as
2134
+ * seekable and frame-addressable as any other (ghosting warm-up replays call
2135
+ * `resolveTimelineAt` at each replay frame's own time). A spec without a
2136
+ * `timeline` is returned untouched, which is what keeps every existing scene
2137
+ * byte-identical.
2138
+ *
2139
+ * Semantics:
2140
+ * - Keys address the same dot-paths as live steering; a path must already
2141
+ * exist on the spec (a key cannot create a field).
2142
+ * - A key glides from wherever its path is at `t` to `value` over `dur` ms
2143
+ * (default 1000, the live-steering default; 0 is a cut), eased `smooth`
2144
+ * unless it says otherwise. Overlapping glides are fine: the later one
2145
+ * starts from the earlier one's in-flight value.
2146
+ * - Without `loop`, a path holds the base spec's value before its first key.
2147
+ * - With `loop`, time wraps every `duration` ms and a path's value before its
2148
+ * first key is its LAST key's value — the lap is one closed cycle, and lap 1
2149
+ * looks exactly like lap 7 (the value depends only on `t mod duration`).
2150
+ */
2151
+
2152
+ /** Default glide length for a key without `dur` — the same as a live steer's. */
2153
+ declare const DEFAULT_KEY_DUR = 1000;
2154
+ type Tracks = Map<string, TimelineKey[]>;
2155
+ /** The spec without its `timeline` (a shallow copy — the layers are shared, never mutated). */
2156
+ declare function withoutTimeline(spec: SaverSpec): SaverSpec;
2157
+ /**
2158
+ * The spec's keys grouped by the field they animate (index-form path), each
2159
+ * group sorted by time (stable, so equal times keep authored order and the
2160
+ * later one wins). Keys whose path does not resolve are dropped — the
2161
+ * validator rejects them, and the runtime stays lenient like steering.
2162
+ */
2163
+ declare function timelineTracks(spec: SaverSpec): Tracks;
2164
+ /** Scene time folded into the timeline's own clock: `t mod duration` under `loop`, else `t`. */
2165
+ declare function timelineLocalTime(tl: Timeline, t: number): number;
2166
+ /**
2167
+ * The scene at time `t` with every key applied — and without its `timeline`,
2168
+ * so the result can be rendered, perceived, lerped or steered like any plain
2169
+ * spec. A spec without a timeline comes back as the same object.
2170
+ */
2171
+ declare function resolveTimelineAt(spec: SaverSpec, t: number): SaverSpec;
2172
+ /**
2173
+ * When the timeline next takes a path back: the scene time of that path's
2174
+ * next key after `t`, and that key's glide length — or null when no key will
2175
+ * ever touch it again (no key on the path, or a non-looping timeline past its
2176
+ * last key). A live steer on the path holds until `at`, then glides back to
2177
+ * the timeline over `dur`.
2178
+ */
2179
+ declare function nextKeyAfter(spec: SaverSpec, path: string, t: number): {
2180
+ at: number;
2181
+ dur: number;
2182
+ } | null;
2183
+ /**
2184
+ * Scene times (timeline-local; within one lap under `loop`) where the keys'
2185
+ * combined state is worth checking: time 0, and every key's start, end and
2186
+ * glide quarter-points. Two keys can each be valid alone and invalid together
2187
+ * (a `clock.rate` and a `pulse.period` the flash floor bounds as a ratio), so
2188
+ * validation samples the composed scene here. `dense: false` keeps only the
2189
+ * starts and ends, for the per-steer check a live viewer runs. Capped (evenly
2190
+ * subsampled) so a 256-key timeline stays cheap to check.
2191
+ */
2192
+ declare function timelineSampleTimes(spec: SaverSpec, dense?: boolean, cap?: number, only?: (path: string) => boolean): number[];
2193
+ /** The absolute scene times at or after `now` where `timelineSampleTimes` fall — the next lap's occurrences under `loop`. */
2194
+ declare function timelineSampleTimesAfter(spec: SaverSpec, now: number, dense?: boolean, only?: (path: string) => boolean): number[];
2195
+
1962
2196
  /**
1963
2197
  * Reproduces the hand-coded Fish Aquarium saver as pure DATA — the discriminating test
1964
2198
  * that the schema is actually expressive: two layers (fish drifting both ways at varied
@@ -2334,4 +2568,4 @@ declare const EXAMPLE_SPECS: SaverSpec[];
2334
2568
  /** Lookup by saver id. */
2335
2569
  declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
2336
2570
 
2337
- 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, 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 LayerMotionStats, type LayerSnapshot, type LayerSpec, 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 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, 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, lifeAlphaAt, linkEdges, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, normalizeSeed, perceiveScene, perceiveSequenceFrame, pickScreenOp, positionAt, presentWithFinish, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, revealState, rgb255Luma, rotationAt, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spriteIndexAt, spriteVariants, steerablePaths, structuralSignature, textBlockAnchorOffset, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, validateSequence, validateSpec };
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 };