@idle-screens/schema 1.0.0 → 2.1.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 ADDED
@@ -0,0 +1,135 @@
1
+ # SaverSpec — Format Specification (version 1)
2
+
3
+ The declarative, agent-authorable screensaver format compiled by
4
+ `@idle-screens/schema`. A spec describes a saver as **data** — a static
5
+ background plus layers of moving sprites — which `compileSaver()` turns into a
6
+ seeded, deterministic, flash-safe `SaverPlugin`. There is no code in a spec: no
7
+ scripting, no network access, no DOM access.
8
+
9
+ - **Machine-readable schema:** [`saver-spec.schema.json`](./saver-spec.schema.json)
10
+ (JSON Schema draft-07), importable as
11
+ `@idle-screens/schema/saver-spec.schema.json`.
12
+ - **Runtime validator:** `validateSpec(spec)` / `assertValidSpec(spec)` — used by
13
+ `compileSaver()`, which refuses to run an invalid spec.
14
+
15
+ ## Versioning
16
+
17
+ The **format version** (`schemaVersion`, currently `1`) is independent of the
18
+ npm package version. The package may ship breaking API changes without bumping
19
+ the format; the format only bumps when existing specs would parse or render
20
+ differently. New *optional* fields may be added within a format version —
21
+ older runtimes ignore unknown fields (see Validation semantics).
22
+
23
+ Additions to version 1 so far, all optional and backward compatible:
24
+ `alpha`, `blend`, `pulse`, `region` (layer appearance/placement);
25
+ `static` motion, `position`, `key` (dashboard/HUD support);
26
+ `align`, `baseline`, `maxWidth` (text sprites).
27
+
28
+ ## Safety invariants
29
+
30
+ These hold **by construction** — no spec can violate them:
31
+
32
+ 1. **No flash primitive.** The background is static and entities are bounded
33
+ sprites, so a compiled spec cannot strobe the full field. Provable by
34
+ sampling any compiled spec through `@idle-screens/validator`.
35
+ 2. **Pulse is bounded.** Opacity breathing amplitude is capped at 0.5 and its
36
+ period floored at 500 ms (2 Hz — under the WCAG 3 Hz flash threshold), and
37
+ every entity gets its own seeded phase, so a layer can never pulse in unison.
38
+ 3. **Motion is bounded.** Speeds are capped at 4000 px/sec.
39
+ 4. **Work is bounded.** ≤ 36 layers, ≤ 400 entities per layer, ≤ 800 total.
40
+ 5. **Determinism.** All randomness comes from a seeded PRNG. Same spec + same
41
+ seed ⇒ identical entity streams and identical frames at any time `t`
42
+ (compiled savers expose `renderFrame(t, seed)`).
43
+
44
+ ## Validation semantics
45
+
46
+ Two validators exist with deliberately different strictness:
47
+
48
+ | | unknown fields | everything else |
49
+ |---|---|---|
50
+ | `saver-spec.schema.json` | **rejected** (catches authoring typos) | identical |
51
+ | `validateSpec()` (runtime) | **ignored** (forward compatibility) | identical |
52
+
53
+ Author against the JSON Schema; ship through the runtime validator.
54
+
55
+ Two rules the JSON Schema cannot fully express (the runtime enforces them):
56
+ total entities across all layers ≤ 800, and every `[min, max]` range must
57
+ satisfy `min ≤ max`.
58
+
59
+ ## Structure
60
+
61
+ ```jsonc
62
+ {
63
+ "schemaVersion": 1,
64
+ "id": "snowfall", // unique kebab-case id
65
+ "label": "Snowfall",
66
+ "seed": 42, // optional; falls back to the host's seed
67
+ "motionIntensity": "calm", // optional: calm | moderate | energetic
68
+ "background": { ... }, // optional; defaults to black
69
+ "layers": [ { ... }, ... ] // 1..36, rendered back-to-front
70
+ }
71
+ ```
72
+
73
+ ### `background`
74
+
75
+ - `{ "type": "solid", "color": "#06060c" }`
76
+ - `{ "type": "gradient", "stops": [{ "at": 0, "color": "#06121e" }, { "at": 1, "color": "#0d2436" }], "band": { "color": "#3a2d18", "height": 90 } }`
77
+ — vertical gradient (`at` 0 = top → 1 = bottom); optional solid `band` at the
78
+ bottom (e.g. an aquarium seafloor). All colours are hex (`#rgb` / `#rrggbb`).
79
+
80
+ ### `layers[]`
81
+
82
+ | Field | Type | Default | Meaning |
83
+ |---|---|---|---|
84
+ | `count` | int 1..400 | — | entities in this layer |
85
+ | `sprite` | SpriteSpec | — | what each entity looks like |
86
+ | `motion` | MotionSpec | — | how entities move |
87
+ | `size` | `[min,max]` px > 0 | sprite-dependent | font size for emoji/text; ignored for circle |
88
+ | `wrap` | boolean | `true` | wrap to the opposite edge when leaving the viewport |
89
+ | `flip` | boolean | `false` | mirror the sprite to face its heading |
90
+ | `alpha` | `[min,max]` 0..1 | `[1,1]` | per-entity opacity range |
91
+ | `blend` | `"lighter"` | source-over | additive compositing (glow stacking) |
92
+ | `region` | `{x?, y?}` ranges 0..1 | full viewport | fractional spawn window (placement only, not travel) |
93
+ | `pulse` | `{amp ≤ 0.5, period ≥ 500}` | none | seeded-phase opacity breathing |
94
+ | `key` | string | none | addressable name → `setParam("key.field", …)` |
95
+ | `position` | `{x, y}` 0..1 | none | exact placement; **requires `count: 1`**; overrides `region` |
96
+
97
+ ### `sprite` (one of)
98
+
99
+ - `{ "kind": "emoji", "glyphs": ["🐟", "🐠"] }` — glyph picked per entity (seeded)
100
+ - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold 24px monospace", "align": "center", "baseline": "middle", "maxWidth": 300 }`
101
+ - `{ "kind": "circle", "radius": [1, 3], "color": "#ffffff", "soft": true }` —
102
+ `soft` renders a radial-falloff glow orb instead of a hard disc
103
+
104
+ ### `motion` (one of)
105
+
106
+ - `{ "type": "drift", "speed": [20, 60], "angle": 0, "bidirectional": true, "bob": 4 }`
107
+ — heading in degrees (0 = right, 90 = down); `bidirectional` flips horizontal
108
+ direction per entity (fish); `bob` adds vertical wobble (px)
109
+ - `{ "type": "rise", "speed": [10, 30], "sway": 6 }` — upward, with horizontal sway (bubbles)
110
+ - `{ "type": "bounce", "speed": [120, 240] }` — diagonal, reflecting off edges
111
+ - `{ "type": "static" }` — stays exactly where placed (use with `position` for HUD text)
112
+
113
+ All speeds are px/sec ranges; each entity draws its own value (seeded).
114
+
115
+ ## Determinism contract
116
+
117
+ Entity construction consumes the seeded RNG in a **fixed draw order** per layer.
118
+ New optional fields only consume RNG draws when present, so adding features to
119
+ the format never changes the entity stream of an existing spec — a spec authored
120
+ against an older runtime renders bit-identically on a newer one.
121
+
122
+ ## Steering
123
+
124
+ Compiled specs accept live parameter changes via dot-paths —
125
+ `layers.0.count`, `background.stops.0.color`, or `key`-based paths like
126
+ `cpu-gauge.color` when layers declare `key`. Changes interpolate over a
127
+ control-track (`step` | `linear` | `smooth`). See `@idle-screens/core` for
128
+ `ControlTrack` and the idlescreens.com MCP `setParam` tool.
129
+
130
+ ## Examples
131
+
132
+ Shipped working specs (also exposed as `EXAMPLE_SPECS`): `aquarium`, `rain`,
133
+ `snowfall`, `lanterns`, `sakura`, `dev-dashboard` — see
134
+ [`src/examples.ts`](./src/examples.ts). The dashboard exercises the static/HUD
135
+ subset at scale (34 layers of keyed, positioned text).
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ interface SaverSpec {
22
22
  background?: BackgroundSpec;
23
23
  layers: LayerSpec[];
24
24
  motionIntensity?: 'calm' | 'moderate' | 'energetic';
25
+ /** Dimensional unit system. 'viewport' = all sizes/speeds/distances are fractions of min(w,h). */
26
+ units?: 'px' | 'viewport';
25
27
  }
