@idle-screens/schema 3.16.1 → 3.17.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
@@ -3,7 +3,8 @@
3
3
  The declarative, agent-authorable screensaver format compiled by
4
4
  `@idle-screens/schema`. A spec describes a saver as **data** — a background plus
5
5
  layers of moving sprites — which `compileSaver()` turns into a seeded,
6
- deterministic, flash-safe `SaverPlugin`. There is no code in a spec: no
6
+ deterministic `SaverPlugin` with bounded motion and work (see Safety
7
+ invariants for what is guaranteed, and what is checked at publish). There is no code in a spec: no
7
8
  scripting, no network access, no DOM access.
8
9
 
9
10
  - **Machine-readable schema:** [`saver-spec.schema.json`](./saver-spec.schema.json)
@@ -58,13 +59,20 @@ live steers), `rock`, `life.stagger`, `reveal.rise`, `textBlock.number`, the
58
59
 
59
60
  ## Safety invariants
60
61
 
61
- These hold **by construction** — no spec can violate them:
62
+ Three kinds of promise, kept apart so that none is claimed for more than it
63
+ covers: what the format guarantees by construction, what is checked when a
64
+ scene is published, and what nothing checks.
65
+
66
+ **By construction.** The validator rejects any spec that breaks these, and
67
+ `compileSaver()` refuses an invalid spec:
62
68
 
63
69
  1. **No flash primitive.** The background is static (drift is floored at 10 s
64
70
  — for a gradient's stop positions and for a `field`'s domain travel alike,
65
71
  the only way either changes over time) and entities are bounded sprites,
66
- so a compiled spec cannot strobe the full field. Provable by sampling any
67
- compiled spec through `@idle-screens/validator`.
72
+ so no single field switches the whole frame from one frame to the next.
73
+ This is about primitives, not pictures: several timeline keys together,
74
+ or a regular pattern in motion, can still flicker, which is what the
75
+ publish checks below are for.
68
76
  2. **Pulse/grow/cycle are bounded.** Breathing amplitude is capped and periods
69
77
  floored at 500 ms (2 Hz — under the WCAG 3 Hz flash threshold). Every entity
70
78
  gets its own seeded phase — and with `pulse.wave`, a position-derived phase —
@@ -74,7 +82,8 @@ These hold **by construction** — no spec can violate them:
74
82
  them. `clock` deliberately removes the per-entity phase, so the validator
75
83
  floors a clocked layer's periods at 1000 ms (1 Hz) instead — a layer in
76
84
  unison breathes, it never flashes. `emit` events are floored at one per
77
- second per entity and always ride a smooth envelope, never a cut.
85
+ second per entity and always ride a smooth envelope, never a cut. Distinct
86
+ timeline key times on one path sit ≥ 200 ms apart (see Timeline).
78
87
  3. **Motion is bounded.** Speeds are capped (4000 px/sec; warp at 1.5
79
88
  depth-units/sec; orbit at 180 deg/sec; path laps at ≥ 2 s).
80
89
  4. **Work is bounded.** ≤ 36 layers, ≤ 400 entities per layer, ≤ 800 total,
@@ -85,6 +94,44 @@ These hold **by construction** — no spec can violate them:
85
94
  replay a bounded fixed-step warm-up so even the accumulated smear is
86
95
  reproducible.
87
96
 
97
+ **Checked at publish.** Properties of the whole picture that no per-field
98
+ rule can promise. Each is an exported, deterministic check that a host
99
+ runs before it accepts a scene, and refuses on; none is part of
100
+ `validateSpec` or `compileSaver`, which run on every screen. A spec drawn
101
+ without them carries none of these guarantees.
102
+
103
+ 6. **Timeline flashes** (`timelineFlashReport`, WCAG 2.3.1): paint keys on
104
+ several paths that combine into more than 3 flashes a second over a
105
+ quarter of the screen. See Timeline for the method and its sample budget.
106
+ 7. **Spatial patterns** (`patternHazardReport`, ITU-R BT.1702-3 Annex 1):
107
+ more than five light-dark pairs of regular stripes (parallel, radial,
108
+ curved, or rows of repeated elements such as dots) whose light and dark
109
+ differ by a flash-level 0.1 relative luminance with the darker side under
110
+ 0.8, covering more than **40 %** of the frame while still, or more than
111
+ **25 %** while it spins, rocks, orbits, bounces, wanders, follows a path,
112
+ pulses, grows, emits, cycles or is keyed by the timeline. A pattern that
113
+ only flows one way (`drift` without `bob` or `bidirectional`, `rise`
114
+ without `sway`, `warp`) is judged as still, and is refused when the
115
+ stripes crossing a point make more than 3 light-dark cycles a second
116
+ (speed ÷ pitch) over 25 % of the frame. The scene is drawn by the
117
+ analytic perception raster at 216 cells on the short side and sampled at
118
+ t = 5 s and up to five points where timeline keys settle.
119
+
120
+ **Not checked.** Nothing measures these; an author has to:
121
+
122
+ - Fast motion of large shapes other than regular stripes. Speeds are capped,
123
+ but a big, bright shape sweeping across a dark frame is not analysed for
124
+ flashes (the flash check reads timeline paint keys only).
125
+ - Stripes finer than about 8 px at 1080p (1/135 of the short side), which
126
+ the pattern raster averages to grey, and stripes coarser than 0.15 of the
127
+ short side, which it does not count as a pattern. Text (`text`,
128
+ `textBlock`) is left out of the pattern check: its glyphs are not drawn.
129
+ - A pattern that appears only between the sampled times, or only on a
130
+ viewport other than the one checked (1920×1080 unless the host passes
131
+ another), and a pattern formed only where a sequence's bed and segment
132
+ overlap when each is checked on its own.
133
+ - Saturated-red flashes, beyond the validator's approximate red check.
134
+
88
135
  ## Validation semantics
