@idle-screens/schema 0.1.0 → 2.0.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
@@ -52,6 +52,41 @@ interface LayerSpec {
52
52
  wrap?: boolean;
53
53
  /** Flip the sprite horizontally to face its heading. Default false. */
54
54
  flip?: boolean;
55
+ /** Per-entity opacity range, both 0..1. Default [1,1]. */
56
+ alpha?: [number, number];
57
+ /** Compositing for this layer. 'lighter' = additive (glow stacking). Default source-over. */
58
+ blend?: 'lighter';
59
+ /**
60
+ * Fractional spawn window (0 = left/top, 1 = right/bottom). Constrains where
61
+ * entities are PLACED, not where they may travel. Default full viewport.
62
+ */
63
+ region?: {
64
+ x?: [number, number];
65
+ y?: [number, number];
66
+ };
67
+ /**
68
+ * Sinusoidal opacity breathing. SAFETY: amp is capped (LIMITS.maxPulseAmp),
69
+ * period has a floor (LIMITS.minPulsePeriod — max 2 Hz), and every entity gets
70
+ * its own seeded phase, so a layer can never strobe in unison. Effective alpha
71
+ * is clamped to 0..1.
72
+ */
73
+ pulse?: {
74
+ amp: number;
75
+ period: number;
76
+ };
77
+ /**
78
+ * Addressable name for this layer. Enables `setParam` to use `key.field` paths
79
+ * instead of `layers.N.field` indices. Also makes specs self-documenting.
80
+ */
81
+ key?: string;
82
+ /**
83
+ * Exact fractional position {x, y} for a single entity (0 = left/top, 1 = right/bottom).
84
+ * Only valid when `count` is 1. Overrides `region` scatter placement.
85
+ */
86
+ position?: {
87
+ x: number;
88
+ y: number;
89
+ };
55
90
  }
