@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/dist/index.js CHANGED
@@ -8,8 +8,20 @@ var LIMITS = {
8
8
  // px/sec — bounds motion so nothing teleports
9
9
  maxPulseAmp: 0.5,
10
10
  // opacity breathing amplitude cap
11
- minPulsePeriod: 500
11
+ minPulsePeriod: 500,
12
12
  // ms — caps pulse at 2 Hz (WCAG flash threshold is 3 Hz)
13
+ maxSpin: 360,
14
+ // degrees/sec — one full revolution per second
15
+ maxGrowAmp: 0.8,
16
+ // size breathing amplitude cap (fraction of base size)
17
+ maxOrbitSpeed: 180,
18
+ // degrees/sec — half a revolution per second
19
+ maxLinksK: 8,
20
+ maxLinkLayerCount: 200,
21
+ minCyclePeriod: 500,
22
+ // ms — same flash-safety floor as pulse
23
+ referenceViewport: 1080
24
+ // for validating viewport-unit dimensional caps
13
25
  };
14
26
 
15
27
  // src/validate.ts
@@ -29,6 +41,9 @@ function validateSpec(spec) {
29
41
  if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
30
42
  err("motionIntensity", "must be calm | moderate | energetic");
31
43
  }
44
+ if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
45
+ err("units", "must be 'px' | 'viewport'");
46
+ }
32
47
  if (spec.background !== void 0) validateBackground(spec.background, err);
33
48
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
34
49
  err("layers", "must be a non-empty array");
@@ -37,7 +52,7 @@ function validateSpec(spec) {
37
52
  let total = 0;
38
53
  spec.layers.forEach((layer, i) => {
39
54
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
40
- validateLayer(layer, `layers[${i}]`, err);
55
+ validateLayer(layer, `layers[${i}]`, err, spec);
41
56
  });
42
57
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
43
58
  }
@@ -70,7 +85,7 @@ function validateBackground(bg, err) {
70
85
  err("background.type", "must be solid | gradient");
71
86
  }
72
87
  }
73
- function validateLayer(layer, path, err) {
88
+ function validateLayer(layer, path, err, spec) {
74
89
  if (!isObj(layer)) return err(path, "must be an object");
75
90
  if (!isNum(layer.count) || !Number.isInteger(layer.count) || layer.count < 1) {
76
91
  err(`${path}.count`, "must be a positive integer");
@@ -120,8 +135,44 @@ function validateLayer(layer, path, err) {
120
135
  }
121
136
  }
122
137
  }
138
+ if (layer.spin !== void 0) {
139
+ if (!isNum(layer.spin)) err(`${path}.spin`, "must be a number (degrees/sec)");
140
+ else if (Math.abs(layer.spin) > LIMITS.maxSpin) err(`${path}.spin`, `must be within \xB1${LIMITS.maxSpin} deg/sec`);
141
+ }
142
+ if (layer.grow !== void 0) {
143
+ if (!isObj(layer.grow)) err(`${path}.grow`, "must be an object");
144
+ else {
145
+ if (!isNum(layer.grow.amp) || layer.grow.amp <= 0 || layer.grow.amp > LIMITS.maxGrowAmp) {
146
+ err(`${path}.grow.amp`, `must be within 0..${LIMITS.maxGrowAmp}`);
147
+ }
148
+ if (!isNum(layer.grow.period) || layer.grow.period < LIMITS.minPulsePeriod) {
149
+ err(`${path}.grow.period`, `must be >= ${LIMITS.minPulsePeriod} ms (flash-safety floor)`);
150
+ }
151
+ }
152
+ }
153
+ if (layer.links !== void 0) {
154
+ if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
155
+ else {
156
+ if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
157
+ err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
158
+ }
159
+ if (!isNum(layer.links.maxDist) || layer.links.maxDist <= 0) {
160
+ err(`${path}.links.maxDist`, "must be > 0");
161
+ }
162
+ if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
163
+ if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
164
+ err(`${path}.links.alpha`, "must be 0..1");
165
+ }
166
+ if (layer.links.width !== void 0 && (!isNum(layer.links.width) || layer.links.width <= 0)) {
167
+ err(`${path}.links.width`, "must be > 0");
168
+ }
169
+ if (isNum(layer.count) && layer.count > LIMITS.maxLinkLayerCount) {
170
+ err(`${path}.links`, `layer count must be <= ${LIMITS.maxLinkLayerCount} when links is set`);
171
+ }
172
+ }
173
+ }
123
174
  validateSprite(layer.sprite, `${path}.sprite`, err);
124
- validateMotion(layer.motion, `${path}.motion`, err);
175
+ validateMotion(layer.motion, `${path}.motion`, err, spec);
125
176
  }