89
136
 
90
137
  Two validators exist with deliberately different strictness:
@@ -96,6 +143,13 @@ Two validators exist with deliberately different strictness:
96
143
 
97
144
  Author against the JSON Schema; ship through the runtime validator.
98
145
 
146
+ Two slips have one unambiguous reading, so `validateSpec()` repairs them in
147
+ place instead of ignoring them: a sprite with `colors` but no `color`
148
+ gets `color: colors[0]`, and a `size` written inside an emoji or text `sprite`
149
+ moves up to the layer (with a `misplaced-property` warning; if the layer
150
+ already has a `size`, the layer's value wins and the sprite's is ignored).
151
+ The JSON Schema still rejects `sprite.size`.
152
+
99
153
  An unknown field that is another motion tool's vocabulary gets a pointer in
100
154
  its `unknown-property` warning: `delay` on a layer names `life.enter`,
101
155
  `duration` on a timeline key names `dur`, `fps` or `bpm` at the top level
@@ -142,6 +196,15 @@ and only `units: "px"` specs get it verbatim. See **Scale** below.
142
196
  `ghosting` paints each frame over a faded copy of the previous one instead of
143
197
  clearing: moving entities leave decaying after-images (Mystify smears, Matrix
144
198
  trails, long-exposure light). 0.85–0.95 is the useful range; 0 (default) is off.
199
+ Only the background is faded in: every layer still paints in full each frame on
200
+ top, so wherever an opaque sprite (alpha 1) sits it repaints over the smear —
201
+ a static opaque sprite looks the same with ghosting as without, and only
202
+ motion leaves a trail. A static translucent sprite of alpha *a* over
203
+ bare ground builds up to an effective alpha of *a* / (1 − *g*(1 − *a*)) at
204
+ `ghosting` *g* — 0.5 at *g* = 0.9 reads as 0.91 — so a soft glow brightens
205
+ under ghosting rather than smearing. That figure is the 60 fps steady state,
206
+ the rate seeks and the warm-up replay paint at; a live display fades by
207
+ *g*^(Δt / 16.7 ms) per frame, so at 120 Hz the build-up is a little stronger.
145
208
 
146
209
  `density` declares what the emptiness or the crowding means. `sparse` says
147
210
  the scene is *meant* to be almost empty — one faint mark on a dark ground,
@@ -171,7 +234,7 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
171
234
  | `scale` | 0.5..8 | — | noise features across the **short side** of the viewport (so a 16:9 frame shows more of the same field than a square one) |
172
235
  | `octaves` | int 1..4 | 2 | layers of detail; each halves in amplitude and doubles in frequency |
173
236
  | `warp` | 0..1 | 0 | domain warp: 0 round blobs, 1 the folded, smeared contours of a weather chart |
174
- | `quantize` | 0 or int 2..8 | `bands.length` | posterise into that many levels, one read off the `bands` ramp per level — hard, crisp contours (Terrain, Sonar). `bands.length` is exactly one band per level; more levels step through the ramp. `0` interpolates the ramp smoothly (Aura, Mist) |
237
+ | `quantize` | 0 or int 2..8 | `bands.length` | posterise into that many levels, one read off the `bands` ramp per level — hard-edged contours (Terrain, Sonar), stepped at the field raster's resolution (see **Raster and cache**). `bands.length` is exactly one band per level; more levels step through the ramp. `0` interpolates the ramp smoothly (Aura, Mist) |
175
238
  | `bands` | 2..8 hex | — | the ramp, low end of the field to high |
176
239
  | `drift` | `{ period ≥ 10000, amount 0..1 }` | none | the field's slow **animate**: the sample domain travels a circle of radius `amount` feature units once per `period` ms, so the contours crawl and the field loops exactly. `amount` defaults to 0.3 |
177
240
  | `seed` | number | the spec seed | the field's own seed — change it to get a different field without re-seating an entity |