26
28
  type BackgroundSpec = {
27
29
  type: 'solid';
@@ -74,6 +76,20 @@ interface LayerSpec {
74
76
  amp: number;
75
77
  period: number;
76
78
  };
79
+ /**
80
+ * Per-entity rotation speed in degrees/sec (positive = clockwise).
81
+ * Each entity gets a seeded starting angle. Composes with any motion type.
82
+ */
83
+ spin?: number;
84
+ /**
85
+ * Sinusoidal size breathing, parallel to `pulse` for opacity. `amp` is a
86
+ * fraction of base size (0.3 = ±30 %). `period` in ms with the same
87
+ * flash-safety floor as pulse. Per-entity seeded phase.
88
+ */
89
+ grow?: {
90
+ amp: number;
91
+ period: number;
92
+ };
77
93
  /**
78
94
  * Addressable name for this layer. Enables `setParam` to use `key.field` paths
79
95
  * instead of `layers.N.field` indices. Also makes specs self-documenting.
@@ -87,10 +103,22 @@ interface LayerSpec {
87
103
  x: number;
88
104
  y: number;
89
105
  };
106
+ /**
107
+ * Inter-entity links: draw lines to each entity's k nearest neighbors within maxDist.
108
+ * Capped at LIMITS.maxLinksK. Layer count must be <= LIMITS.maxLinkLayerCount when set.
109
+ */
110
+ links?: {
111
+ k: number;
112
+ maxDist: number;
113
+ color?: string;
114
+ alpha?: number;
115
+ width?: number;
116
+ };
90
117
  }