56
91
  type SpriteSpec = {
57
92
  kind: 'emoji';
@@ -61,10 +96,16 @@ type SpriteSpec = {
61
96
  strings: string[];
62
97
  color?: string;
63
98
  font?: string;
64
- } | {
99
+ align?: 'left' | 'center' | 'right';
100
+ baseline?: 'top' | 'middle' | 'bottom';
101
+ maxWidth?: number;
102
+ }
103
+ /** `soft` renders a radial falloff (glow orb) instead of a hard disc. */
104
+ | {
65
105
  kind: 'circle';
66
106
  radius: [number, number];
67
107
  color: string;
108
+ soft?: boolean;
68
109
  };
69
110
  type MotionSpec =
70
111
  /**
@@ -91,6 +132,10 @@ type MotionSpec =
91
132
  | {
92
133
  type: 'bounce';
93
134
  speed: [number, number];
135
+ }
136
+ /** Entity stays exactly where placed. No movement. Use with `position` for pinned elements. */
137
+ | {
138
+ type: 'static';
94
139
  };
95
140
  /** A validation problem, pointing at a JSON path within the spec. */
96
141
  interface SpecError {
@@ -105,8 +150,10 @@ interface ValidationResult {
105
150
  declare const LIMITS: {
106
151
  readonly maxPerLayer: 400;
107
152
  readonly maxTotal: 800;
108
- readonly maxLayers: 8;
153
+ readonly maxLayers: 36;
109
154
  readonly maxSpeed: 4000;
155
+ readonly maxPulseAmp: 0.5;
156
+ readonly minPulsePeriod: 500;
110
157
  };
111
158
 
112
159
  /**
@@ -128,8 +175,12 @@ interface Entity {
128
175
  vx: number;
129
176
  vy: number;
130
177
  bob: number;
131
- motion: 'drift' | 'rise' | 'bounce';
178
+ motion: 'drift' | 'rise' | 'bounce' | 'static';
132
179
  headingLeft: boolean;
180
+ alpha: number;
181
+ pulseAmp: number;
182
+ pulsePeriod: number;
183
+ pulsePhase: number;
133
184
  }
134
185
  interface Placed {
135
186
  x: number;
@@ -139,6 +190,8 @@ interface Placed {
139
190
  declare function spriteVariants(sprite: SpriteSpec): number;
140
191
  /** Deterministically place a layer's entities using the seeded RNG (never Math.random). */
141
192
  declare function buildEntities(layer: LayerSpec, rng: Rng, w: number, h: number): Entity[];
193
+ /** Analytic opacity of an entity at logical time `t` (ms). Pure, clamped to 0..1. */
194
+ declare function alphaAt(e: Entity, t: number): number;
142
195
  /** Analytic position of an entity at logical time `t` (ms). Pure & deterministic. */
143
196
  declare function positionAt(e: Entity, t: number, w: number, h: number): Placed;
144
197
 
@@ -151,14 +204,100 @@ declare function manifestFor(spec: SaverSpec): SaverManifest;
151
204
  */
152
205
  declare function compileSaver(spec: unknown): SaverPlugin;
153
206
 
207
+ /**
208
+ * Pure helpers for LIVE steering of compiled specs (setParam / applyTrack).
209
+ * A track's deltas address spec fields by dot-path ("layers.0.count",
210
+ * "background.stops.1.color") or layer key ("fireflies.count"). Steering
211
+ * changes existing values only — unknown paths are ignored (the server
212
+ * validates and rejects them; the runtime stays lenient).
213
+ */
214
+
215
+ interface PathTarget {
216
+ parent: Record<string, unknown> | unknown[];
217
+ key: string | number;
218
+ }
219
+ /** Resolve a dot-path (key-aware) to its parent + final key; null if absent. */
220
+ declare function resolveSpecPath(spec: unknown, path: string): PathTarget | null;
221
+ /** A steering delta as carried on a channel control-track. */
222
+ interface SteerDelta {
223
+ t: number;
224
+ path: string;
225
+ value: unknown;
226
+ ease?: string;
227
+ dur?: number;
228
+ }
229
+ /** Apply deltas to a deep copy of the spec (last-wins, target values). */
230
+ declare function applyDeltasToSpec(spec: SaverSpec, deltas: SteerDelta[]): SaverSpec;
231
+ /**
232
+ * Interpolate between two structurally-similar specs at progress k (0..1).
233
+ * Numbers lerp, hex colours lerp per channel, everything else steps to the
234
+ * target immediately (k > 0). Layer counts round to integers.
235
+ */
236
+ declare function lerpSpec(from: SaverSpec, to: SaverSpec, k: number): SaverSpec;
237
+ /** Smooth (ease-in-out) progress curve used for glides. */
238
+ declare function easeSmooth(k: number): number;
239
+ /**
240
+ * Fields that require re-seeding entities when they change (placement/motion
241
+ * are baked at build time). Colour/alpha/pulse-only changes redraw in place.
242
+ */
243
+ declare function structuralSignature(spec: SaverSpec): string;
244
+
154
245
  /**
155
246
  * Reproduces the hand-coded Fish Aquarium saver as pure DATA — the discriminating test
156
247
  * that the schema is actually expressive: two layers (fish drifting both ways at varied
157
248
  * speeds with a bob + flip, over a gradient with a seafloor band; bubbles rising with sway).
158
249
  */
159
250
  declare const AQUARIUM_SPEC: SaverSpec;
251
+
160
252
  /** Rain: dense text streaks falling straight down over a near-black sky. */
161
253
  declare const RAIN_SPEC: SaverSpec;
254
+
255
+ /** Snowfall: three depth layers of white circles drifting down with horizontal bob. */
256
+ declare const SNOWFALL_SPEC: SaverSpec;
257
+
258
+ /**
259
+ * Night Lanterns (Yi Peng festival sky): depth from four correlated parallax cues —
260
+ * closer layers are LARGER, FASTER, SWAY WIDER, and glow WARMER/BRIGHTER. v2 of this
261
+ * composition exercises the expressive additions the first draft exposed as missing:
262
+ * soft glow orbs + additive blend (lantern light that sums where it overlaps), bounded
263
+ * alpha pulse with per-entity phases (breathing flames, strobe-proof by construction),
264
+ * and spawn regions (stars only above the horizon; dim ember shimmer kept low near
265
+ * the water line).
266
+ */
267
+ declare const LANTERNS_SPEC: SaverSpec;
268
+
269
+ /**
270
+ * Sakura Drift: cherry-blossom petals carried on a spring twilight breeze. Five
271
+ * correlated-parallax layers build the depth — the far backdrop is a barely-moving
272
+ * field of soft blossom-glow orbs (additive, breathing out of phase), then three
273
+ * petal layers grow LARGER, FASTER, BRIGHTER and FLUTTER WIDER as they approach
274
+ * (the near gust), over a blue-hour gradient with a warm rose horizon and a dark
275
+ * grass band. A final low layer of dim pink glow settles at the grass line — fallen
276
+ * petals catching the last light. Wind direction is consistent (down-left) across
277
+ * every moving layer; petals differ from snowfall by glyph, colour, palette and the
278
+ * diagonal breeze, and from lanterns by falling rather than rising.
279
+ */
280
+ declare const SAKURA_SPEC: SaverSpec;
281
+
282
+ /**
283
+ * Dev Dashboard: a dense ops-style HUD exercising static positioning, keyed layers,
284
+ * and text alignment at scale. Two ambient particle layers provide depth behind 28
285
+ * pinned text elements arranged in a three-column layout with header, metrics,
286
+ * file list, and scrolling event log.
287
+ */
288
+ declare const DASHBOARD_SPEC: SaverSpec;
289
+
290
+ /** Catalog entry for a bundled schema example. */
291
+ interface SchemaExample {
292
+ id: string;
293
+ label: string;
294
+ spec: SaverSpec;
295
+ }
296
+ /** Ordered catalog of bundled schema examples. Add new specs as standalone files, then register here. */
297
+ declare const SCHEMA_EXAMPLES: readonly SchemaExample[];
298
+ /** All example specs in catalog order (tests, batch compile). */
162
299
  declare const EXAMPLE_SPECS: SaverSpec[];
300
+ /** Lookup by saver id. */
301
+ declare const EXAMPLE_BY_ID: Readonly<Record<string, SaverSpec>>;
163
302
 
164
- export { AQUARIUM_SPEC, type BackgroundSpec, type BandSpec, EXAMPLE_SPECS, type Entity, type GradientStop, LIMITS, type LayerSpec, type MotionSpec, type Placed, RAIN_SPEC, SCHEMA_VERSION, type SaverSpec, type SpecError, type SpriteSpec, type ValidationResult, assertValidSpec, buildEntities, compileSaver, manifestFor, positionAt, spriteVariants, validateSpec };
303
+ export { AQUARIUM_SPEC, type BackgroundSpec, type BandSpec, DASHBOARD_SPEC, EXAMPLE_BY_ID, EXAMPLE_SPECS, type Entity, type GradientStop, LANTERNS_SPEC, LIMITS, type LayerSpec, type MotionSpec, type Placed, RAIN_SPEC, SAKURA_SPEC, SCHEMA_EXAMPLES, SCHEMA_VERSION, SNOWFALL_SPEC, type SaverSpec, type SchemaExample, type SpecError, type SpriteSpec, type SteerDelta, type ValidationResult, alphaAt, applyDeltasToSpec, assertValidSpec, buildEntities, compileSaver, easeSmooth, lerpSpec, manifestFor, positionAt, resolveSpecPath, spriteVariants, structuralSignature, validateSpec };