126
177
  function validateSprite(sprite, path, err) {
127
178
  if (!isObj(sprite)) return err(path, "must be an object");
@@ -129,6 +180,7 @@ function validateSprite(sprite, path, err) {
129
180
  if (!Array.isArray(sprite.glyphs) || sprite.glyphs.length === 0 || !sprite.glyphs.every(isStr)) {
130
181
  err(`${path}.glyphs`, "must be a non-empty array of strings");
131
182
  }
183
+ validateCycle(sprite, path, err);
132
184
  } else if (sprite.kind === "text") {
133
185
  if (!Array.isArray(sprite.strings) || sprite.strings.length === 0 || !sprite.strings.every(isStr)) {
134
186
  err(`${path}.strings`, "must be a non-empty array of strings");
@@ -143,19 +195,36 @@ function validateSprite(sprite, path, err) {
143
195
  if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
144
196
  err(`${path}.maxWidth`, "must be a positive number");
145
197
  }
198
+ validateCycle(sprite, path, err);
146
199
  } else if (sprite.kind === "circle") {
147
200
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
148
201
  color(sprite.color, `${path}.color`, err);
149
202
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
203
+ if (sprite.colors !== void 0) {
204
+ if (!Array.isArray(sprite.colors) || sprite.colors.length === 0) {
205
+ err(`${path}.colors`, "must be a non-empty array of hex colours");
206
+ } else {
207
+ sprite.colors.forEach((c, ci) => color(c, `${path}.colors[${ci}]`, err));
208
+ }
209
+ }
150
210
  } else {
151
211
  err(`${path}.kind`, "must be emoji | text | circle");
152
212
  }
153
213
  }
154
- function validateMotion(motion, path, err) {
214
+ function validateCycle(sprite, path, err) {
215
+ if (sprite.cycle === void 0) return;
216
+ if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
217
+ if (!isNum(sprite.cycle.period) || sprite.cycle.period < LIMITS.minCyclePeriod) {
218
+ err(`${path}.cycle.period`, `must be >= ${LIMITS.minCyclePeriod} ms (flash-safety floor)`);
219
+ }
220
+ }
221
+ function validateMotion(motion, path, err, spec) {
155
222
  if (!isObj(motion)) return err(path, "must be an object");
223
+ const isViewport = isObj(spec) && spec.units === "viewport";
224
+ const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
156
225
  const speedOk = (v, p) => {
157
226
  if (!isRange(v)) err(p, "must be a [min,max] range");
158
- else if (v[1] > LIMITS.maxSpeed) err(p, `speed exceeds cap ${LIMITS.maxSpeed} px/sec`);
227
+ else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
159
228
  };
160
229
  if (motion.type === "drift") {
161
230
  speedOk(motion.speed, `${path}.speed`);
@@ -168,8 +237,20 @@ function validateMotion(motion, path, err) {
168
237
  } else if (motion.type === "bounce") {
169
238
  speedOk(motion.speed, `${path}.speed`);
170
239
  } else if (motion.type === "static") {
240
+ } else if (motion.type === "orbit") {
241
+ if (!isRange(motion.speed)) err(`${path}.speed`, "must be a [min,max] range");
242
+ else if (Math.abs(motion.speed[0]) > LIMITS.maxOrbitSpeed || Math.abs(motion.speed[1]) > LIMITS.maxOrbitSpeed) err(`${path}.speed`, `orbit speed exceeds cap ${LIMITS.maxOrbitSpeed} deg/sec`);
243
+ if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
244
+ if (motion.center !== void 0) {
245
+ if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
246
+ err(`${path}.center`, "must be {x, y} with numbers 0..1");
247
+ } else {
248
+ if (motion.center.x < 0 || motion.center.x > 1) err(`${path}.center.x`, "must be 0..1");
249
+ if (motion.center.y < 0 || motion.center.y > 1) err(`${path}.center.y`, "must be 0..1");
250
+ }
251
+ }
171
252
  } else {
172
- err(`${path}.type`, "must be drift | rise | bounce | static");
253
+ err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
173
254
  }
174
255
  }
175
256
  function assertValidSpec(spec) {
@@ -187,12 +268,14 @@ function spriteVariants(sprite) {
187
268
  if (sprite.kind === "text") return sprite.strings.length;
188
269
  return 1;
189
270
  }
190
- function buildEntities(layer, rng, w, h) {
271
+ function buildEntities(layer, rng, w, h, scale = 1) {
191
272
  const [smin, smax] = layer.size ?? [20, 40];
192
273
  const variants = spriteVariants(layer.sprite);
274
+ const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
275
+ const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
193
276
  const out = [];
194
277
  for (let i = 0; i < layer.count; i++) {
195
- const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 : rng.range(smin, smax);
278
+ const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
196
279
  let vx = 0;
197
280
  let vy = 0;
198
281
  let bob = 0;
@@ -201,22 +284,25 @@ function buildEntities(layer, rng, w, h) {
201
284
  if (m.type === "static") {
202
285
  motion = "static";
203
286
  } else if (m.type === "drift") {
204
- const s = rng.range(m.speed[0], m.speed[1]);
287
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
205
288
  let angle = (m.angle ?? 0) * Math.PI / 180;
206
289
  if (m.bidirectional && rng.next() < 0.5) angle = Math.PI - angle;
207
290
  vx = s * Math.cos(angle);
208
291
  vy = s * Math.sin(angle);
209
- bob = m.bob ?? 0;
292
+ bob = (m.bob ?? 0) * scale;
210
293
  } else if (m.type === "rise") {
211
294
  motion = "rise";
212
- vy = -rng.range(m.speed[0], m.speed[1]);
213
- bob = m.sway ?? 0;
214
- } else {
295
+ vy = -rng.range(m.speed[0], m.speed[1]) * scale;
296
+ bob = (m.sway ?? 0) * scale;
297
+ } else if (m.type === "bounce") {
215
298
  motion = "bounce";
216
- const s = rng.range(m.speed[0], m.speed[1]);
299
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
217
300
  const a = rng.range(0, Math.PI * 2);
218
301
  vx = s * Math.cos(a);
219
302
  vy = s * Math.sin(a);
303
+ } else if (m.type === "orbit") {
304
+ motion = "orbit";
305
+ vx = rng.range(m.speed[0], m.speed[1]);
220
306
  }
221
307
  let x0;
222
308
  let y0;
@@ -243,7 +329,20 @@ function buildEntities(layer, rng, w, h) {
243
329
  alpha: layer.alpha ? rng.range(layer.alpha[0], layer.alpha[1]) : 1,
244
330
  pulseAmp: layer.pulse?.amp ?? 0,
245
331
  pulsePeriod: layer.pulse?.period ?? 1e3,
246
- pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0
332
+ pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0,
333
+ // New draws AFTER pulsePhase — guarded so existing specs keep identical streams.
334
+ spinSpeed: layer.spin ? layer.spin * Math.PI / 180 : 0,
335
+ // deg/sec → rad/sec
336
+ spinPhase: layer.spin ? rng.range(0, Math.PI * 2) : 0,
337
+ growAmp: layer.grow?.amp ?? 0,
338
+ growPeriod: layer.grow?.period ?? 1e3,
339
+ growPhase: layer.grow ? rng.range(0, Math.PI * 2) : 0,
340
+ orbitR: m.type === "orbit" ? rng.range(m.radius[0], m.radius[1]) * scale : 0,
341
+ orbitCx: m.type === "orbit" ? (m.center?.x ?? 0.5) * w : 0,
342
+ orbitCy: m.type === "orbit" ? (m.center?.y ?? 0.5) * h : 0,
343
+ // New draws AFTER orbit — guarded so existing specs keep identical streams.
344
+ colorIndex: colorsLen > 0 ? rng.int(0, colorsLen - 1) : -1,
345
+ cyclePeriod: cycle ? cycle.period : 0
247
346
  });
248
347
  }
249
348
  return out;
@@ -253,6 +352,15 @@ function alphaAt(e, t) {
253
352
  const a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
254
353
  return a < 0 ? 0 : a > 1 ? 1 : a;
255
354
  }
355
+ function sizeAt(e, t) {
356
+ if (!e.growAmp) return e.size;
357
+ const s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
358
+ return s > 0 ? s : 0.1;
359
+ }
360
+ function rotationAt(e, t) {
361
+ if (!e.spinSpeed) return 0;
362
+ return e.spinPhase + e.spinSpeed * (t / 1e3);
363
+ }
256
364
  function wrap(v, min, max) {
257
365
  const range = max - min;
258
366
  return ((v - min) % range + range) % range + min;
@@ -266,6 +374,14 @@ function reflect(v, min, max) {
266
374
  }
267
375
  function positionAt(e, t, w, h) {
268
376
  if (e.motion === "static") return { x: e.x0, y: e.y0, flip: false };
377
+ if (e.motion === "orbit") {
378
+ const angle = e.phase + e.vx * Math.PI / 180 * (t / 1e3);
379
+ return {
380
+ x: e.orbitCx + e.orbitR * Math.cos(angle),
381
+ y: e.orbitCy + e.orbitR * Math.sin(angle),
382
+ flip: false
383
+ };
384
+ }
269
385
  const dt = t / 1e3;
270
386
  const m = e.size;
271
387
  if (e.motion === "bounce") {
@@ -287,11 +403,145 @@ function positionAt(e, t, w, h) {
287
403
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
288
404
  return { x, y, flip: e.headingLeft };
289
405
  }
406
+ function spriteIndexAt(e, t, variants) {
407
+ if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
408
+ return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
409
+ }
410
+ function linkPairs(positions, k, maxDist, doWrap, w, h) {
411
+ const n = positions.length;
412
+ const edges = /* @__PURE__ */ new Set();
413
+ const out = [];
414
+ for (let i = 0; i < n; i++) {
415
+ const pi = positions[i];
416
+ const neighbors = [];
417
+ for (let j = 0; j < n; j++) {
418
+ if (i === j) continue;
419
+ const pj = positions[j];
420
+ let dx = pj.x - pi.x;
421
+ let dy = pj.y - pi.y;
422
+ if (doWrap) {
423
+ if (Math.abs(dx) > w / 2) dx = dx > 0 ? dx - w : dx + w;
424
+ if (Math.abs(dy) > h / 2) dy = dy > 0 ? dy - h : dy + h;
425
+ }
426
+ const dist = Math.sqrt(dx * dx + dy * dy);
427
+ if (dist <= maxDist) neighbors.push({ dist, j });
428
+ }
429
+ neighbors.sort((a, b) => a.dist - b.dist || a.j - b.j);
430
+ for (let ni = 0; ni < Math.min(k, neighbors.length); ni++) {
431
+ const j = neighbors[ni].j;
432
+ const lo = Math.min(i, j);
433
+ const hi = Math.max(i, j);
434
+ const key = `${lo}:${hi}`;
435
+ if (!edges.has(key)) {
436
+ edges.add(key);
437
+ out.push([lo, hi]);
438
+ }
439
+ }
440
+ }
441
+ return out;
442
+ }
290
443
 
291
444
  // src/compile.ts
292
445
  import {
293
446
  createRng
294
447
  } from "@idle-screens/core";
448
+
449
+ // src/steer.ts
450
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
451
+ function resolveSpecPath(spec, path) {
452
+ if (!spec || typeof spec !== "object" || !path) return null;
453
+ const parts = path.split(".");
454
+ if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
455
+ const s = spec;
456
+ if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
457
+ const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
458
+ if (idx === -1) return null;
459
+ parts.splice(0, 1, "layers", String(idx));
460
+ }
461
+ let node = spec;
462
+ for (let i = 0; i < parts.length - 1; i++) {
463
+ if (node === null || typeof node !== "object") return null;
464
+ node = node[parts[i]];
465
+ }
466
+ if (node === null || typeof node !== "object") return null;
467
+ const last = parts[parts.length - 1];
468
+ const key = Array.isArray(node) ? Number(last) : last;
469
+ const exists = Array.isArray(node) ? Number.isInteger(key) && key >= 0 && key < node.length : last in node;
470
+ return exists ? { parent: node, key } : null;
471
+ }
472
+ function applyDeltasToSpec(spec, deltas) {
473
+ const copy = JSON.parse(JSON.stringify(spec));
474
+ for (const d of deltas) {
475
+ const loc = resolveSpecPath(copy, d.path);
476
+ if (loc) loc.parent[loc.key] = d.value;
477
+ }
478
+ return copy;
479
+ }
480
+ var HEX2 = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
481
+ function hexToRgb(hex) {
482
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
483
+ const n = parseInt(h.slice(1), 16);
484
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
485
+ }
486
+ function lerpHex(a, b, k) {
487
+ const ca = hexToRgb(a);
488
+ const cb = hexToRgb(b);
489
+ const mix = ca.map((v, i) => Math.round(v + (cb[i] - v) * k));
490
+ return `#${mix.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
491
+ }
492
+ function lerpSpec(from, to, k) {
493
+ const kk = Math.max(0, Math.min(1, k));
494
+ const walk = (a, b, key) => {
495
+ if (typeof a === "number" && typeof b === "number") {
496
+ const v = a + (b - a) * kk;
497
+ return key === "count" ? Math.max(1, Math.round(v)) : v;
498
+ }
499
+ if (typeof a === "string" && typeof b === "string" && HEX2.test(a) && HEX2.test(b)) {
500
+ return lerpHex(a, b, kk);
501
+ }
502
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
503
+ return b.map((bv, i) => walk(a[i], bv, i));
504
+ }
505
+ if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
506
+ const out = {};
507
+ for (const kName of Object.keys(b)) {
508
+ out[kName] = walk(a[kName], b[kName], kName);
509
+ }
510
+ return out;
511
+ }
512
+ return kk > 0 ? b : a;
513
+ };
514
+ return walk(from, to);
515
+ }
516
+ function easeSmooth(k) {
517
+ const c = Math.max(0, Math.min(1, k));
518
+ return c * c * (3 - 2 * c);
519
+ }
520
+ function structuralSignature(spec) {
521
+ return JSON.stringify([
522
+ spec.units,
523
+ spec.layers.map((l) => [
524
+ l.count,
525
+ l.size,
526
+ l.region,
527
+ l.position,
528
+ l.motion,
529
+ l.wrap,
530
+ l.flip,
531
+ l.alpha,
532
+ l.pulse,
533
+ l.spin,
534
+ l.grow,
535
+ l.sprite.kind,
536
+ l.sprite.kind === "circle" ? l.sprite.radius : void 0,
537
+ l.sprite.kind === "circle" ? l.sprite.colors?.length : void 0,
538
+ l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : void 0
539
+ ])
540
+ ]);
541
+ }
542
+
543
+ // src/compile.ts
544
+ var DEFAULT_STEER_DUR = 1e3;
295
545
  function hexToRgba(hex, alpha) {
296
546
  const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
297
547
  const n = parseInt(h.slice(1), 16);
@@ -310,12 +560,14 @@ function manifestFor(spec) {
310
560
  reducedMotionFallback: "static",
311
561
  // Flash-safe by construction: static background + bounded sprites, no strobe
312
562
  // primitive. Proven by sampling a compiled spec through @idle-screens/validator.
313
- a11y: { flashSafe: true }
563
+ a11y: { flashSafe: true },
564
+ workerReady: true
314
565
  };
315
566
  }
316
567
  var SpecInstance = class {
317
568
  constructor(spec, ctx) {
318
569
  this.spec = spec;
570
+ this.effSpec = spec;
319
571
  this.saverCtx = ctx;
320
572
  this.seed = (spec.seed ?? ctx.seed) >>> 0 || 1;
321
573
  let canvas;
@@ -354,6 +606,11 @@ var SpecInstance = class {
354
606
  baseT = 0;
355
607
  // elapsed logical time carried across pause/resume
356
608
  lastT = 0;
609
+ /** The spec currently being rendered (base spec + any applied steering). */
610
+ effSpec;
611
+ /** Active glide between two resolved specs (live setParam/applyTrack). */
612
+ transition = null;
613
+ lastStructural = "";
357
614
  sizeCanvas() {
358
615
  const dpr = Math.min(this.saverCtx.dpr, 2);
359
616
  this.canvas.width = Math.max(1, Math.round(this.w * dpr));
@@ -363,7 +620,9 @@ var SpecInstance = class {
363
620
  /** (Re)seed and place all entities — deterministic for the seed + size. */
364
621
  rebuild() {
365
622
  const rng = createRng(this.seed);
366
- this.layers = this.spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h) }));
623
+ const scale = this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1;
624
+ this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale) }));
625
+ this.lastStructural = structuralSignature(this.effSpec);
367
626
  }
368
627
  start() {
369
628
  if (this.frameId !== null || typeof requestAnimationFrame === "undefined") return;
@@ -385,7 +644,7 @@ var SpecInstance = class {
385
644
  }
386
645
  drawBackground() {
387
646
  const { ctx, w, h } = this;
388
- const bg = this.spec.background;
647
+ const bg = this.effSpec.background;
389
648
  if (!bg || bg.type === "solid") {
390
649
  ctx.fillStyle = bg?.color ?? "#05050a";
391
650
  ctx.fillRect(0, 0, w, h);
@@ -397,57 +656,145 @@ var SpecInstance = class {
397
656
  ctx.fillRect(0, 0, w, h);
398
657
  if (bg.band) {
399
658
  ctx.fillStyle = bg.band.color;
400
- ctx.fillRect(0, h - bg.band.height, w, bg.band.height);
659
+ const bh = bg.band.height * (this.effSpec.units === "viewport" ? Math.min(w, h) : 1);
660
+ ctx.fillRect(0, h - bh, w, bh);
401
661
  }
402
662
  }
403
663
  drawEntity(built, e, t) {
404
664
  const { ctx } = this;
405
665
  const p = positionAt(e, t, this.w, this.h);
406
666
  const sprite = built.layer.sprite;
667
+ const sz = sizeAt(e, t);
668
+ const rot = rotationAt(e, t);
407
669
  ctx.globalAlpha = alphaAt(e, t);
408
670
  if (sprite.kind === "circle") {
409
- const r = e.size / 2;
671
+ const r = sz / 2;
672
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
673
+ ctx.save();
674
+ if (rot) {
675
+ ctx.translate(p.x, p.y);
676
+ ctx.rotate(rot);
677
+ ctx.translate(-p.x, -p.y);
678
+ }
410
679
  if (sprite.soft) {
411
680
  const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
412
- g.addColorStop(0, sprite.color);
413
- g.addColorStop(0.35, hexToRgba(sprite.color, 0.75));
414
- g.addColorStop(1, hexToRgba(sprite.color, 0));
681
+ g.addColorStop(0, resolvedColor);
682
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
683
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
415
684
  ctx.fillStyle = g;
416
685
  } else {
417
- ctx.fillStyle = sprite.color;
686
+ ctx.fillStyle = resolvedColor;
418
687
  }
419
688
  ctx.beginPath();
420
689
  ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
421
690
  ctx.fill();
691
+ ctx.restore();
422
692
  return;
423
693
  }
424
694
  ctx.save();
425
695
  ctx.translate(p.x, p.y);
696
+ if (rot) ctx.rotate(rot);
426
697
  if (p.flip && built.layer.flip) ctx.scale(-1, 1);
427
698
  ctx.textAlign = "center";
428
699
  ctx.textBaseline = "middle";
429
700
  if (sprite.kind === "emoji") {
430
- ctx.font = `${e.size}px serif`;
431
- ctx.fillText(sprite.glyphs[e.spriteIndex] ?? sprite.glyphs[0], 0, 0);
701
+ const idx = spriteIndexAt(e, t, sprite.glyphs.length);
702
+ ctx.font = `${sz}px serif`;
703
+ ctx.fillText(sprite.glyphs[idx] ?? sprite.glyphs[0], 0, 0);
432
704
  } else {
433
705
  ctx.textAlign = sprite.align ?? "center";
434
706
  ctx.textBaseline = sprite.baseline ?? "middle";
435
- ctx.font = sprite.font ?? `${e.size}px system-ui, sans-serif`;
707
+ ctx.font = sprite.font ?? `${sz}px system-ui, sans-serif`;
436
708
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
437
- const text = sprite.strings[e.spriteIndex] ?? sprite.strings[0];
438
- if (sprite.maxWidth) ctx.fillText(text, 0, 0, sprite.maxWidth);
709
+ const idx = spriteIndexAt(e, t, sprite.strings.length);
710
+ const text = sprite.strings[idx] ?? sprite.strings[0];
711
+ const mw = sprite.maxWidth ? sprite.maxWidth * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1) : void 0;
712
+ if (mw) ctx.fillText(text, 0, 0, mw);
439
713
  else ctx.fillText(text, 0, 0);
440
714
  }
441
715
  ctx.restore();
442
716
  }
717
+ /** Advance any live steering glide; rebuild entities on structural change. */
718
+ stepTransition(t) {
719
+ const tr = this.transition;
720
+ if (!tr) return;
721
+ const k = tr.dur <= 0 ? 1 : (t - tr.startT) / tr.dur;
722
+ this.effSpec = k >= 1 ? tr.to : lerpSpec(tr.from, tr.to, easeSmooth(k));
723
+ if (k >= 1) this.transition = null;
724
+ const sig = structuralSignature(this.effSpec);
725
+ if (sig !== this.lastStructural) this.rebuild();
726
+ else this.layers.forEach((b, i) => {
727
+ b.layer = this.effSpec.layers[i] ?? b.layer;
728
+ });
729
+ }
730
+ /**
731
+ * Live steering: glide from what's currently rendered to the track's target
732
+ * state (deltas applied last-wins). Duration = the longest delta `dur`.
733
+ */
734
+ applyTrack(track) {
735
+ const deltas = track?.deltas ?? [];
736
+ const target = applyDeltasToSpec(this.effSpec, deltas);
737
+ if (!validateSpec(target).valid) return;
738
+ const dur = deltas.length ? deltas.reduce((m, d) => Math.max(m, d.dur ?? DEFAULT_STEER_DUR), 0) : DEFAULT_STEER_DUR;
739
+ this.transition = {
740
+ from: JSON.parse(JSON.stringify(this.effSpec)),
741
+ to: target,
742
+ startT: this.lastT,
743
+ dur
744
+ };
745
+ if (this.paused) {
746
+ this.transition = null;
747
+ this.effSpec = target;
748
+ if (structuralSignature(this.effSpec) !== this.lastStructural) this.rebuild();
749
+ else this.layers.forEach((b, i) => {
750
+ b.layer = this.effSpec.layers[i] ?? b.layer;
751
+ });
752
+ this.renderFrame(this.lastT, this.seed);
753
+ }
754
+ }
755
+ drawLinks(built, t) {
756
+ const { links } = built.layer;
757
+ if (!links) return;
758
+ const { ctx } = this;
759
+ const wrap2 = built.layer.wrap !== false;
760
+ const positions = built.entities.map((e) => positionAt(e, t, this.w, this.h));
761
+ const pairs = linkPairs(positions, links.k, links.maxDist * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1), wrap2, this.w, this.h);
762
+ const lw = (links.width ?? 1) * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1);
763
+ ctx.lineWidth = lw;
764
+ for (const [i, j] of pairs) {
765
+ const pi = positions[i];
766
+ const pj = positions[j];
767
+ const ei = built.entities[i];
768
+ let resolvedColor = links.color;
769
+ if (!resolvedColor) {
770
+ const sprite = built.layer.sprite;
771
+ if (sprite.kind === "circle") resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
772
+ else resolvedColor = "#e6e8ef";
773
+ }
774
+ ctx.globalAlpha = links.alpha ?? alphaAt(ei, t);
775
+ ctx.strokeStyle = resolvedColor;
776
+ let dx = pj.x - pi.x;
777
+ let dy = pj.y - pi.y;
778
+ if (wrap2) {
779
+ if (Math.abs(dx) > this.w / 2) dx = dx > 0 ? dx - this.w : dx + this.w;
780
+ if (Math.abs(dy) > this.h / 2) dy = dy > 0 ? dy - this.h : dy + this.h;
781
+ }
782
+ ctx.beginPath();
783
+ ctx.moveTo(pi.x, pi.y);
784
+ ctx.lineTo(pi.x + dx, pi.y + dy);
785
+ ctx.stroke();
786
+ }
787
+ }
443
788
  /** Deterministic, frame-addressable render (shared by the rAF loop). */
444
789
  renderFrame(t, _seed) {
790
+ this.stepTransition(t);
445
791
  const { ctx } = this;
446
792
  ctx.globalAlpha = 1;
447
793
  ctx.globalCompositeOperation = "source-over";
448
794
  this.drawBackground();
449
795
  for (const built of this.layers) {
450
796
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
797
+ this.drawLinks(built, t);
451
798
  for (const e of built.entities) this.drawEntity(built, e, t);
452
799
  }
453
800
  ctx.globalAlpha = 1;
@@ -483,7 +830,77 @@ function compileSaver(spec) {
483
830
  };
484
831
  }
485
832
 
486
- // src/examples.ts
833
+ // src/advise.ts
834
+ import { createRng as createRng2 } from "@idle-screens/core";
835
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
836
+ const warnings = [];
837
+ const w = viewport.width;
838
+ const h = viewport.height;
839
+ const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
840
+ const rng = createRng2(spec.seed ?? 42);
841
+ const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale));
842
+ let totalEntities = 0;
843
+ let textLayerCount = 0;
844
+ let motionLayerCount = 0;
845
+ for (let li = 0; li < spec.layers.length; li++) {
846
+ const layer = spec.layers[li];
847
+ const entities = allEntities[li];
848
+ totalEntities += entities.length;
849
+ const isText = layer.sprite.kind === "text";
850
+ const isEmoji = layer.sprite.kind === "emoji";
851
+ const isStaticText = isText && layer.motion.type === "static";
852
+ if (isStaticText) textLayerCount++;
853
+ if (layer.motion.type !== "static") motionLayerCount++;
854
+ if (layer.sprite.kind === "circle") {
855
+ const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
856
+ const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
857
+ if (maxR * maxAlpha < 0.05) {
858
+ warnings.push({
859
+ path: `layers[${li}]`,
860
+ code: "invisible-layer",
861
+ message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
862
+ });
863
+ }
864
+ }
865
+ }
866
+ let totalCoverage = 0;
867
+ for (let li = 0; li < spec.layers.length; li++) {
868
+ const layer = spec.layers[li];
869
+ const entities = allEntities[li];
870
+ for (const e of entities) {
871
+ const r = e.size / 2;
872
+ const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
873
+ totalCoverage += pixArea * e.alpha / (w * h);
874
+ }
875
+ }
876
+ if (totalCoverage < 5e-4 && spec.layers.length > 0) {
877
+ warnings.push({
878
+ path: "layers",
879
+ code: "sparse-scene",
880
+ message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
881
+ });
882
+ }
883
+ if (totalEntities > 500) {
884
+ warnings.push({
885
+ path: "layers",
886
+ code: "dense-scene",
887
+ message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
888
+ });
889
+ }
890
+ if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
891
+ const textRatio = textLayerCount / spec.layers.length;
892
+ if (textRatio >= 0.8) {
893
+ warnings.push({
894
+ path: "layers",
895
+ code: "text-heavy",
896
+ message: "most layers are static text \u2014 reads as a document, not a screensaver"
897
+ });
898
+ }
899
+ }
900
+ return warnings;
901
+ }
902
+
903
+ // src/examples/aquarium.ts
487
904
  var AQUARIUM_SPEC = {
488
905
  schemaVersion: 1,
489
906
  id: "aquarium",
@@ -514,185 +931,53 @@ var AQUARIUM_SPEC = {
514
931
  }
515
932
  ]
516
933
  };
517
- var RAIN_SPEC = {
518
- schemaVersion: 1,
519
- id: "rain",
520
- label: "Rain",
521
- seed: 7,
522
- motionIntensity: "moderate",
523
- background: { type: "solid", color: "#05070a" },
524
- layers: [
525
- {
526
- count: 140,
527
- sprite: { kind: "text", strings: ["\u2502"], color: "#9fd0ff" },
528
- size: [12, 26],
529
- motion: { type: "drift", angle: 90, speed: [520, 900] }
530
- }
531
- ]
532
- };
533
- var SNOWFALL_SPEC = {
534
- schemaVersion: 1,
535
- id: "snowfall",
536
- label: "Snowfall",
537
- seed: 12,
538
- motionIntensity: "calm",
539
- background: {
540
- type: "gradient",
541
- stops: [
542
- { at: 0, color: "#0a1628" },
543
- { at: 0.7, color: "#0d1f3c" },
544
- { at: 1, color: "#121212" }
545
- ]
546
- },
547
- layers: [
548
- {
549
- count: 50,
550
- sprite: { kind: "circle", radius: [1, 2.5], color: "#8899aa" },
551
- motion: { type: "drift", angle: 90, speed: [15, 40], bob: 3 }
552
- },
553
- {
554
- count: 35,
555
- sprite: { kind: "circle", radius: [2, 4.5], color: "#c0cdd8" },
556
- motion: { type: "drift", angle: 85, speed: [35, 80], bob: 6 }
557
- },
558
- {
559
- count: 15,
560
- sprite: { kind: "circle", radius: [3.5, 7], color: "#e8eff5" },
561
- motion: { type: "drift", angle: 82, speed: [55, 120], bob: 10 }
562
- }
563
- ]
564
- };
565
- var LANTERNS_SPEC = {
934
+
935
+ // src/examples/constellation.ts
936
+ var CONSTELLATION_SPEC = {
566
937
  schemaVersion: 1,
567
- id: "lanterns",
568
- label: "Night Lanterns",
569
- seed: 88,
938
+ id: "constellation",
939
+ label: "Constellation",
940
+ seed: 73,
570
941
  motionIntensity: "calm",
571
942
  background: {
572
943
  type: "gradient",
573
944
  stops: [
574
- { at: 0, color: "#04060f" },
575
- { at: 0.55, color: "#0b1026" },
576
- { at: 0.85, color: "#251731" },
577
- { at: 1, color: "#472518" }
945
+ { at: 0, color: "#020212" },
946
+ { at: 0.5, color: "#050520" },
947
+ { at: 1, color: "#0a0830" }
578
948
  ]
579
949
  },
580
950
  layers: [
951
+ // Distant static stars (no links, just atmosphere)
581
952
  {
582
- count: 60,
583
- sprite: { kind: "circle", radius: [0.5, 1.4], color: "#8fa0c8" },
584
- alpha: [0.35, 1],
585
- region: { y: [0, 0.62] },
586
- motion: { type: "drift", speed: [0.5, 2], bob: 1 }
953
+ count: 100,
954
+ sprite: { kind: "circle", radius: [1, 3], color: "#667799", colors: ["#8899cc", "#99aadd", "#aabbee", "#7788aa"] },
955
+ alpha: [0.2, 0.7],
956
+ pulse: { amp: 0.2, period: 3e3 },
957
+ motion: { type: "static" }
587
958
  },
959
+ // Connected constellation nodes — slow drift, linked
588
960
  {
589
- count: 36,
590
- sprite: {
591
- kind: "circle",
592
- radius: [1.5, 3],
593
- color: "#b06a2a",
594
- soft: true
595
- },
961
+ key: "nodes",
962
+ count: 40,
963
+ sprite: { kind: "circle", radius: [2, 5], color: "#88aaff", colors: ["#88aaff", "#aaccff", "#ffcc88", "#ff8866"], soft: true },
596
964
  alpha: [0.5, 0.9],
597
965
  blend: "lighter",
598
- pulse: { amp: 0.18, period: 2800 },
599
- motion: { type: "rise", speed: [6, 14], sway: 4 }
600
- },
601
- {
602
- count: 14,
603
- sprite: {
604
- kind: "circle",
605
- radius: [3.5, 6.5],
606
- color: "#e08a34",
607
- soft: true
608
- },
609
- alpha: [0.6, 1],
610
- blend: "lighter",
611
- pulse: { amp: 0.22, period: 3400 },
612
- motion: { type: "rise", speed: [16, 28], sway: 7 }
613
- },
614
- {
615
- count: 8,
616
- sprite: { kind: "emoji", glyphs: ["\u{1F3EE}"] },
617
- size: [26, 44],
618
- alpha: [0.85, 1],
619
- motion: { type: "rise", speed: [30, 48], sway: 10 }
966
+ motion: { type: "drift", speed: [2, 8], bidirectional: true, bob: 3 },
967
+ links: { k: 3, maxDist: 200, alpha: 0.15, width: 0.5 }
620
968
  },
969
+ // Cycling asterism markers
621
970
  {
622
- count: 4,
623
- sprite: { kind: "emoji", glyphs: ["\u{1F3EE}"] },
624
- size: [58, 84],
625
- motion: { type: "rise", speed: [52, 74], sway: 14 }
626
- },
627
- {
628
- count: 12,
629
- sprite: { kind: "circle", radius: [2, 5], color: "#e08a34", soft: true },
630
- alpha: [0.12, 0.35],
631
- blend: "lighter",
632
- region: { y: [0.93, 1] },
633
- pulse: { amp: 0.1, period: 2200 },
634
- motion: { type: "drift", speed: [2, 6], bob: 2 }
635
- }
636
- ]
637
- };
638
- var SAKURA_SPEC = {
639
- schemaVersion: 1,
640
- id: "sakura",
641
- label: "Sakura Drift",
642
- seed: 314,
643
- motionIntensity: "calm",
644
- background: {
645
- type: "gradient",
646
- stops: [
647
- { at: 0, color: "#1b1d38" },
648
- { at: 0.45, color: "#3a2645" },
649
- { at: 0.8, color: "#7d3d4e" },
650
- { at: 1, color: "#0f140d" }
651
- ],
652
- band: { color: "#0c140d", height: 44 }
653
- },
654
- layers: [
655
- {
656
- count: 60,
657
- sprite: { kind: "circle", radius: [0.6, 1.6], color: "#ffe3ec", soft: true },
658
- alpha: [0.3, 0.9],
659
- blend: "lighter",
660
- region: { y: [0, 0.72] },
661
- pulse: { amp: 0.22, period: 3e3 },
662
- motion: { type: "drift", speed: [0.3, 1.2], bob: 1 }
663
- },
664
- {
665
- count: 28,
666
- sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
667
- size: [13, 20],
668
- alpha: [0.5, 0.8],
669
- motion: { type: "drift", speed: [12, 26], angle: 100, bob: 3 }
670
- },
671
- {
672
- count: 18,
673
- sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
674
- size: [20, 32],
675
- alpha: [0.7, 0.95],
676
- motion: { type: "drift", speed: [24, 44], angle: 100, bob: 6 }
677
- },
678
- {
679
- count: 10,
680
- sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
681
- size: [34, 54],
682
- alpha: [0.85, 1],
683
- motion: { type: "drift", speed: [42, 68], angle: 100, bob: 11 }
684
- },
685
- {
686
- count: 16,
687
- sprite: { kind: "circle", radius: [1.5, 4], color: "#ff9bb5", soft: true },
688
- alpha: [0.1, 0.3],
689
- blend: "lighter",
690
- region: { y: [0.9, 1] },
691
- pulse: { amp: 0.15, period: 2600 },
692
- motion: { type: "drift", speed: [0.3, 1.5], angle: 180, bob: 1 }
971
+ count: 6,
972
+ sprite: { kind: "emoji", glyphs: ["\u2726", "\u2727", "\u22C6", "\u2217"], cycle: { period: 2e3 } },
973
+ size: [14, 22],
974
+ alpha: [0.4, 0.7],
975
+ motion: { type: "drift", speed: [1, 4], bidirectional: true }
693
976
  }
694
977
  ]
695
978
  };
979
+
980
+ // src/examples/dev-dashboard.ts
696
981
  var DASHBOARD_SPEC = {
697
982
  schemaVersion: 1,
698
983
  id: "dev-dashboard",
@@ -1154,7 +1439,7 @@ var DASHBOARD_SPEC = {
1154
1439
  position: { x: 0.06, y: 0.92 },
1155
1440
  sprite: {
1156
1441
  kind: "text",
1157
- strings: ["14:03 Edit examples.ts +84 -1"],
1442
+ strings: ["14:03 Edit examples/dev-dashboard.ts +84 -1"],
1158
1443
  color: "#445544",
1159
1444
  font: "10px monospace",
1160
1445
  align: "left",
@@ -1180,31 +1465,324 @@ var DASHBOARD_SPEC = {
1180
1465
  }
1181
1466
  ]
1182
1467
  };
1183
- var EXAMPLE_SPECS = [
1184
- AQUARIUM_SPEC,
1185
- RAIN_SPEC,
1186
- SNOWFALL_SPEC,
1187
- LANTERNS_SPEC,
1188
- SAKURA_SPEC,
1189
- DASHBOARD_SPEC
1468
+
1469
+ // src/examples/lanterns.ts
1470
+ var LANTERNS_SPEC = {
1471
+ schemaVersion: 1,
1472
+ id: "lanterns",
1473
+ label: "Night Lanterns",
1474
+ seed: 88,
1475
+ motionIntensity: "calm",
1476
+ background: {
1477
+ type: "gradient",
1478
+ stops: [
1479
+ { at: 0, color: "#04060f" },
1480
+ { at: 0.55, color: "#0b1026" },
1481
+ { at: 0.85, color: "#251731" },
1482
+ { at: 1, color: "#472518" }
1483
+ ]
1484
+ },
1485
+ layers: [
1486
+ {
1487
+ count: 60,
1488
+ sprite: { kind: "circle", radius: [0.5, 1.4], color: "#8fa0c8" },
1489
+ alpha: [0.35, 1],
1490
+ region: { y: [0, 0.62] },
1491
+ motion: { type: "drift", speed: [0.5, 2], bob: 1 }
1492
+ },
1493
+ {
1494
+ count: 36,
1495
+ sprite: {
1496
+ kind: "circle",
1497
+ radius: [1.5, 3],
1498
+ color: "#b06a2a",
1499
+ soft: true
1500
+ },
1501
+ alpha: [0.5, 0.9],
1502
+ blend: "lighter",
1503
+ pulse: { amp: 0.18, period: 2800 },
1504
+ motion: { type: "rise", speed: [6, 14], sway: 4 }
1505
+ },
1506
+ {
1507
+ count: 14,
1508
+ sprite: {
1509
+ kind: "circle",
1510
+ radius: [3.5, 6.5],
1511
+ color: "#e08a34",
1512
+ soft: true
1513
+ },
1514
+ alpha: [0.6, 1],
1515
+ blend: "lighter",
1516
+ pulse: { amp: 0.22, period: 3400 },
1517
+ motion: { type: "rise", speed: [16, 28], sway: 7 }
1518
+ },
1519
+ {
1520
+ count: 8,
1521
+ sprite: { kind: "emoji", glyphs: ["\u{1F3EE}"] },
1522
+ size: [26, 44],
1523
+ alpha: [0.85, 1],
1524
+ motion: { type: "rise", speed: [30, 48], sway: 10 }
1525
+ },
1526
+ {
1527
+ count: 4,
1528
+ sprite: { kind: "emoji", glyphs: ["\u{1F3EE}"] },
1529
+ size: [58, 84],
1530
+ motion: { type: "rise", speed: [52, 74], sway: 14 }
1531
+ },
1532
+ {
1533
+ count: 12,
1534
+ sprite: { kind: "circle", radius: [2, 5], color: "#e08a34", soft: true },
1535
+ alpha: [0.12, 0.35],
1536
+ blend: "lighter",
1537
+ region: { y: [0.93, 1] },
1538
+ pulse: { amp: 0.1, period: 2200 },
1539
+ motion: { type: "drift", speed: [2, 6], bob: 2 }
1540
+ }
1541
+ ]
1542
+ };
1543
+
1544
+ // src/examples/orrery.ts
1545
+ var ORRERY_SPEC = {
1546
+ schemaVersion: 1,
1547
+ id: "orrery",
1548
+ label: "Orrery",
1549
+ seed: 7,
1550
+ motionIntensity: "calm",
1551
+ background: {
1552
+ type: "gradient",
1553
+ stops: [
1554
+ { at: 0, color: "#020210" },
1555
+ { at: 0.5, color: "#06051a" },
1556
+ { at: 1, color: "#0a0820" }
1557
+ ]
1558
+ },
1559
+ layers: [
1560
+ // Distant stars
1561
+ {
1562
+ count: 80,
1563
+ sprite: { kind: "circle", radius: [0.3, 1.2], color: "#8899cc" },
1564
+ alpha: [0.2, 0.8],
1565
+ pulse: { amp: 0.3, period: 3e3 },
1566
+ motion: { type: "static" }
1567
+ },
1568
+ // Sun corona (soft glow)
1569
+ {
1570
+ key: "corona",
1571
+ count: 1,
1572
+ sprite: { kind: "circle", radius: [60, 60], color: "#ffaa22", soft: true },
1573
+ blend: "lighter",
1574
+ alpha: [0.6, 0.6],
1575
+ grow: { amp: 0.15, period: 4e3 },
1576
+ motion: { type: "static" },
1577
+ position: { x: 0.5, y: 0.5 }
1578
+ },
1579
+ // Sun body
1580
+ {
1581
+ key: "sun",
1582
+ count: 1,
1583
+ sprite: { kind: "emoji", glyphs: ["\u2600\uFE0F"] },
1584
+ size: [48, 48],
1585
+ grow: { amp: 0.08, period: 4e3 },
1586
+ motion: { type: "static" },
1587
+ position: { x: 0.5, y: 0.5 }
1588
+ },
1589
+ // Inner ring — small fast planets
1590
+ {
1591
+ key: "inner",
1592
+ count: 3,
1593
+ sprite: { kind: "emoji", glyphs: ["\u{1FAA8}", "\u{1F534}", "\u{1F7E0}"] },
1594
+ size: [16, 22],
1595
+ spin: 30,
1596
+ motion: { type: "orbit", speed: [28, 42], radius: [80, 120] }
1597
+ },
1598
+ // Middle ring — gas giants
1599
+ {
1600
+ key: "middle",
1601
+ count: 2,
1602
+ sprite: { kind: "emoji", glyphs: ["\u{1F7E4}", "\u{1FA90}"] },
1603
+ size: [28, 38],
1604
+ spin: -15,
1605
+ motion: { type: "orbit", speed: [12, 20], radius: [160, 200] }
1606
+ },
1607
+ // Outer ring — ice giants
1608
+ {
1609
+ key: "outer",
1610
+ count: 2,
1611
+ sprite: { kind: "emoji", glyphs: ["\u{1F535}", "\u{1F48E}"] },
1612
+ size: [22, 30],
1613
+ spin: 10,
1614
+ motion: { type: "orbit", speed: [5, 10], radius: [240, 300] }
1615
+ },
1616
+ // Orbital dust ring (soft glow particles orbiting slowly)
1617
+ {
1618
+ count: 24,
1619
+ sprite: { kind: "circle", radius: [1, 3], color: "#ccaa66", soft: true },
1620
+ alpha: [0.15, 0.4],
1621
+ blend: "lighter",
1622
+ pulse: { amp: 0.2, period: 2500 },
1623
+ motion: { type: "orbit", speed: [2, 8], radius: [100, 320] }
1624
+ }
1625
+ ]
1626
+ };
1627
+
1628
+ // src/examples/rain.ts
1629
+ var RAIN_SPEC = {
1630
+ schemaVersion: 1,
1631
+ id: "rain",
1632
+ label: "Rain",
1633
+ seed: 7,
1634
+ motionIntensity: "moderate",
1635
+ background: { type: "solid", color: "#05070a" },
1636
+ layers: [
1637
+ {
1638
+ count: 140,
1639
+ sprite: { kind: "text", strings: ["\u2502"], color: "#9fd0ff" },
1640
+ size: [12, 26],
1641
+ motion: { type: "drift", angle: 90, speed: [520, 900] }
1642
+ }
1643
+ ]
1644
+ };
1645
+
1646
+ // src/examples/sakura.ts
1647
+ var SAKURA_SPEC = {
1648
+ schemaVersion: 1,
1649
+ id: "sakura",
1650
+ label: "Sakura Drift",
1651
+ seed: 314,
1652
+ motionIntensity: "calm",
1653
+ background: {
1654
+ type: "gradient",
1655
+ stops: [
1656
+ { at: 0, color: "#1b1d38" },
1657
+ { at: 0.45, color: "#3a2645" },
1658
+ { at: 0.8, color: "#7d3d4e" },
1659
+ { at: 1, color: "#0f140d" }
1660
+ ],
1661
+ band: { color: "#0c140d", height: 44 }
1662
+ },
1663
+ layers: [
1664
+ {
1665
+ count: 60,
1666
+ sprite: { kind: "circle", radius: [0.6, 1.6], color: "#ffe3ec", soft: true },
1667
+ alpha: [0.3, 0.9],
1668
+ blend: "lighter",
1669
+ region: { y: [0, 0.72] },
1670
+ pulse: { amp: 0.22, period: 3e3 },
1671
+ motion: { type: "drift", speed: [0.3, 1.2], bob: 1 }
1672
+ },
1673
+ {
1674
+ count: 28,
1675
+ sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
1676
+ size: [13, 20],
1677
+ alpha: [0.5, 0.8],
1678
+ motion: { type: "drift", speed: [12, 26], angle: 100, bob: 3 }
1679
+ },
1680
+ {
1681
+ count: 18,
1682
+ sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
1683
+ size: [20, 32],
1684
+ alpha: [0.7, 0.95],
1685
+ motion: { type: "drift", speed: [24, 44], angle: 100, bob: 6 }
1686
+ },
1687
+ {
1688
+ count: 10,
1689
+ sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
1690
+ size: [34, 54],
1691
+ alpha: [0.85, 1],
1692
+ motion: { type: "drift", speed: [42, 68], angle: 100, bob: 11 }
1693
+ },
1694
+ {
1695
+ count: 16,
1696
+ sprite: { kind: "circle", radius: [1.5, 4], color: "#ff9bb5", soft: true },
1697
+ alpha: [0.1, 0.3],
1698
+ blend: "lighter",
1699
+ region: { y: [0.9, 1] },
1700
+ pulse: { amp: 0.15, period: 2600 },
1701
+ motion: { type: "drift", speed: [0.3, 1.5], angle: 180, bob: 1 }
1702
+ }
1703
+ ]
1704
+ };
1705
+
1706
+ // src/examples/snowfall.ts
1707
+ var SNOWFALL_SPEC = {
1708
+ schemaVersion: 1,
1709
+ id: "snowfall",
1710
+ label: "Snowfall",
1711
+ seed: 12,
1712
+ motionIntensity: "calm",
1713
+ background: {
1714
+ type: "gradient",
1715
+ stops: [
1716
+ { at: 0, color: "#0a1628" },
1717
+ { at: 0.7, color: "#0d1f3c" },
1718
+ { at: 1, color: "#121212" }
1719
+ ]
1720
+ },
1721
+ layers: [
1722
+ {
1723
+ count: 50,
1724
+ sprite: { kind: "circle", radius: [1, 2.5], color: "#8899aa" },
1725
+ motion: { type: "drift", angle: 90, speed: [15, 40], bob: 3 }
1726
+ },
1727
+ {
1728
+ count: 35,
1729
+ sprite: { kind: "circle", radius: [2, 4.5], color: "#c0cdd8" },
1730
+ motion: { type: "drift", angle: 85, speed: [35, 80], bob: 6 }
1731
+ },
1732
+ {
1733
+ count: 15,
1734
+ sprite: { kind: "circle", radius: [3.5, 7], color: "#e8eff5" },
1735
+ motion: { type: "drift", angle: 82, speed: [55, 120], bob: 10 }
1736
+ }
1737
+ ]
1738
+ };
1739
+
1740
+ // src/examples/index.ts
1741
+ var SCHEMA_EXAMPLES = [
1742
+ { id: "aquarium", label: "Aquarium", spec: AQUARIUM_SPEC },
1743
+ { id: "rain", label: "Rain", spec: RAIN_SPEC },
1744
+ { id: "snowfall", label: "Snowfall", spec: SNOWFALL_SPEC },
1745
+ { id: "lanterns", label: "Night Lanterns", spec: LANTERNS_SPEC },
1746
+ { id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
1747
+ { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
1748
+ { id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
1749
+ { id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC }
1190
1750
  ];
1751
+ var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
1752
+ var EXAMPLE_BY_ID = Object.fromEntries(
1753
+ SCHEMA_EXAMPLES.map((e) => [e.id, e.spec])
1754
+ );
1191
1755
  export {
1192
1756
  AQUARIUM_SPEC,
1757
+ CONSTELLATION_SPEC,
1193
1758
  DASHBOARD_SPEC,
1759
+ EXAMPLE_BY_ID,
1194
1760
  EXAMPLE_SPECS,
1195
1761
  LANTERNS_SPEC,
1196
1762
  LIMITS,
1763
+ ORRERY_SPEC,
1197
1764
  RAIN_SPEC,
1198
1765
  SAKURA_SPEC,
1766
+ SCHEMA_EXAMPLES,
1199
1767
  SCHEMA_VERSION,
1200
1768
  SNOWFALL_SPEC,
1769
+ adviseSpec,
1201
1770
  alphaAt,
1771
+ applyDeltasToSpec,
1202
1772
  assertValidSpec,
1203
1773
  buildEntities,
1204
1774
  compileSaver,
1775
+ easeSmooth,
1776
+ lerpSpec,
1777
+ linkPairs,
1205
1778
  manifestFor,
1206
1779
  positionAt,
1780
+ resolveSpecPath,
1781
+ rotationAt,
1782
+ sizeAt,
1783
+ spriteIndexAt,
1207
1784
  spriteVariants,
1785
+ structuralSignature,
1208
1786
  validateSpec
1209
1787
  };
1210
1788
  //# sourceMappingURL=index.js.map