91
118
  type SpriteSpec = {
92
119
  kind: 'emoji';
93
120
  glyphs: string[];
121
+ cycle?: CycleSpec;
94
122
  } | {
95
123
  kind: 'text';
96
124
  strings: string[];
@@ -99,6 +127,7 @@ type SpriteSpec = {
99
127
  align?: 'left' | 'center' | 'right';
100
128
  baseline?: 'top' | 'middle' | 'bottom';
101
129
  maxWidth?: number;
130
+ cycle?: CycleSpec;
102
131
  }
103
132
  /** `soft` renders a radial falloff (glow orb) instead of a hard disc. */
104
133
  | {
@@ -106,7 +135,12 @@ type SpriteSpec = {
106
135
  radius: [number, number];
107
136
  color: string;
108
137
  soft?: boolean;
138
+ colors?: string[];
109
139
  };
140
+ /** Rotate through sprite variants over time. Each entity offsets by its seeded phase. */
141
+ interface CycleSpec {
142
+ period: number;
143
+ }
110
144
  type MotionSpec =
111
145
  /**
112
146
  * Drift at a per-entity speed picked from `speed` (px/sec) along `angle` degrees
@@ -136,6 +170,20 @@ type MotionSpec =
136
170
  /** Entity stays exactly where placed. No movement. Use with `position` for pinned elements. */
137
171
  | {
138
172
  type: 'static';
173
+ }
174
+ /**
175
+ * Orbit around a center point. Each entity gets a seeded radius from `radius`
176
+ * and a seeded phase. `speed` is angular velocity in degrees/sec. `center` is
177
+ * fractional {x, y} (default {0.5, 0.5} = viewport center).
178
+ */
179
+ | {
180
+ type: 'orbit';
181
+ speed: [number, number];
182
+ radius: [number, number];
183
+ center?: {
184
+ x: number;
185
+ y: number;
186
+ };
139
187
  };
140
188
  /** A validation problem, pointing at a JSON path within the spec. */
141
189
  interface SpecError {
@@ -146,6 +194,12 @@ interface ValidationResult {
146
194
  valid: boolean;
147
195
  errors: SpecError[];
148
196
  }
197
+ /** An advisory warning (non-blocking). Returned by `adviseSpec`. */
198
+ interface SpecWarning {
199
+ path: string;
200
+ code: string;
201
+ message: string;
202
+ }
149
203
  /** Perf/safety caps enforced by `validateSpec`. */
150
204
  declare const LIMITS: {
151
205
  readonly maxPerLayer: 400;
@@ -154,6 +208,13 @@ declare const LIMITS: {
154
208
  readonly maxSpeed: 4000;
155
209
  readonly maxPulseAmp: 0.5;
156
210
  readonly minPulsePeriod: 500;
211
+ readonly maxSpin: 360;
212
+ readonly maxGrowAmp: 0.8;
213
+ readonly maxOrbitSpeed: 180;
214
+ readonly maxLinksK: 8;
215
+ readonly maxLinkLayerCount: 200;
216
+ readonly minCyclePeriod: 500;
217
+ readonly referenceViewport: 1080;
157
218
  };
158
219
 
159
220
  /**
@@ -175,12 +236,22 @@ interface Entity {
175
236
  vx: number;
176
237
  vy: number;
177
238
  bob: number;
178
- motion: 'drift' | 'rise' | 'bounce' | 'static';
239
+ motion: 'drift' | 'rise' | 'bounce' | 'static' | 'orbit';
179
240
  headingLeft: boolean;
180
241
  alpha: number;
181
242
  pulseAmp: number;
182
243
  pulsePeriod: number;
183
244
  pulsePhase: number;
245
+ spinSpeed: number;
246
+ spinPhase: number;
247
+ growAmp: number;
248
+ growPeriod: number;
249
+ growPhase: number;
250
+ orbitR: number;
251
+ orbitCx: number;
252
+ orbitCy: number;
253
+ colorIndex: number;
254
+ cyclePeriod: number;
184
255
  }
185
256
  interface Placed {
186
257
  x: number;
@@ -188,12 +259,25 @@ interface Placed {
188
259
  flip: boolean;
189
260
  }
190
261
  declare function spriteVariants(sprite: SpriteSpec): number;
191
- /** Deterministically place a layer's entities using the seeded RNG (never Math.random). */
192
- declare function buildEntities(layer: LayerSpec, rng: Rng, w: number, h: number): Entity[];
262
+ /** Deterministically place a layer's entities using the seeded RNG (never Math.random).
263
+ * `scale` multiplies every dimensional draw (default 1). Used for viewport units. */
264
+ declare function buildEntities(layer: LayerSpec, rng: Rng, w: number, h: number, scale?: number): Entity[];
193
265
  /** Analytic opacity of an entity at logical time `t` (ms). Pure, clamped to 0..1. */
194
266
  declare function alphaAt(e: Entity, t: number): number;
267
+ /** Analytic size multiplier at time `t` (ms). Pure, clamped to > 0. */
268
+ declare function sizeAt(e: Entity, t: number): number;
269
+ /** Analytic rotation angle at time `t` (ms) in radians. Pure. */
270
+ declare function rotationAt(e: Entity, t: number): number;
195
271
  /** Analytic position of an entity at logical time `t` (ms). Pure & deterministic. */
196
272
  declare function positionAt(e: Entity, t: number, w: number, h: number): Placed;
273
+ /** Time-varying sprite index for text/emoji cycling. Returns static index when cyclePeriod is 0. */
274
+ declare function spriteIndexAt(e: Entity, t: number, variants: number): number;
275
+ /** K-nearest-neighbor edge list. Deterministic tie-break: (dist, index).
276
+ * When `doWrap` is true, uses toroidal distance so links don't streak across wrap seams. */
277
+ declare function linkPairs(positions: Array<{
278
+ x: number;
279
+ y: number;
280
+ }>, k: number, maxDist: number, doWrap: boolean, w: number, h: number): Array<[number, number]>;
197
281
 
198
282
  /** Derive a manifest so a compiled spec composes with @idle-screens/capabilities. */
199
283
  declare function manifestFor(spec: SaverSpec): SaverManifest;
@@ -204,16 +288,73 @@ declare function manifestFor(spec: SaverSpec): SaverManifest;
204
288
  */
205
289
  declare function compileSaver(spec: unknown): SaverPlugin;
206
290
 
291
+ /**
292
+ * Pure helpers for LIVE steering of compiled specs (setParam / applyTrack).
293
+ * A track's deltas address spec fields by dot-path ("layers.0.count",
294
+ * "background.stops.1.color") or layer key ("fireflies.count"). Steering
295
+ * changes existing values only — unknown paths are ignored (the server
296
+ * validates and rejects them; the runtime stays lenient).
297
+ */
298
+
299
+ interface PathTarget {
300
+ parent: Record<string, unknown> | unknown[];
301
+ key: string | number;
302
+ }
303
+ /** Resolve a dot-path (key-aware) to its parent + final key; null if absent. */
304
+ declare function resolveSpecPath(spec: unknown, path: string): PathTarget | null;
305
+ /** A steering delta as carried on a channel control-track. */
306
+ interface SteerDelta {
307
+ t: number;
308
+ path: string;
309
+ value: unknown;
310
+ ease?: string;
311
+ dur?: number;
312
+ }
313
+ /** Apply deltas to a deep copy of the spec (last-wins, target values). */
314
+ declare function applyDeltasToSpec(spec: SaverSpec, deltas: SteerDelta[]): SaverSpec;
315
+ /**
316
+ * Interpolate between two structurally-similar specs at progress k (0..1).
317
+ * Numbers lerp, hex colours lerp per channel, everything else steps to the
318
+ * target immediately (k > 0). Layer counts round to integers.
319
+ */
320
+ declare function lerpSpec(from: SaverSpec, to: SaverSpec, k: number): SaverSpec;
321
+ /** Smooth (ease-in-out) progress curve used for glides. */
322
+ declare function easeSmooth(k: number): number;
323
+ /**
324
+ * Fields that require re-seeding entities when they change (placement/motion
325
+ * are baked at build time). Colour/alpha/pulse-only changes redraw in place.
326
+ */
327
+ declare function structuralSignature(spec: SaverSpec): string;
328
+
329
+ /**
330
+ * Non-blocking advisory warnings for a valid spec. Does NOT replace validateSpec —
331
+ * call advise only on specs that have already passed validation.
332
+ */
333
+ declare function adviseSpec(spec: SaverSpec, viewport?: {
334
+ width: number;
335
+ height: number;
336
+ }): SpecWarning[];
337
+
207
338
  /**
208
339
  * Reproduces the hand-coded Fish Aquarium saver as pure DATA — the discriminating test
209
340
  * that the schema is actually expressive: two layers (fish drifting both ways at varied
210
341
  * speeds with a bob + flip, over a gradient with a seafloor band; bubbles rising with sway).
211
342
  */
212
343
  declare const AQUARIUM_SPEC: SaverSpec;
344
+
345
+ /**
346
+ * Constellation: drifting stars connected by faint k-nearest links. Exercises
347
+ * colors[] (multi-hued stars), links (k-nearest neighbor lines), and cycle
348
+ * (emoji asterisms rotate glyphs). Stars are soft circles in pixel units.
349
+ */
350
+ declare const CONSTELLATION_SPEC: SaverSpec;
351
+
213
352
  /** Rain: dense text streaks falling straight down over a near-black sky. */
214
353
  declare const RAIN_SPEC: SaverSpec;
354
+
215
355
  /** Snowfall: three depth layers of white circles drifting down with horizontal bob. */
216
356
  declare const SNOWFALL_SPEC: SaverSpec;
357
+
217
358
  /**
218
359
  * Night Lanterns (Yi Peng festival sky): depth from four correlated parallax cues —
219
360
  * closer layers are LARGER, FASTER, SWAY WIDER, and glow WARMER/BRIGHTER. v2 of this
@@ -224,6 +365,7 @@ declare const SNOWFALL_SPEC: SaverSpec;
224
365
  * the water line).
225
366
  */
226
367
  declare const LANTERNS_SPEC: SaverSpec;
368
+
227
369
  /**
228
370
  * Sakura Drift: cherry-blossom petals carried on a spring twilight breeze. Five
229
371
  * correlated-parallax layers build the depth — the far backdrop is a barely-moving
@@ -236,6 +378,7 @@ declare const LANTERNS_SPEC: SaverSpec;
236
378
  * diagonal breeze, and from lanterns by falling rather than rising.
237
379
  */
238
380
  declare const SAKURA_SPEC: SaverSpec;
381
+
239
382
  /**
240
383
  * Dev Dashboard: a dense ops-style HUD exercising static positioning, keyed layers,
241
384
  * and text alignment at scale. Two ambient particle layers provide depth behind 28
@@ -243,6 +386,26 @@ declare const SAKURA_SPEC: SaverSpec;
243
386
  * file list, and scrolling event log.
244
387
  */
245
388
  declare const DASHBOARD_SPEC: SaverSpec;
389
+
390
+ /**
391
+ * Orrery: a mechanical solar system. Exercises orbit motion (concentric rings
392
+ * at different speeds), spin (planets rotate on their axes), and grow (the sun
393
+ * pulses). Soft glow circles with additive blending create the corona and
394
+ * planetary halos.
395
+ */
396
+ declare const ORRERY_SPEC: SaverSpec;
397
+
398
+ /** Catalog entry for a bundled schema example. */
399
+ interface SchemaExample {
400
+ id: string;
401
+ label: string;
402
+ spec: SaverSpec;
403
+ }
404
+ /** Ordered catalog of bundled schema examples. Add new specs as standalone files, then register here. */
405
+ declare const SCHEMA_EXAMPLES: readonly SchemaExample[];
406
+ /** All example specs in catalog order (tests, batch compile). */
246
407
  declare const EXAMPLE_SPECS: SaverSpec[];
408
+ /** Lookup by saver id. */
409
+ declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
247
410
 
248
- export { AQUARIUM_SPEC, type BackgroundSpec, type BandSpec, DASHBOARD_SPEC, EXAMPLE_SPECS, type Entity, type GradientStop, LANTERNS_SPEC, LIMITS, type LayerSpec, type MotionSpec, type Placed, RAIN_SPEC, SAKURA_SPEC, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SpecError, type SpriteSpec, type ValidationResult, alphaAt, assertValidSpec, buildEntities, compileSaver, manifestFor, positionAt, spriteVariants, validateSpec };
411
+ export { AQUARIUM_SPEC, type BackgroundSpec, type BandSpec, CONSTELLATION_SPEC, type CycleSpec, DASHBOARD_SPEC, EXAMPLE_BY_ID, EXAMPLE_SPECS, type Entity, type GradientStop, LANTERNS_SPEC, LIMITS, type LayerSpec, type MotionSpec, ORRERY_SPEC, type Placed, RAIN_SPEC, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SchemaExample, type SpecError, type SpecWarning, type SpriteSpec, type SteerDelta, type ValidationResult, adviseSpec, alphaAt, applyDeltasToSpec, assertValidSpec, buildEntities, compileSaver, easeSmooth, lerpSpec, linkPairs, manifestFor, positionAt, resolveSpecPath, rotationAt, sizeAt, spriteIndexAt, spriteVariants, structuralSignature, validateSpec };