@@ -190,8 +253,11 @@ exceeds 2 %, or a `dense` one that would have tripped `sparse-scene`, gets a
190
253
  **Raster and cache.** The renderer samples the field into a low-res raster
191
254
  — 96 px on the short side, the long side by aspect; 48 px on the `basic` /
192
255
  `minimal` capability tiers (`capabilityTier` on the mount context) — and
193
- draws it scaled to the canvas, nearest-neighbour for quantised bands so the
194
- contours stay crisp and smoothed for `quantize: 0`. A static field is
256
+ draws it scaled to the canvas: nearest-neighbour for quantised bands
257
+ (`quantize` ≥ 2), smoothed for `quantize: 0`. The contours are therefore
258
+ hard but not fine — each raster pixel is about 11 px on a 1080p screen (22 px
259
+ on the low tiers), so a band edge is a staircase of that pitch, not a smooth
260
+ line. Draw a line with a layer when it must be crisp. A static field is
195
261
  sampled **once per mount**; a drifting one is resampled only when `t`
196
262
  crosses a 100 ms bucket, so ten times a second at most, however high the
197
263
  frame rate. Steering any knob recomputes on the next frame.
@@ -289,7 +355,7 @@ on the sprite tier (the `basic` tier rule).
289
355
  | `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 |
290
356
  | `sprite` | SpriteSpec | — | what each entity looks like |
291
357
  | `motion` | MotionSpec | — | how entities move |
292
- | `size` | `[min,max]` > 0 | sprite-dependent | font size for emoji/text; ignored for shaped sprites |
358
+ | `size` | `[min,max]` > 0 | sprite-dependent | font size for emoji/text; ignored for shaped sprites. A **layer** field: it goes next to `count`, never inside `sprite` |
293
359
  | `wrap` | boolean | `true` | wrap to the opposite edge when leaving the viewport |
294
360
  | `flip` | boolean | `false` | mirror the sprite to face its heading |
295
361
  | `alpha` | `[min,max]` 0..1 | `[1,1]` | per-entity opacity range |
@@ -303,7 +369,7 @@ on the sprite tier (the `basic` tier rule).
303
369
  | `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 |
304
370
  | `links` | see below | none | inter-entity lines |
305
371
  | `layout` | `{type: "grid", columns?, jitter?}` \| `{type: "list", gap?}` \| `{type: "table", columns, gap?}` (`gap`: a number or `{x?, y?}`) | 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?}` — a list is one column, so only its `y`, the row pitch, applies). **`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 |
306
- | `life` | `{enter?, exit?, fade?, stagger?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit`. `stagger` shifts entity *i*'s whole envelope (enter and exit) by *i* × `stagger` in entity order (reading order for `list` / `table` / `grid`), so a row of labels builds one after another; paint, no RNG draws. Density scaling changes the count, so the build takes (count − 1) × `stagger`. **Native:** rendered |
372
+ | `life` | `{enter?, exit?, fade?, stagger?}` ms | always on | act structure: fade the layer in at `enter`, out at `exit`. The times are **scene time from mount** (a sequence segment's own start), not the `timeline` clock: under `timeline.loop` the envelope plays once and does not replay on later laps, so an `exit` stays gone (`adviseSpec` notes it as `life-runs-once`); key `opacity` in the timeline for a build that repeats every lap. `stagger` shifts entity *i*'s whole envelope (enter and exit) by *i* × `stagger` in entity order (reading order for `list` / `table` / `grid`), so a row of labels builds one after another; paint, no RNG draws. Density scaling changes the count, so the build takes (count − 1) × `stagger`. **Native:** rendered |
307
373
  | `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 |
308
374
  | `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` |
309
375
  | `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`) |
@@ -324,7 +390,14 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
324
390
  ### `sprite` (one of)
325
391
 
326
392
  - `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
327
- glyph picked per entity (seeded); optional `cycle` rotates variants over time
393
+ glyph picked per entity (seeded); optional `cycle` rotates variants over time.
394
+ The glyph size is the layer's `size`, beside `count`, not a sprite field:
395
+
396
+ ```jsonc
397
+ { "count": 12, "size": [0.03, 0.06],
398
+ "sprite": { "kind": "emoji", "glyphs": ["🐟"] },
399
+ "motion": { "type": "drift", "speed": [0.02, 0.05], "bidirectional": true } }
400
+ ```
328
401
  - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ..., "role": "read" }`
329
402
  — a `font` **with** a px size keeps that shorthand, its number rescaled to the
330
403
  viewport (verbatim only under `units: "px"`); a family/weight only
@@ -535,8 +608,12 @@ for `"nearest"` and `"random"`; a `"chain"` may omit both, except that
535
608
  checking on. Absent, or `"atmosphere"`, the layer is texture — a haiku
536
609
  fading in a corner, dim labels on a wall board, the `dev-dashboard`'s
537
610
  2.6:1 telemetry — and `adviseSpec` says nothing about how readable it is,
538
- exactly as before the field existed; keep atmospheric text undeclared, the
539
- advisories would only nag. `"read"` says the words must be read from
611
+ exactly as before the field existed. `"atmosphere"` goes one step further
612
+ and also opts the layer out of the layout checks — `text-overlap`,
613
+ `layout-text-overlap` and the validator's `list-length-mismatch` — since
614
+ texture words layered over a title or packed into a dense wall are the
615
+ look, not a collision; undeclared text is still checked for layout.
616
+ `"read"` says the words must be read from
540
617
  across the room, and opts that layer into two advisories:
541
618
  `text-legibility` fires when the text colour's WCAG ratio falls below
542
619
  **4.5:1** against the background sampled at the box centre (gradient stops
@@ -1111,14 +1188,29 @@ moves and scales its positions and sizes (sprite rotation is not modelled).
1111
1188
  its own × its group's — is 0 in the base scene and at every key boundary of
1112
1189
  its `timeline` never shows, so it is not judged and its entities do not count
1113
1190
  toward `dense-scene`; coverage is weighed by paint opacity) (also on
1114
- `perceiveScene().advisories`). Three of them are **spatial**, for static text:
1191
+ `perceiveScene().advisories`). Count-based advisories (`dense-scene`,
1192
+ `full-coherence`) read the **declared** `count`, not the density-scaled
1193
+ build, so a small preview and a 4K screen agree on them. `giant-entity` flags an emoji/text layer
1194
+ whose glyphs come out taller than the viewport's min dimension — usually
1195
+ px numbers in a viewport-unit spec (`size: [20, 40]` is 21,600 px at
1196
+ 1080p), which blankets the frame and otherwise reads as healthy coverage.
1197
+ Three of the advisories are **spatial**, for static text:
1115
1198
  `text-off-screen` (a text/textBlock box crosses the viewport edge by more
1116
1199
  than 1%), `text-overlap` (two static text layers share more than 10% of
1117
1200
  the smaller box; it carries both boxes as `boxes: [{x, y, w, h}]` in
1118
1201
  viewport fractions so the fix needs no re-derivation) and
1119
1202
  `layout-text-overlap` (two cells of one `list`/`table` layer do — its
1120
1203
  `gap` pitch is narrower than its labels; the message names the `gap` that
1121
- clears them, and it carries the two boxes too). Boxes come from the
1204
+ clears them, and it carries the two boxes too). A pair `text-overlap` finds
1205
+ at the sample instant is then checked over the piece: at that instant and
1206
+ at every key boundary of a `timeline` that keys the text, a box counts only
1207
+ while it shows (paint opacity in force, an effective `transform` scale above
1208
+ 2 % on both axes, a `reveal` that has begun, on screen), and the pair is
1209
+ reported only if both show together while they overlap — a cross-fade
1210
+ between two cards in one place is not a collision (the message then names
1211
+ the time, `both visible at N ms`). Text declared `role: "atmosphere"` is
1212
+ texture: it is left out of `text-overlap` and `layout-text-overlap`, and the
1213
+ validator's `list-length-mismatch` does not fire on it. Boxes come from the
1122
1214
  same character-class width table the textBlock line-breaker uses, so they
1123
1215
  are estimates of the renderer's own layout — a caption that fails these
1124
1216
  will look wrong on the wall; one that passes may still sit a few px off.
@@ -1143,6 +1235,12 @@ moves and scales its positions and sizes (sprite rotation is not modelled).
1143
1235
  their densest one-second window, flicker at least 2 times a second over a
1144
1236
  quarter of the frame — a full-frame step at the 200 ms key floor is 2.5, which
1145
1237
  the publish-time WCAG gate (more than 3) lets through.
1238
+ `life-runs-once` (informational) notes a layer whose `life` envelope sits
1239
+ under a looping timeline: `life` runs on scene time from mount, so it plays
1240
+ once and never replays when the lap wraps.
1241
+ `uniform-motion` fires when every entity of a moving layer travels at
1242
+ nearly the same on-screen speed; an orbit's is its angular speed × its
1243
+ radius, so one angular speed over a spread of radii is not uniform.
1146
1244
  `loop-seam` fires when a scene declares a looping timeline and the rest
1147
1245
  of the scene repeats too, but on a period the lap does not divide — so
1148
1246
  one lap can never come round to its own first frame (it names the period
@@ -1273,7 +1371,8 @@ change, with the baseline regenerated. **Native:** tvOS lerps the two
1273
1371
  segments frame by frame on the chain root's seed and honours `text`. Morph requires structurally
1274
1372
  identical adjacent segments (same `structuralSignature`); if they differ, the
1275
1373
  engine falls back to cut and the validator emits a `morph-structural-mismatch`
1276
- warning. When the segments are structural twins whose only differences are
1374
+ warning that names the fields that differ (e.g. `layers[2].layout.type: grid →
1375
+ list`; the first three, then a count of the rest). When the segments are structural twins whose only differences are
1277
1376
  values morph steps (a text-only change), the morph runs but every frame of it
1278
1377
  shows the incoming segment — it reads as a cut — and the validator emits a
1279
1378
  `morph-nothing-morphable` warning (never an error; stored sequences stay
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @idle-screens/schema
2
2
 
3
- Declarative, agent-authorable saver format for idle-screens. Validate a data spec, then compile it into a runnable, seeded, flash-safe SaverPlugin.
3
+ Declarative, agent-authorable saver format for idle-screens. Validate a data spec, then compile it into a runnable, seeded SaverPlugin with bounded motion and work (FORMAT.md, "Safety invariants", says what is guaranteed and what is checked at publish).
4
4
 
5
5
  See the [idle-screens repository](https://github.com/shaaaaawn/idle-screens) for full documentation.
package/dist/index.d.ts CHANGED
@@ -706,7 +706,9 @@ interface TextNumber {
706
706
  * words must be readable from across the room and opts the layer into
707
707
  * `adviseSpec`'s legibility advisories (`text-legibility`, `text-safe-area`).
708
708
  * `atmosphere` says the text is texture (a haiku fading in a corner, dim
709
- * labels on a wall board) and is the same as absent: silent. Changes no
709
+ * labels on a wall board): silent on legibility, like absent, and also left
710
+ * out of the layout checks (`text-overlap`, `layout-text-overlap`,
711
+ * `list-length-mismatch`) that still judge undeclared text. Changes no
710
712
  * pixel anywhere, on any client; never part of the structural signature.
711
713
  */
712
714
  type TextRole = 'read' | 'atmosphere';
@@ -1752,6 +1754,15 @@ declare const STEER_EASES: ReadonlySet<string>;
1752
1754
  * are baked at build time). Colour/alpha/pulse-only changes redraw in place.
1753
1755
  */
1754
1756
  declare function structuralSignature(spec: SaverSpec): string;
1757
+ /**
1758
+ * What makes two scenes differ structurally, named field by field — e.g.
1759
+ * `layers[2].layout.type: grid → list` — in the order `structuralSignature`
1760
+ * reads them. Empty exactly when the signatures agree. A layer whose sprite
1761
+ * kind changed reports the kind alone: every sprite field differs with it.
1762
+ */
1763
+ declare function structuralDifferences(a: SaverSpec, b: SaverSpec): string[];
1764
+ /** `structuralDifferences` as one phrase for a message: the first few, then a count of the rest. */
1765
+ declare function describeStructuralDifferences(a: SaverSpec, b: SaverSpec, max?: number): string;
1755
1766
 
1756
1767
  /**
1757
1768
  * Non-blocking advisory warnings for a valid spec. Does NOT replace validateSpec —
@@ -1773,11 +1784,15 @@ declare function adviseSpec(spec: SaverSpec, viewport?: {
1773
1784
  * potentially different seeds: `seed` drives the ink layers' own entity
1774
1785
  * stream, `backgroundSeed` samples the bed's field. Defaults to `seed` —
1775
1786
  * every other caller has one scene and one seed for both.
1787
+ *
1788
+ * `until` bounds the timeline times the over-time checks sample (a
1789
+ * sequence segment's `duration`): a segment never plays past its end.
1776
1790
  */
1777
1791
  opts?: {
1778
1792
  t?: number;
1779
1793
  seed?: number;
1780
1794
  backgroundSeed?: number;
1795
+ until?: number;
1781
1796
  }): SpecWarning[];
1782
1797
  /**
1783
1798
  * Non-blocking advisory warnings for a validated sequence envelope.
@@ -2002,7 +2017,13 @@ interface LuminanceGridOptions extends PerceiveOptions {
2002
2017
  * sampled at its rest position. Good enough to perceive composition, focus,
2003
2018
  * balance — and now smear.
2004
2019
  */
2005
- declare function luminanceGrid(spec: SaverSpec, opts?: LuminanceGridOptions): LuminanceGrid;
2020
+ declare function luminanceGrid(spec: SaverSpec, opts?: LuminanceGridOptions,
2021
+ /**
2022
+ * @internal The largest grid side. 200 for every public caller (a request
2023
+ * cannot ask for more); the pattern-hazard check raises it to resolve
2024
+ * stripes finer than a 200-cell grid can (see `pattern-hazard.ts`).
2025
+ */
2026
+ maxSide?: number): LuminanceGrid;
2006
2027
  /**
2007
2028
  * The luminance-grid cells each layer can paint (same viewport, seed and
2008
2029
  * grid as `luminanceGrid` with these options), for the timeline flash gate's
@@ -2381,6 +2402,88 @@ interface FlashSamplingForecast {
2381
2402
  */
2382
2403
  declare function flashSamplingForecast(spec: SaverSpec): FlashSamplingForecast;
2383
2404
 
2405
+ /** Raster cells across the viewport's short side. */
2406
+ declare const PATTERN_GRID_SHORT_SIDE = 216;
2407
+ /** "More than five light-dark pairs": six stripes of one tone, each between stripes of the other. */
2408
+ declare const PATTERN_MIN_PAIRS = 6;
2409
+ /** A still pattern is harmful above this fraction of the screen. */
2410
+ declare const PATTERN_STATIONARY_AREA = 0.4;
2411
+ /** A moving, oscillating, rotating or reversing pattern is harmful above this fraction. */
2412
+ declare const PATTERN_MOVING_AREA = 0.25;
2413
+ /** Forecast flicker above {@link FLASH_MAX_PER_SECOND} a second over this fraction fails. */
2414
+ declare const PATTERN_FLICKER_AREA = 0.25;
2415
+ /** Most sample times one check draws (t = 5 s plus timeline settle points). */
2416
+ declare const PATTERN_MAX_SAMPLE_TIMES = 6;
2417
+ /**
2418
+ * Stripes coarser than this (fraction of the short side) are not counted as
2419
+ * discernible stripes. BT.1702 sets no upper bound on stripe size; this one
2420
+ * is a judgment call, pending review: six stripes this coarse span most of
2421
+ * the frame's width (a row of tiles, a shelf of windows) rather than reading
2422
+ * as a grating. Lowering it to Infinity applies the standard's letter.
2423
+ */
2424
+ declare const PATTERN_MAX_PITCH = 0.15;
2425
+ /** A patch of runs must reach this many pitches across its stripes to be a pattern (a row of six objects is one pitch deep). */
2426
+ declare const PATTERN_MIN_EXTENT = 2;
2427
+ type PatternHazardKind = 'pattern' | 'moving-pattern' | 'flicker';
2428
+ interface PatternHazardReport {
2429
+ /** Refuse: a pattern over its BT.1702 area limit, or forecast flicker over 25 % of the frame. */
2430
+ fails: boolean;
2431
+ /** Which rule failed (null when it passes). */
2432
+ kind: PatternHazardKind | null;
2433
+ /** Scene time (ms) of the finding, or of the most patterned sample when it passes; null for a scene with no stripes at all. */
2434
+ at: number | null;
2435
+ /** Fraction of the frame covered by discernible stripes (the worst sample). */
2436
+ area: number;
2437
+ /** Of it, the fraction drawn by moving layers (measured only when `area` is between the two limits; else 0). */
2438
+ movingArea: number;
2439
+ /** Fraction of the frame forecast to flicker above 3 Hz (measured only when needed; else 0). */
2440
+ flickerArea: number;
2441
+ /** Fastest forecast flicker in a patterned region, flashes a second (0 when not measured). */
2442
+ flickerHz: number;
2443
+ /** Most stripes of one tone in any single run (a run qualifies at {@link PATTERN_MIN_PAIRS}). */
2444
+ pairs: number;
2445
+ /** That run's pitch, as a fraction of the viewport's short side (null without a qualifying run). */
2446
+ pitch: number | null;
2447
+ /** That run's mean light-dark swing in relative luminance (0..1, linear light). */
2448
+ contrast: number;
2449
+ /** Rasters drawn: the check's cost in units of one perception frame. */
2450
+ rasters: number;
2451
+ /** With `fails`: why, worded for the author, with what would pass. */
2452
+ message?: string;
2453
+ }
2454
+ interface PatternHazardOptions {
2455
+ /** Default 1920×1080. */
2456
+ viewport?: {
2457
+ width: number;
2458
+ height: number;
2459
+ };
2460
+ seed?: number;
2461
+ /** Sample times (ms) instead of t = 5 s plus the timeline's settle points. */
2462
+ times?: number[];
2463
+ }
2464
+ type PatternMotion = 'static' | 'flow' | 'moving';
2465
+ /**
2466
+ * How a layer's drawing changes over time, as BT.1702 sorts it: still,
2467
+ * flowing one way (`drift` without `bob` or `bidirectional`, `rise` without
2468
+ * `sway`, `warp`), or moving in any other way — spin, rock, orbit, bounce,
2469
+ * wander, path, pulse, grow, emit, a sprite `cycle`, or any timeline key on
2470
+ * the layer or its group. Anything this does not recognise is `moving`.
2471
+ */
2472
+ declare function layerPatternMotion(spec: SaverSpec, index: number): PatternMotion;
2473
+ /**
2474
+ * t = 5 s, plus each time a timeline key settles (where a keyed-in pattern
2475
+ * is at its fullest), within the first lap of a looping timeline. Past
2476
+ * {@link PATTERN_MAX_SAMPLE_TIMES} the settle times are subsampled evenly,
2477
+ * always keeping the last.
2478
+ */
2479
+ declare function patternSampleTimes(spec: SaverSpec): number[];
2480
+ /**
2481
+ * BT.1702 spatial-pattern check for one scene. Pure and deterministic (same
2482
+ * spec, seed and viewport ⇒ same report). Fails closed nowhere: every
2483
+ * finding is a measurement. See the module comment for what it does not see.
2484
+ */
2485
+ declare function patternHazardReport(spec: SaverSpec, opts?: PatternHazardOptions): PatternHazardReport;
2486
+
2384
2487
  /**
2385
2488
  * The `field` background sampler — seeded, dependency-free value noise that
2386
2489
  * is a PURE function of `(u, v, t, config, seed)`.
@@ -3314,5 +3417,5 @@ declare function pathFrame(s: PolygonSprite, r: number, w: number, h: number): {
3314
3417
  uniform: boolean;
3315
3418
  };
3316
3419
 
3317
- export { AQUARIUM_SPEC, AURORA_SPEC, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, GRAIN_ALPHA, GRAIN_TILE, HAIKU_SPEC, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, MATRIX_RAIN_SPEC, MAX_PATH_POINTS, MAX_SCENE_PERIOD, MOTION_FIELDS, MURMUR_SPEC, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, RAIN_SPEC, RELAY_BOARD_SPEC, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SEAMLESS_PERIOD_KINDS, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, STEER_EASES, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, adviseSequence, adviseSpec, alphaAt, applyDeltasShared, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, buriedAreaOf, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, diffScenes, dominanceRanking, drawSpan, easeGlide, easeSmooth, emitEnvelope, emitWindow, entityScales, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, flashSamplingForecast, flattenPath, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, isCombiningKana, isFullWidth, isPathPolygon, latticeHash, layerCellFootprints, layerCohesion, lerpPathD, lerpSpec, lerpValue, lifeAlphaAt, lifeStaggerAt, linkEdges, linkMaxDistPx, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, pathFrame, pathParts, perceiveScene, perceiveSequenceFrame, periodWithoutTimeline, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, sceneColour, scenePeriod, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spinSymmetry, spriteIndexAt, spriteVariants, steerablePaths, strokeDrawnSamples, strokeLines, structuralSignature, textBlockAnchorOffset, textBlockText, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineFlashReport, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, trimLines, trimPolyline, validateSequence, validateSpec, validateSpecPaths, wipeMask, withoutTimeline };
3318
- export type { BackgroundDrift, BackgroundSpec, BandSpec, CapabilityTier, CohesionRead, CycleSpec, DominanceEntry, Entity, FieldBackground, FieldDrift, FinishPass, FinishSpec, FlashSamplingForecast, FlattenedPath, GlideEase, GradientStop, IdleSequence, LayerCohesion, LayerGroup, LayerMotionStats, LayerSnapshot, LayerSpec, LayerTransform, LinkEdge, LuminanceGrid, LuminanceGridOptions, MotionEase, MotionSpec, PathGradient, PathGradientStop, PathPart, PathStroke, PerceiveOptions, PerceiveSequenceOptions, PeriodKind, Placed, ResolveOptions, ResolvedPart, ResolvedSegment, RevealState, SaverSpec, SceneColour, SceneDescription, SceneDiff, ScenePerception, ScenePeriod, SceneSnapshot, SchemaExample, ScreenOp, SequenceExample, SequenceFramePerception, SequenceMountContext, SequenceSaverInstance, SequenceSaverPlugin, SequenceSegment, SequenceTransition, SpecError, SpecMountContext, SpecWarning, SpriteSpec, SteerDelta, StrokeDraw, TextBlockAnchor, TextBlockLine, TextMetricsClass, TextNumber, TextRevealInput, TextRevealSpec, TextRole, TextSpriteInfo, Timeline, TimelineFlashReport, TimelineKey, ValidationResult, WanderOsc, WarningBox, WipeTransition };
3420
+ export { AQUARIUM_SPEC, AURORA_SPEC, COHESION_T, COMETS_SPEC, CONSTELLATION_SPEC, DASHBOARD_SPEC, DEFAULT_KEY_DUR, DITHER_ALPHA, DITHER_TILE, EXAMPLE_BY_ID, EXAMPLE_SEQUENCES, EXAMPLE_SPECS, FACETS_SPEC, FIELD_BUCKET_MS, FIELD_DRIFT_DEFAULT_AMOUNT, FIELD_RASTER_SHORT_SIDE, FIELD_RASTER_SHORT_SIDE_LOW, FINISH_ANIMATE_HZ, GRAIN_ALPHA, GRAIN_TILE, HAIKU_SPEC, LANTERNS_SPEC, LIMITS, LOBBY_TALK_SPEC, MATRIX_RAIN_SPEC, MAX_PATH_POINTS, MAX_SCENE_PERIOD, MOTION_FIELDS, MURMUR_SPEC, NOSTALGHIA_CANDLE_SPEC, ORRERY_SPEC, PATTERN_FLICKER_AREA, PATTERN_GRID_SHORT_SIDE, PATTERN_MAX_PITCH, PATTERN_MAX_SAMPLE_TIMES, PATTERN_MIN_EXTENT, PATTERN_MIN_PAIRS, PATTERN_MOVING_AREA, PATTERN_STATIONARY_AREA, PHASE_DUET_SPEC, PINGS_SPEC, POLYGONS_SPEC, PROCESSION_SPEC, RAIN_SPEC, RELAY_BOARD_SPEC, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SEAMLESS_PERIOD_KINDS, SHARD_FALL_SPEC, SIGNAL_BOARD_SPEC, SNOWFALL_SPEC, SPARSE_NIGHT_SPEC, STEER_EASES, THERMAL_FIELD_SPEC, THREE_MOVEMENTS_SEQUENCE, TYPED_CUE_SPEC, WARP_MAX_SCALE, WARP_NEAR, WARP_TUNNEL_SPEC, WEB_WORK_SPEC, adviseSequence, adviseSpec, alphaAt, applyDeltasShared, applyDeltasToSpec, assertValidSequence, assertValidSpec, bayerMatrix, bayerTilePixels, bedRenderSeed, breakTextBlock, buildEntities, buriedAreaOf, canMorph, canWrapMorph, canonicalSpecPath, cohesionOf, compileSaver, compileSequence, createFinishPass, createSceneCanvas, describeScene, describeStructuralDifferences, diffScenes, dominanceRanking, drawSpan, easeGlide, easeSmooth, emitEnvelope, emitWindow, entityScales, fieldAt, fieldOctaves, fieldRgb, fieldRgbAt, fieldSampleTime, finishStrength, flashSamplingForecast, flattenPath, grainOffset, grainTilePixels, graphemeClusters, hasHotSwapSequence, headingAt, hexToRgb255, ignoredPropertyPaths, isCombiningKana, isFullWidth, isPathPolygon, latticeHash, layerCellFootprints, layerCohesion, layerPatternMotion, lerpPathD, lerpSpec, lerpValue, lifeAlphaAt, lifeStaggerAt, linkEdges, linkMaxDistPx, linkPairs, luminanceGrid, manifestFor, morphChainRoot, morphNothingMorphable, motionStats, nextKeyAfter, normalizeSeed, pathFrame, pathParts, patternHazardReport, patternSampleTimes, perceiveScene, perceiveSequenceFrame, periodWithoutTimeline, pickScreenOp, positionAt, presentWithFinish, readSpecPath, renderBrailleMap, renderDensityMap, resolveSegment, resolveSpecPath, resolveTimelineAt, revealState, rgb255Luma, rotationAt, sceneColour, scenePeriod, seamCauseOf, seamsWorthWarning, segmentRenderSeed, segmentStart, sequenceSteerablePaths, sequenceSwapCompatible, sequenceWantsFinish, sizeAt, spinSymmetry, spriteIndexAt, spriteVariants, steerablePaths, strokeDrawnSamples, strokeLines, structuralDifferences, structuralSignature, textBlockAnchorOffset, textBlockText, textMetricsClassFor, textSprites, textStringsDiffer, textStringsOf, textWidthEm, timelineFlashReport, timelineLocalTime, timelineSampleTimes, timelineSampleTimesAfter, timelineTracks, trimLines, trimPolyline, validateSequence, validateSpec, validateSpecPaths, wipeMask, withoutTimeline };
3421
+ export type { BackgroundDrift, BackgroundSpec, BandSpec, CapabilityTier, CohesionRead, CycleSpec, DominanceEntry, Entity, FieldBackground, FieldDrift, FinishPass, FinishSpec, FlashSamplingForecast, FlattenedPath, GlideEase, GradientStop, IdleSequence, LayerCohesion, LayerGroup, LayerMotionStats, LayerSnapshot, LayerSpec, LayerTransform, LinkEdge, LuminanceGrid, LuminanceGridOptions, MotionEase, MotionSpec, PathGradient, PathGradientStop, PathPart, PathStroke, PatternHazardKind, PatternHazardOptions, PatternHazardReport, PatternMotion, PerceiveOptions, PerceiveSequenceOptions, PeriodKind, Placed, ResolveOptions, ResolvedPart, ResolvedSegment, RevealState, SaverSpec, SceneColour, SceneDescription, SceneDiff, ScenePerception, ScenePeriod, SceneSnapshot, SchemaExample, ScreenOp, SequenceExample, SequenceFramePerception, SequenceMountContext, SequenceSaverInstance, SequenceSaverPlugin, SequenceSegment, SequenceTransition, SpecError, SpecMountContext, SpecWarning, SpriteSpec, SteerDelta, StrokeDraw, TextBlockAnchor, TextBlockLine, TextMetricsClass, TextNumber, TextRevealInput, TextRevealSpec, TextRole, TextSpriteInfo, Timeline, TimelineFlashReport, TimelineKey, ValidationResult, WanderOsc, WarningBox, WipeTransition };