@idle-screens/schema 2.2.0 → 2.3.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
@@ -28,8 +28,21 @@ var LIMITS = {
28
28
  // dots per trail
29
29
  minDriftPeriod: 1e4,
30
30
  // ms — background drift floor (10 s)
31
- maxDriftAmount: 0.3
31
+ maxDriftAmount: 0.3,
32
32
  // fraction of gradient stop shift
33
+ maxGhosting: 0.95,
34
+ // frame persistence cap — bounds the seek warm-up replay
35
+ maxGhostReplayFrames: 120,
36
+ // fixed-step frames replayed on a non-contiguous seek
37
+ maxMeander: 500,
38
+ // px — wander harmonic amplitude cap (viewport cap: /referenceViewport)
39
+ maxWarpSpeed: 1.5,
40
+ // depth-units/sec — full near-to-far span in ~0.7 s at max
41
+ minPathPoints: 2,
42
+ maxPathPoints: 24,
43
+ minPathDuration: 2e3,
44
+ // ms — a path lap can't be faster than this
45
+ maxGridColumns: 100
33
46
  };
34
47
 
35
48
  // src/validate.ts
@@ -38,7 +51,7 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
38
51
  var isStr = (v) => typeof v === "string";
39
52
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
40
53
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
41
- var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "background", "layers"]);
54
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "referenceViewport", "background", "layers", "ghosting"]);
42
55
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
43
56
  "count",
44
57
  "sprite",
@@ -55,9 +68,14 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
55
68
  "key",
56
69
  "position",
57
70
  "trail",
58
- "links"
71
+ "links",
72
+ "layout",
73
+ "life"
59
74
  ]);
60
- var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors"]);
75
+ var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
76
+ var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
77
+ var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
78
+ var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
61
79
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
62
80
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
63
81
  var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
@@ -65,6 +83,9 @@ var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
65
83
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
66
84
  var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
67
85
  var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
86
+ var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence"]);
87
+ var KNOWN_WARP = /* @__PURE__ */ new Set(["type", "speed", "center"]);
88
+ var KNOWN_PATH = /* @__PURE__ */ new Set(["type", "points", "duration", "curve", "closed", "scatter"]);
68
89
  var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
69
90
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
70
91
  var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key"]);
@@ -87,6 +108,14 @@ function validateSpec(spec) {
87
108
  if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
88
109
  err("units", "must be 'px' | 'viewport'");
89
110
  }
111
+ if (spec.referenceViewport !== void 0) {
112
+ if (!isNum(spec.referenceViewport) || spec.referenceViewport < 100 || spec.referenceViewport > 8640) {
113
+ err("referenceViewport", "must be a number between 100 and 8640");
114
+ }
115
+ }
116
+ if (spec.ghosting !== void 0 && (!isNum(spec.ghosting) || spec.ghosting < 0 || spec.ghosting > LIMITS.maxGhosting)) {
117
+ err("ghosting", `must be a number 0..${LIMITS.maxGhosting}`);
118
+ }
90
119
  for (const k of unknownKeys(spec, KNOWN_TOP)) {
91
120
  warn(k, "unknown-property", `unknown top-level property '${k}' \u2014 will be ignored`);
92
121
  }
@@ -154,7 +183,7 @@ function validateLayer(layer, path, err, warn, spec) {
154
183
  if (k === "id") {
155
184
  warn(`${path}.id`, "misplaced-property", `'id' is not a layer property \u2014 did you mean 'key'? Use 'key' to name a layer`);
156
185
  } else if (k === "depth") {
157
- warn(`${path}.depth`, "unknown-property", `'depth' is not yet supported \u2014 fake parallax with size/speed/alpha across layers instead`);
186
+ warn(`${path}.depth`, "unknown-property", `'depth' is not a layer property \u2014 use motion type 'warp' for true perspective depth, or fake parallax with size/speed/alpha across layers`);
158
187
  } else {
159
188
  warn(`${path}.${k}`, "unknown-property", `unknown layer property '${k}' \u2014 will be ignored`);
160
189
  }
@@ -172,7 +201,9 @@ function validateLayer(layer, path, err, warn, spec) {
172
201
  if (layer.alpha !== void 0 && (!isRange(layer.alpha) || layer.alpha[0] < 0 || layer.alpha[1] > 1)) {
173
202
  err(`${path}.alpha`, "must be a [min,max] range within 0..1");
174
203
  }
175
- if (layer.blend !== void 0 && layer.blend !== "lighter") err(`${path}.blend`, "must be 'lighter' when set");
204
+ if (layer.blend !== void 0 && !["lighter", "screen", "multiply"].includes(layer.blend)) {
205
+ err(`${path}.blend`, "must be 'lighter' | 'screen' | 'multiply' when set");
206
+ }
176
207
  if (layer.key !== void 0 && (!isStr(layer.key) || layer.key.trim() === "")) {
177
208
  err(`${path}.key`, "must be a non-empty string");
178
209
  }
@@ -205,6 +236,17 @@ function validateLayer(layer, path, err, warn, spec) {
205
236
  if (!isNum(layer.pulse.period) || layer.pulse.period < LIMITS.minPulsePeriod) {
206
237
  err(`${path}.pulse.period`, `must be >= ${LIMITS.minPulsePeriod} ms (flash-safety floor)`);
207
238
  }
239
+ if (layer.pulse.wave !== void 0) {
240
+ if (!isObj(layer.pulse.wave)) err(`${path}.pulse.wave`, "must be an object");
241
+ else {
242
+ if (!isNum(layer.pulse.wave.wavelength) || layer.pulse.wave.wavelength <= 0) {
243
+ err(`${path}.pulse.wave.wavelength`, "must be > 0 (px or viewport-units)");
244
+ }
245
+ if (layer.pulse.wave.angle !== void 0 && !isNum(layer.pulse.wave.angle)) {
246
+ err(`${path}.pulse.wave.angle`, "must be a number (degrees)");
247
+ }
248
+ }
249
+ }
208
250
  }
209
251
  }
210
252
  if (layer.spin !== void 0) {
@@ -238,11 +280,61 @@ function validateLayer(layer, path, err, warn, spec) {
238
280
  if (layer.links.width !== void 0 && (!isNum(layer.links.width) || layer.links.width <= 0)) {
239
281
  err(`${path}.links.width`, "must be > 0");
240
282
  }
283
+ if (layer.links.mode !== void 0 && !["nearest", "chain", "random"].includes(layer.links.mode)) {
284
+ err(`${path}.links.mode`, "must be 'nearest' | 'chain' | 'random'");
285
+ }
286
+ if (layer.links.falloff !== void 0 && typeof layer.links.falloff !== "boolean") {
287
+ err(`${path}.links.falloff`, "must be a boolean");
288
+ }
289
+ if (layer.links.closed !== void 0 && typeof layer.links.closed !== "boolean") {
290
+ err(`${path}.links.closed`, "must be a boolean");
291
+ }
241
292
  if (isNum(layer.count) && layer.count > LIMITS.maxLinkLayerCount) {
242
293
  err(`${path}.links`, `layer count must be <= ${LIMITS.maxLinkLayerCount} when links is set`);
243
294
  }
244
295
  }
245
296
  }
297
+ if (layer.layout !== void 0) {
298
+ if (!isObj(layer.layout) || layer.layout.type !== "grid") {
299
+ err(`${path}.layout`, "must be an object with type: 'grid'");
300
+ } else {
301
+ if (layer.layout.columns !== void 0 && (!isNum(layer.layout.columns) || !Number.isInteger(layer.layout.columns) || layer.layout.columns < 1 || layer.layout.columns > LIMITS.maxGridColumns)) {
302
+ err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
303
+ }
304
+ const jit = layer.layout.jitter;
305
+ if (jit !== void 0) {
306
+ const jitterOk = (v) => isNum(v) && v >= 0 && v <= 1;
307
+ if (isNum(jit)) {
308
+ if (!jitterOk(jit)) err(`${path}.layout.jitter`, "must be 0..1");
309
+ } else if (isObj(jit)) {
310
+ if (jit.x !== void 0 && !jitterOk(jit.x)) err(`${path}.layout.jitter.x`, "must be 0..1");
311
+ if (jit.y !== void 0 && !jitterOk(jit.y)) err(`${path}.layout.jitter.y`, "must be 0..1");
312
+ } else {
313
+ err(`${path}.layout.jitter`, "must be a number 0..1 or { x?, y? }");
314
+ }
315
+ }
316
+ if (layer.position !== void 0) {
317
+ warn(`${path}.layout`, "layout-with-position", "position overrides layout for the single entity \u2014 layout has no effect");
318
+ }
319
+ }
320
+ }
321
+ if (layer.life !== void 0) {
322
+ if (!isObj(layer.life)) err(`${path}.life`, "must be an object");
323
+ else {
324
+ if (layer.life.enter !== void 0 && (!isNum(layer.life.enter) || layer.life.enter < 0)) {
325
+ err(`${path}.life.enter`, "must be >= 0 (ms)");
326
+ }
327
+ if (layer.life.exit !== void 0 && (!isNum(layer.life.exit) || layer.life.exit < 0)) {
328
+ err(`${path}.life.exit`, "must be >= 0 (ms)");
329
+ }
330
+ if (isNum(layer.life.enter) && isNum(layer.life.exit) && layer.life.exit <= layer.life.enter) {
331
+ err(`${path}.life.exit`, "must be greater than life.enter");
332
+ }
333
+ if (layer.life.fade !== void 0 && (!isNum(layer.life.fade) || layer.life.fade < 0)) {
334
+ err(`${path}.life.fade`, "must be >= 0 (ms)");
335
+ }
336
+ }
337
+ }
246
338
  if (layer.trail !== void 0) {
247
339
  if (!isObj(layer.trail)) err(`${path}.trail`, "must be an object");
248
340
  else {
@@ -287,15 +379,29 @@ function validateSprite(sprite, path, err, warn) {
287
379
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
288
380
  color(sprite.color, `${path}.color`, err);
289
381
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
290
- if (sprite.colors !== void 0) {
291
- if (!Array.isArray(sprite.colors) || sprite.colors.length === 0) {
292
- err(`${path}.colors`, "must be a non-empty array of hex colours");
293
- } else {
294
- sprite.colors.forEach((c, ci) => color(c, `${path}.colors[${ci}]`, err));
295
- }
382
+ validatePalette(sprite, path, err);
383
+ } else if (sprite.kind === "ring") {
384
+ knownSet = KNOWN_RING;
385
+ if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
386
+ color(sprite.color, `${path}.color`, err);
387
+ if (sprite.width !== void 0 && (!isNum(sprite.width) || sprite.width <= 0)) err(`${path}.width`, "must be > 0");
388
+ validatePalette(sprite, path, err);
389
+ } else if (sprite.kind === "streak") {
390
+ knownSet = KNOWN_STREAK;
391
+ if (!isRange(sprite.length) || sprite.length[0] <= 0) err(`${path}.length`, "must be a [min,max] range of positive px");
392
+ color(sprite.color, `${path}.color`, err);
393
+ if (sprite.width !== void 0 && (!isNum(sprite.width) || sprite.width <= 0)) err(`${path}.width`, "must be > 0");
394
+ validatePalette(sprite, path, err);
395
+ } else if (sprite.kind === "rect") {
396
+ knownSet = KNOWN_RECT;
397
+ if (!isRange(sprite.width) || sprite.width[0] <= 0) err(`${path}.width`, "must be a [min,max] range of positive px");
398
+ if (sprite.aspect !== void 0 && (!isRange(sprite.aspect) || sprite.aspect[0] <= 0)) {
399
+ err(`${path}.aspect`, "must be a [min,max] range of positive height/width ratios");
296
400
  }
401
+ color(sprite.color, `${path}.color`, err);
402
+ validatePalette(sprite, path, err);
297
403
  } else {
298
- err(`${path}.kind`, "must be emoji | text | circle");
404
+ err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect");
299
405
  return;
300
406
  }
301
407
  for (const k of unknownKeys(sprite, knownSet)) {
@@ -306,6 +412,24 @@ function validateSprite(sprite, path, err, warn) {
306
412
  }
307
413
  }
308
414
  }
415
+ function validatePalette(sprite, path, err) {
416
+ if (sprite.colors !== void 0) {
417
+ if (!Array.isArray(sprite.colors) || sprite.colors.length === 0) {
418
+ err(`${path}.colors`, "must be a non-empty array of hex colours");
419
+ } else {
420
+ sprite.colors.forEach((c, ci) => color(c, `${path}.colors[${ci}]`, err));
421
+ }
422
+ }
423
+ if (sprite.colorWeights !== void 0) {
424
+ if (!Array.isArray(sprite.colors)) {
425
+ err(`${path}.colorWeights`, "requires colors to be set");
426
+ } else if (!Array.isArray(sprite.colorWeights) || sprite.colorWeights.length !== sprite.colors.length) {
427
+ err(`${path}.colorWeights`, "must be an array the same length as colors");
428
+ } else if (!sprite.colorWeights.every((v) => isNum(v) && v >= 0) || !sprite.colorWeights.some((v) => isNum(v) && v > 0)) {
429
+ err(`${path}.colorWeights`, "weights must be >= 0 with at least one > 0");
430
+ }
431
+ }
432
+ }
309
433
  function validateCycle(sprite, path, err) {
310
434
  if (sprite.cycle === void 0) return;
311
435
  if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
@@ -315,10 +439,12 @@ function validateCycle(sprite, path, err) {
315
439
  }
316
440
  function validateMotion(motion, path, err, warn, spec) {
317
441
  if (!isObj(motion)) return err(path, "must be an object");
318
- const isViewport = isObj(spec) && spec.units === "viewport";
319
- const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
442
+ const isViewport = !isObj(spec) || spec.units !== "px";
443
+ const refVp = isObj(spec) && isNum(spec.referenceViewport) ? spec.referenceViewport : LIMITS.referenceViewport;
444
+ const speedCap = isViewport ? LIMITS.maxSpeed / refVp : LIMITS.maxSpeed;
320
445
  const speedOk = (v, p) => {
321
446
  if (!isRange(v)) err(p, "must be a [min,max] range");
447
+ else if (v[0] < 0) err(p, "speed lower bound must be >= 0");
322
448
  else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
323
449
  };
324
450
  let knownSet;
@@ -356,6 +482,47 @@ function validateMotion(motion, path, err, warn, spec) {
356
482
  }
357
483
  }
358
484
  if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
485
+ if (motion.center !== void 0) {
486
+ if (!isObj(motion.center)) {
487
+ err(`${path}.center`, "must be {x, y} (0..1) or { layer: key }");
488
+ } else if ("layer" in motion.center) {
489
+ if (!isStr(motion.center.layer) || motion.center.layer.trim() === "") {
490
+ err(`${path}.center.layer`, "must be a non-empty layer key");
491
+ } else if (isObj(spec) && Array.isArray(spec.layers)) {
492
+ const layers = spec.layers;
493
+ const parent = layers.find((l) => isObj(l) && l.key === motion.center.layer);
494
+ if (!parent) {
495
+ err(`${path}.center.layer`, `no layer with key '${motion.center.layer}'`);
496
+ } else {
497
+ if (parent.count !== 1) err(`${path}.center.layer`, "parent layer must have count: 1");
498
+ const pm = parent.motion;
499
+ if (isObj(pm) && pm.type === "orbit" && isObj(pm.center) && "layer" in pm.center) {
500
+ err(`${path}.center.layer`, "parent may not itself orbit a layer (one level deep only)");
501
+ }
502
+ }
503
+ }
504
+ } else if (!isNum(motion.center.x) || !isNum(motion.center.y)) {
505
+ err(`${path}.center`, "must be {x, y} with numbers 0..1");
506
+ } else {
507
+ if (motion.center.x < 0 || motion.center.x > 1) err(`${path}.center.x`, "must be 0..1");
508
+ if (motion.center.y < 0 || motion.center.y > 1) err(`${path}.center.y`, "must be 0..1");
509
+ }
510
+ }
511
+ } else if (motion.type === "wander") {
512
+ knownSet = KNOWN_WANDER;
513
+ speedOk(motion.speed, `${path}.speed`);
514
+ if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
515
+ const meanderCap = isViewport ? LIMITS.maxMeander / refVp : LIMITS.maxMeander;
516
+ if (motion.meander !== void 0 && (!isNum(motion.meander) || motion.meander <= 0 || motion.meander > meanderCap)) {
517
+ err(`${path}.meander`, `must be > 0 and <= ${isViewport ? meanderCap.toFixed(3) + " viewport-units" : LIMITS.maxMeander + " px"}`);
518
+ }
519
+ if (motion.coherence !== void 0 && (!isNum(motion.coherence) || motion.coherence < 0 || motion.coherence > 1)) {
520
+ err(`${path}.coherence`, "must be 0..1");
521
+ }
522
+ } else if (motion.type === "warp") {
523
+ knownSet = KNOWN_WARP;
524
+ if (!isRange(motion.speed) || motion.speed[0] < 0) err(`${path}.speed`, "must be a [min,max] range >= 0 (depth-units/sec)");
525
+ else if (motion.speed[1] > LIMITS.maxWarpSpeed) err(`${path}.speed`, `warp speed exceeds cap ${LIMITS.maxWarpSpeed} depth-units/sec`);
359
526
  if (motion.center !== void 0) {
360
527
  if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
361
528
  err(`${path}.center`, "must be {x, y} with numbers 0..1");
@@ -364,8 +531,30 @@ function validateMotion(motion, path, err, warn, spec) {
364
531
  if (motion.center.y < 0 || motion.center.y > 1) err(`${path}.center.y`, "must be 0..1");
365
532
  }
366
533
  }
534
+ } else if (motion.type === "path") {
535
+ knownSet = KNOWN_PATH;
536
+ if (!Array.isArray(motion.points) || motion.points.length < LIMITS.minPathPoints || motion.points.length > LIMITS.maxPathPoints) {
537
+ err(`${path}.points`, `must be an array of ${LIMITS.minPathPoints}..${LIMITS.maxPathPoints} points`);
538
+ } else {
539
+ motion.points.forEach((p, pi) => {
540
+ if (!isObj(p) || !isNum(p.x) || !isNum(p.y) || p.x < 0 || p.x > 1 || p.y < 0 || p.y > 1) {
541
+ err(`${path}.points[${pi}]`, "must be {x, y} with numbers 0..1");
542
+ }
543
+ });
544
+ }
545
+ if (!isNum(motion.duration) || motion.duration < LIMITS.minPathDuration) {
546
+ err(`${path}.duration`, `must be >= ${LIMITS.minPathDuration} ms`);
547
+ }
548
+ if (motion.curve !== void 0 && motion.curve !== "linear" && motion.curve !== "smooth") {
549
+ err(`${path}.curve`, "must be 'linear' | 'smooth'");
550
+ }
551
+ if (motion.closed !== void 0 && typeof motion.closed !== "boolean") err(`${path}.closed`, "must be a boolean");
552
+ const scatterCap = isViewport ? LIMITS.maxMeander / refVp : LIMITS.maxMeander;
553
+ if (motion.scatter !== void 0 && (!isNum(motion.scatter) || motion.scatter <= 0 || motion.scatter > scatterCap)) {
554
+ err(`${path}.scatter`, `must be > 0 and <= ${isViewport ? scatterCap.toFixed(3) + " viewport-units" : LIMITS.maxMeander + " px"}`);
555
+ }
367
556
  } else {
368
- err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
557
+ err(`${path}.type`, "must be drift | rise | bounce | static | orbit | wander | warp | path");
369
558
  return;
370
559
  }
371
560
  for (const k of unknownKeys(motion, knownSet)) {
@@ -382,11 +571,42 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
382
571
  }
383
572
 
384
573
  // src/simulate.ts
574
+ var WARP_NEAR = 0.08;
575
+ var WARP_MAX_SCALE = 8;
385
576
  function spriteVariants(sprite) {
386
577
  if (sprite.kind === "emoji") return sprite.glyphs.length;
387
578
  if (sprite.kind === "text") return sprite.strings.length;
388
579
  return 1;
389
580
  }
581
+ var OCT_AMP = [1, 0.5, 0.25];
582
+ var OCT_PERIOD = [
583
+ [6e3, 14e3],
584
+ [3e3, 7e3],
585
+ [1500, 4e3]
586
+ ];
587
+ function drawOsc(rng, amp) {
588
+ const osc = { ax: [], fx: [], phx: [], ay: [], fy: [], phy: [] };
589
+ for (let i = 0; i < 3; i++) {
590
+ osc.ax.push(amp * OCT_AMP[i] * rng.range(0.6, 1.4));
591
+ osc.fx.push(2 * Math.PI / rng.range(OCT_PERIOD[i][0], OCT_PERIOD[i][1]));
592
+ osc.phx.push(rng.range(0, Math.PI * 2));
593
+ }
594
+ for (let i = 0; i < 3; i++) {
595
+ osc.ay.push(amp * OCT_AMP[i] * rng.range(0.6, 1.4));
596
+ osc.fy.push(2 * Math.PI / rng.range(OCT_PERIOD[i][0], OCT_PERIOD[i][1]));
597
+ osc.phy.push(rng.range(0, Math.PI * 2));
598
+ }
599
+ return osc;
600
+ }
601
+ function weightedIndex(u, weights) {
602
+ const total = weights.reduce((a, b) => a + b, 0);
603
+ let acc = 0;
604
+ for (let i = 0; i < weights.length; i++) {
605
+ acc += weights[i] / total;
606
+ if (u < acc) return i;
607
+ }
608
+ return weights.length - 1;
609
+ }
390
610
  function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
391
611
  const effectiveCount = countScale === 1 ? layer.count : Math.max(1, Math.min(
392
612
  Math.round(layer.count * countScale),
@@ -394,15 +614,55 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
394
614
  ));
395
615
  const [smin, smax] = layer.size ?? [20, 40];
396
616
  const variants = spriteVariants(layer.sprite);
397
- const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
398
- const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
617
+ const sprite = layer.sprite;
618
+ const spriteColors = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors : void 0;
619
+ const colorWeights = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colorWeights : void 0;
620
+ const colorsLen = spriteColors?.length ?? 0;
621
+ const cycle = sprite.kind === "emoji" || sprite.kind === "text" ? sprite.cycle : void 0;
622
+ const mSpec = layer.motion;
623
+ let sharedOsc = null;
624
+ let meanderPx = 0;
625
+ if (mSpec.type === "wander") {
626
+ meanderPx = (mSpec.meander ?? (scale === 1 ? 60 : 0.05)) * scale;
627
+ sharedOsc = drawOsc(rng, meanderPx);
628
+ }
629
+ let pathPts = null;
630
+ if (mSpec.type === "path") {
631
+ pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
632
+ }
633
+ const layout = layer.layout;
634
+ let gridCols = 0;
635
+ let cellW = 0;
636
+ let cellH = 0;
637
+ let gridX0 = 0;
638
+ let gridY0 = 0;
639
+ if (layout?.type === "grid") {
640
+ const [rx0, rx1] = layer.region?.x ?? [0, 1];
641
+ const [ry0, ry1] = layer.region?.y ?? [0, 1];
642
+ const gw = Math.max(1, (rx1 - rx0) * w);
643
+ const gh = Math.max(1, (ry1 - ry0) * h);
644
+ gridCols = Math.min(
645
+ layout.columns ?? Math.max(1, Math.round(Math.sqrt(effectiveCount * (gw / gh)))),
646
+ LIMITS.maxGridColumns
647
+ );
648
+ const gridRows = Math.max(1, Math.ceil(effectiveCount / gridCols));
649
+ cellW = gw / gridCols;
650
+ cellH = gh / gridRows;
651
+ gridX0 = rx0 * w;
652
+ gridY0 = ry0 * h;
653
+ }
399
654
  const out = [];
400
655
  for (let i = 0; i < effectiveCount; i++) {
401
- const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
656
+ const size = sprite.kind === "circle" || sprite.kind === "ring" ? rng.range(sprite.radius[0], sprite.radius[1]) * 2 * scale : sprite.kind === "streak" ? rng.range(sprite.length[0], sprite.length[1]) * scale : sprite.kind === "rect" ? rng.range(sprite.width[0], sprite.width[1]) * scale : rng.range(smin, smax) * scale;
657
+ const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : void 0;
402
658
  let vx = 0;
403
659
  let vy = 0;
404
660
  let bob = 0;
405
661
  let motion = "drift";
662
+ let warpParams;
663
+ let pathPhase = 0;
664
+ let pathOffX = 0;
665
+ let pathOffY = 0;
406
666
  const m = layer.motion;
407
667
  if (m.type === "static") {
408
668
  motion = "static";
@@ -426,18 +686,53 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
426
686
  } else if (m.type === "orbit") {
427
687
  motion = "orbit";
428
688
  vx = rng.range(m.speed[0], m.speed[1]);
689
+ } else if (m.type === "wander") {
690
+ motion = "wander";
691
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
692
+ const heading = m.angle !== void 0 ? m.angle * Math.PI / 180 : rng.range(0, Math.PI * 2);
693
+ vx = s * Math.cos(heading);
694
+ vy = s * Math.sin(heading);
695
+ } else if (m.type === "warp") {
696
+ motion = "warp";
697
+ const vz = rng.range(m.speed[0], m.speed[1]);
698
+ const theta = rng.range(0, Math.PI * 2);
699
+ const r = rng.range(0.15, 1);
700
+ warpParams = {
701
+ ux: Math.cos(theta) * r,
702
+ uy: Math.sin(theta) * r,
703
+ z0: rng.range(WARP_NEAR, 1),
704
+ vz,
705
+ cx: (m.center?.x ?? 0.5) * w,
706
+ cy: (m.center?.y ?? 0.5) * h
707
+ };
708
+ } else if (m.type === "path") {
709
+ motion = "path";
710
+ pathPhase = rng.next();
711
+ if (m.scatter) {
712
+ pathOffX = rng.range(-1, 1) * m.scatter * scale;
713
+ pathOffY = rng.range(-1, 1) * m.scatter * scale;
714
+ }
429
715
  }
430
716
  let x0;
431
717
  let y0;
432
718
  if (layer.position && layer.count === 1) {
433
719
  x0 = layer.position.x * w;
434
720
  y0 = layer.position.y * h;
721
+ } else if (layout?.type === "grid") {
722
+ const col = i % gridCols;
723
+ const row = Math.floor(i / gridCols);
724
+ const j = layout.jitter ?? 0;
725
+ const jx = typeof j === "number" ? j : j.x ?? 0;
726
+ const jy = typeof j === "number" ? j : j.y ?? 0;
727
+ x0 = gridX0 + (col + 0.5) * cellW + rng.range(-0.5, 0.5) * jx * cellW;
728
+ y0 = gridY0 + (row + 0.5) * cellH + rng.range(-0.5, 0.5) * jy * cellH;
435
729
  } else {
436
730
  const [rx0, rx1] = layer.region?.x ?? [0, 1];
437
731
  const [ry0, ry1] = layer.region?.y ?? [0, 1];
438
732
  x0 = rng.range(rx0 * w, rx1 * w);
439
733
  y0 = rng.range(ry0 * h, ry1 * h);
440
734
  }
735
+ const orbitParent = m.type === "orbit" && m.center && "layer" in m.center ? m.center.layer : void 0;
441
736
  out.push({
442
737
  x0,
443
738
  y0,
@@ -461,29 +756,79 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
461
756
  growPeriod: layer.grow?.period ?? 1e3,
462
757
  growPhase: layer.grow ? rng.range(0, Math.PI * 2) : 0,
463
758
  orbitR: m.type === "orbit" ? rng.range(m.radius[0], m.radius[1]) * scale : 0,
464
- orbitCx: m.type === "orbit" ? (m.center?.x ?? 0.5) * w : 0,
465
- orbitCy: m.type === "orbit" ? (m.center?.y ?? 0.5) * h : 0,
759
+ orbitCx: m.type === "orbit" && !orbitParent ? (m.center?.x ?? 0.5) * w : 0,
760
+ orbitCy: m.type === "orbit" && !orbitParent ? (m.center?.y ?? 0.5) * h : 0,
466
761
  // New draws AFTER orbit — guarded so existing specs keep identical streams.
467
- colorIndex: colorsLen > 0 ? rng.int(0, colorsLen - 1) : -1,
468
- cyclePeriod: cycle ? cycle.period : 0
762
+ colorIndex: colorsLen > 0 ? colorWeights && colorWeights.length === colorsLen ? weightedIndex(rng.next(), colorWeights) : rng.int(0, colorsLen - 1) : -1,
763
+ cyclePeriod: cycle ? cycle.period : 0,
764
+ // Optional feature fields last (guarded draws inside — wander draws 18 here).
765
+ ...size2 !== void 0 ? { size2 } : {},
766
+ ...orbitParent ? { orbitParent } : {},
767
+ ...m.type === "wander" ? {
768
+ wander: {
769
+ own: drawOsc(rng, meanderPx),
770
+ shared: sharedOsc,
771
+ coherence: Math.max(0, Math.min(1, m.coherence ?? 0)),
772
+ margin: meanderPx * (OCT_AMP[0] + OCT_AMP[1] + OCT_AMP[2]) * 1.4
773
+ }
774
+ } : {},
775
+ ...warpParams ? { warp: warpParams } : {},
776
+ ...m.type === "path" && pathPts ? {
777
+ path: {
778
+ pts: pathPts,
779
+ duration: m.duration,
780
+ closed: m.closed !== false,
781
+ smooth: m.curve !== "linear",
782
+ phase: pathPhase,
783
+ offX: pathOffX,
784
+ offY: pathOffY
785
+ }
786
+ } : {}
469
787
  });
788
+ if (layer.pulse?.wave) {
789
+ const wl = Math.max(1e-6, layer.pulse.wave.wavelength * scale);
790
+ const ang = (layer.pulse.wave.angle ?? 0) * Math.PI / 180;
791
+ out[out.length - 1].pulsePhase = -((x0 * Math.cos(ang) + y0 * Math.sin(ang)) / wl) * Math.PI * 2;
792
+ }
470
793
  }
471
794
  return out;
472
795
  }
796
+ function warpZ(e, t) {
797
+ const wp = e.warp;
798
+ return wrap(wp.z0 - wp.vz * (t / 1e3), WARP_NEAR, 1);
799
+ }
473
800
  function alphaAt(e, t) {
474
- if (!e.pulseAmp) return e.alpha;
475
- const a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
801
+ let a = e.alpha;
802
+ if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
803
+ if (e.motion === "warp") {
804
+ const z = warpZ(e, t);
805
+ a *= Math.max(0, Math.min(1, (1 - z) / 0.2));
806
+ }
476
807
  return a < 0 ? 0 : a > 1 ? 1 : a;
477
808
  }
478
809
  function sizeAt(e, t) {
479
- if (!e.growAmp) return e.size;
480
- const s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
810
+ let s = e.size;
811
+ if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
812
+ if (e.motion === "warp") s *= Math.min(1 / warpZ(e, t), WARP_MAX_SCALE);
481
813
  return s > 0 ? s : 0.1;
482
814
  }
483
815
  function rotationAt(e, t) {
484
816
  if (!e.spinSpeed) return 0;
485
817
  return e.spinPhase + e.spinSpeed * (t / 1e3);
486
818
  }
819
+ function lifeAlphaAt(life, t) {
820
+ if (!life) return 1;
821
+ const fade = life.fade ?? 1e3;
822
+ let a = 1;
823
+ if (life.enter !== void 0) {
824
+ if (t < life.enter) return 0;
825
+ a = fade > 0 ? Math.min(1, (t - life.enter) / fade) : 1;
826
+ }
827
+ if (life.exit !== void 0 && t >= life.exit) {
828
+ a *= fade > 0 ? Math.max(0, 1 - (t - life.exit) / fade) : 0;
829
+ }
830
+ return a;
831
+ }
487
832
  function wrap(v, min, max) {
488
833
  const range = max - min;
489
834
  return ((v - min) % range + range) % range + min;
@@ -495,6 +840,40 @@ function reflect(v, min, max) {
495
840
  const p = ((v - min) % period + period) % period;
496
841
  return min + (p < range ? p : period - p);
497
842
  }
843
+ function catmullRom(p0, p1, p2, p3, u) {
844
+ const u2 = u * u;
845
+ const u3 = u2 * u;
846
+ return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
847
+ }
848
+ function pathPosition(e, t) {
849
+ const p = e.path;
850
+ const n = p.pts.length;
851
+ let s = (t / p.duration + p.phase) % 1;
852
+ if (s < 0) s += 1;
853
+ if (!p.closed) {
854
+ const pp = s * 2;
855
+ s = pp < 1 ? pp : 2 - pp;
856
+ }
857
+ const segs = p.closed ? n : n - 1;
858
+ const u = Math.min(s * segs, segs - 1e-9);
859
+ const i = Math.floor(u);
860
+ const local = u - i;
861
+ const at = (k) => p.closed ? p.pts[(k % n + n) % n] : p.pts[Math.max(0, Math.min(n - 1, k))];
862
+ const p1 = at(i);
863
+ const p2 = at(i + 1);
864
+ if (!p.smooth) {
865
+ return {
866
+ x: p1.x + (p2.x - p1.x) * local + p.offX,
867
+ y: p1.y + (p2.y - p1.y) * local + p.offY
868
+ };
869
+ }
870
+ const p0 = at(i - 1);
871
+ const p3 = at(i + 2);
872
+ return {
873
+ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local) + p.offX,
874
+ y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) + p.offY
875
+ };
876
+ }
498
877
  function positionAt(e, t, w, h) {
499
878
  if (e.motion === "static") return { x: e.x0, y: e.y0, flip: false };
500
879
  if (e.motion === "orbit") {
@@ -505,6 +884,20 @@ function positionAt(e, t, w, h) {
505
884
  flip: false
506
885
  };
507
886
  }
887
+ if (e.motion === "warp") {
888
+ const wp = e.warp;
889
+ const persp = 1 / warpZ(e, t);
890
+ const halfMin = Math.min(w, h) / 2;
891
+ return {
892
+ x: wp.cx + wp.ux * halfMin * persp,
893
+ y: wp.cy + wp.uy * halfMin * persp,
894
+ flip: false
895
+ };
896
+ }
897
+ if (e.motion === "path") {
898
+ const pos = pathPosition(e, t);
899
+ return { x: pos.x, y: pos.y, flip: false };
900
+ }
508
901
  const dt = t / 1e3;
509
902
  const m = e.size;
510
903
  if (e.motion === "bounce") {
@@ -521,15 +914,94 @@ function positionAt(e, t, w, h) {
521
914
  flip: false
522
915
  };
523
916
  }
917
+ if (e.motion === "wander") {
918
+ const wp = e.wander;
919
+ const c = wp.coherence;
920
+ let hx = 0;
921
+ let hy = 0;
922
+ for (let i = 0; i < 3; i++) {
923
+ if (c < 1) {
924
+ hx += (1 - c) * wp.own.ax[i] * Math.sin(wp.own.fx[i] * t + wp.own.phx[i]);
925
+ hy += (1 - c) * wp.own.ay[i] * Math.sin(wp.own.fy[i] * t + wp.own.phy[i]);
926
+ }
927
+ if (c > 0) {
928
+ hx += c * wp.shared.ax[i] * Math.sin(wp.shared.fx[i] * t + wp.shared.phx[i]);
929
+ hy += c * wp.shared.ay[i] * Math.sin(wp.shared.fy[i] * t + wp.shared.phy[i]);
930
+ }
931
+ }
932
+ const margin = m + wp.margin;
933
+ return {
934
+ x: wrap(e.x0 + e.vx * dt + hx, -margin, w + margin),
935
+ y: wrap(e.y0 + e.vy * dt + hy, -margin, h + margin),
936
+ flip: e.headingLeft
937
+ };
938
+ }
524
939
  const x = wrap(e.x0 + e.vx * dt, -m, w + m);
525
940
  let y = e.vy !== 0 ? wrap(e.y0 + e.vy * dt, -m, h + m) : e.y0;
526
941
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
527
942
  return { x, y, flip: e.headingLeft };
528
943
  }
944
+ function headingAt(e, t, w, h) {
945
+ const dtMs = 32;
946
+ const t1 = t < dtMs ? t + dtMs : t;
947
+ const t0 = t1 - dtMs;
948
+ const a = positionAt(e, t0, w, h);
949
+ const b = positionAt(e, t1, w, h);
950
+ const dx = b.x - a.x;
951
+ const dy = b.y - a.y;
952
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) return null;
953
+ if (dx === 0 && dy === 0) return null;
954
+ return Math.atan2(dy, dx);
955
+ }
529
956
  function spriteIndexAt(e, t, variants) {
530
957
  if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
531
958
  return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
532
959
  }
960
+ function wrapDist(pi, pj, doWrap, w, h) {
961
+ let dx = pj.x - pi.x;
962
+ let dy = pj.y - pi.y;
963
+ if (doWrap) {
964
+ if (Math.abs(dx) > w / 2) dx = dx > 0 ? dx - w : dx + w;
965
+ if (Math.abs(dy) > h / 2) dy = dy > 0 ? dy - h : dy + h;
966
+ }
967
+ return Math.sqrt(dx * dx + dy * dy);
968
+ }
969
+ function linkEdges(links, positions, maxDistPx, doWrap, w, h) {
970
+ const n = positions.length;
971
+ const mode = links.mode ?? "nearest";
972
+ if (mode === "chain") {
973
+ const out = [];
974
+ for (let i = 0; i < n - 1; i++) {
975
+ out.push({ i, j: i + 1, dist: wrapDist(positions[i], positions[i + 1], doWrap, w, h) });
976
+ }
977
+ if (links.closed && n > 2) out.push({ i: n - 1, j: 0, dist: wrapDist(positions[n - 1], positions[0], doWrap, w, h) });
978
+ return out;
979
+ }
980
+ if (mode === "random") {
981
+ const stride = Math.max(1, Math.round(n * 0.381966));
982
+ const edges = /* @__PURE__ */ new Set();
983
+ const out = [];
984
+ for (let i = 0; i < n; i++) {
985
+ for (let mIdx = 1; mIdx <= links.k; mIdx++) {
986
+ const j = (i + mIdx * stride) % n;
987
+ if (j === i) continue;
988
+ const dist = wrapDist(positions[i], positions[j], doWrap, w, h);
989
+ if (dist > maxDistPx) continue;
990
+ const key = `${Math.min(i, j)}:${Math.max(i, j)}`;
991
+ if (!edges.has(key)) {
992
+ edges.add(key);
993
+ out.push({ i: Math.min(i, j), j: Math.max(i, j), dist });
994
+ }
995
+ }
996
+ }
997
+ return out;
998
+ }
999
+ return linkPairs(positions, links.k, maxDistPx, doWrap, w, h).map(([i, j]) => ({
1000
+ i,
1001
+ j,
1002
+ dist: wrapDist(positions[i], positions[j], doWrap, w, h)
1003
+ }));
1004
+ }
533
1005
  function linkPairs(positions, k, maxDist, doWrap, w, h) {
534
1006
  const n = positions.length;
535
1007
  const edges = /* @__PURE__ */ new Set();
@@ -638,7 +1110,7 @@ function lerpSpec(from, to, k) {
638
1110
  }
639
1111
  function steerablePaths(spec) {
640
1112
  if (!spec || typeof spec !== "object") return [];
641
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units"]);
1113
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
642
1114
  const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
643
1115
  const out = [];
644
1116
  const walk = (node, prefix, key) => {
@@ -669,23 +1141,32 @@ function easeSmooth(k) {
669
1141
  function structuralSignature(spec) {
670
1142
  return JSON.stringify([
671
1143
  spec.units,
672
- spec.layers.map((l) => [
673
- l.count,
674
- l.size,
675
- l.region,
676
- l.position,
677
- l.motion,
678
- l.wrap,
679
- l.flip,
680
- l.alpha,
681
- l.pulse,
682
- l.spin,
683
- l.grow,
684
- l.sprite.kind,
685
- l.sprite.kind === "circle" ? l.sprite.radius : void 0,
686
- l.sprite.kind === "circle" ? l.sprite.colors?.length : void 0,
687
- 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
688
- ])
1144
+ spec.layers.map((l) => {
1145
+ const s = l.sprite;
1146
+ return [
1147
+ l.count,
1148
+ l.size,
1149
+ l.region,
1150
+ l.position,
1151
+ l.motion,
1152
+ l.wrap,
1153
+ l.flip,
1154
+ l.alpha,
1155
+ l.pulse,
1156
+ l.spin,
1157
+ l.grow,
1158
+ l.layout,
1159
+ l.sprite.kind,
1160
+ // Dimensional draws baked into entities: radius (circle/ring), length
1161
+ // (streak), width+aspect (rect), palette pick (all shaped sprites).
1162
+ s.radius,
1163
+ s.length,
1164
+ l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
1165
+ Array.isArray(s.colors) ? s.colors.length : void 0,
1166
+ s.colorWeights,
1167
+ 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
1168
+ ];
1169
+ })
689
1170
  ]);
690
1171
  }
691
1172
 
@@ -696,6 +1177,12 @@ function hexToRgba(hex, alpha) {
696
1177
  const n = parseInt(h.slice(1), 16);
697
1178
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${alpha})`;
698
1179
  }
1180
+ var FONT_PREFIX_RE = /^((?:(?:italic|oblique|bold|bolder|lighter|normal|\d{3})\s+)+)/;
1181
+ function composeFontShorthand(sz, font) {
1182
+ const m = FONT_PREFIX_RE.exec(font);
1183
+ if (m) return `${m[1].trim()} ${sz}px ${font.slice(m[0].length)}`;
1184
+ return `${sz}px ${font}`;
1185
+ }
699
1186
  function manifestFor(spec) {
700
1187
  const total = spec.layers.reduce((n, l) => n + l.count, 0);
701
1188
  const costTier = total < 30 ? "idle" : total < 150 ? "low" : total < 400 ? "medium" : "high";
@@ -755,6 +1242,8 @@ var SpecInstance = class {
755
1242
  baseT = 0;
756
1243
  // elapsed logical time carried across pause/resume
757
1244
  lastT = 0;
1245
+ /** Last time painted — detects non-contiguous seeks for the ghosting warm-up replay. */
1246
+ lastRenderT = Number.NEGATIVE_INFINITY;
758
1247
  /** The spec currently being rendered (base spec + any applied steering). */
759
1248
  effSpec;
760
1249
  /** Active glide between two resolved specs (live setParam/applyTrack). */
@@ -765,12 +1254,14 @@ var SpecInstance = class {
765
1254
  this.canvas.width = Math.max(1, Math.round(this.w * dpr));
766
1255
  this.canvas.height = Math.max(1, Math.round(this.h * dpr));
767
1256
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1257
+ this.lastRenderT = Number.NEGATIVE_INFINITY;
768
1258
  }
769
1259
  /** (Re)seed and place all entities — deterministic for the seed + viewport. */
770
1260
  rebuild() {
771
1261
  const rng = createRng(this.seed);
772
- const scale = this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1;
773
- let countScale = scale > 1 ? Math.min(this.w, this.h) / LIMITS.referenceViewport : 1;
1262
+ const scale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
1263
+ const refVp = this.effSpec.referenceViewport ?? LIMITS.referenceViewport;
1264
+ let countScale = scale > 1 ? Math.min(this.w, this.h) / refVp : 1;
774
1265
  if (countScale > 1) {
775
1266
  const rawTotal = this.effSpec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
776
1267
  if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
@@ -820,30 +1311,49 @@ var SpecInstance = class {
820
1311
  ctx.fillRect(0, 0, w, h);
821
1312
  if (bg.band) {
822
1313
  ctx.fillStyle = bg.band.color;
823
- const bh = bg.band.height * (this.effSpec.units === "viewport" ? Math.min(w, h) : 1);
1314
+ const bh = bg.band.height * (this.effSpec.units === "px" ? 1 : Math.min(w, h));
824
1315
  ctx.fillRect(0, h - bh, w, bh);
825
1316
  }
826
1317
  }
827
- drawTrail(built, e, t) {
1318
+ /** Position with parent-orbit resolution: a layer-parented orbit entity's
1319
+ * positionAt is an offset around (0,0); add the parent's own analytic position. */
1320
+ entityPos(e, t, parentE) {
1321
+ const p = positionAt(e, t, this.w, this.h);
1322
+ if (parentE && e.orbitParent) {
1323
+ const pp = positionAt(parentE, t, this.w, this.h);
1324
+ p.x += pp.x;
1325
+ p.y += pp.y;
1326
+ }
1327
+ return p;
1328
+ }
1329
+ /** Resolve the single parent entity for a layer-parented orbit layer (or null). */
1330
+ parentEntityFor(built) {
1331
+ const m = built.layer.motion;
1332
+ if (m.type !== "orbit" || !m.center || !("layer" in m.center)) return null;
1333
+ const key = m.center.layer;
1334
+ const parent = this.layers.find((b) => b.layer.key === key);
1335
+ return parent?.entities[0] ?? null;
1336
+ }
1337
+ drawTrail(built, e, t, lifeA, parentE) {
828
1338
  const trail = built.layer.trail;
829
1339
  if (!trail) return;
830
1340
  const { ctx, w, h } = this;
831
1341
  const fade = trail.fade ?? 1;
832
1342
  const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
833
- const headAlpha = alphaAt(e, t);
1343
+ const headAlpha = alphaAt(e, t) * lifeA;
834
1344
  const headSize = sizeAt(e, t);
835
1345
  const sprite = built.layer.sprite;
836
- const resolvedColor = sprite.kind === "circle" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
1346
+ const resolvedColor = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
837
1347
  const isSoft = sprite.kind === "circle" && sprite.soft;
838
1348
  const wrap2 = built.layer.wrap !== false;
839
- const head = positionAt(e, t, w, h);
1349
+ const head = this.entityPos(e, t, parentE);
840
1350
  let prevX = head.x;
841
1351
  let prevY = head.y;
842
1352
  for (let s = 1; s <= n; s++) {
843
1353
  const k = s / n;
844
1354
  const pastT = t - k * trail.length;
845
1355
  if (pastT < 0) break;
846
- const pos = positionAt(e, pastT, w, h);
1356
+ const pos = this.entityPos(e, pastT, parentE);
847
1357
  if (wrap2) {
848
1358
  const dx = pos.x - prevX;
849
1359
  const dy = pos.y - prevY;
@@ -870,13 +1380,52 @@ var SpecInstance = class {
870
1380
  ctx.fill();
871
1381
  }
872
1382
  }
873
- drawEntity(built, e, t) {
1383
+ drawEntity(built, e, t, lifeA, parentE) {
874
1384
  const { ctx } = this;
875
- const p = positionAt(e, t, this.w, this.h);
1385
+ const p = this.entityPos(e, t, parentE);
876
1386
  const sprite = built.layer.sprite;
877
1387
  const sz = sizeAt(e, t);
878
1388
  const rot = rotationAt(e, t);
879
- ctx.globalAlpha = alphaAt(e, t);
1389
+ const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
1390
+ ctx.globalAlpha = alphaAt(e, t) * lifeA;
1391
+ if (sprite.kind === "ring") {
1392
+ const r = sz / 2;
1393
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
1394
+ ctx.strokeStyle = resolvedColor;
1395
+ ctx.lineWidth = Math.max(0.5, (sprite.width ?? (unitScale === 1 ? 2 : 2e-3)) * unitScale);
1396
+ ctx.beginPath();
1397
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
1398
+ ctx.stroke();
1399
+ return;
1400
+ }
1401
+ if (sprite.kind === "streak") {
1402
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
1403
+ const heading = headingAt(e, t, this.w, this.h) ?? 0;
1404
+ const tailX = p.x - Math.cos(heading) * sz;
1405
+ const tailY = p.y - Math.sin(heading) * sz;
1406
+ const g = ctx.createLinearGradient(tailX, tailY, p.x, p.y);
1407
+ g.addColorStop(0, hexToRgba(resolvedColor, 0));
1408
+ g.addColorStop(1, resolvedColor);
1409
+ ctx.strokeStyle = g;
1410
+ ctx.lineWidth = Math.max(0.5, (sprite.width ?? (unitScale === 1 ? 2 : 2e-3)) * unitScale);
1411
+ ctx.lineCap = "round";
1412
+ ctx.beginPath();
1413
+ ctx.moveTo(tailX, tailY);
1414
+ ctx.lineTo(p.x, p.y);
1415
+ ctx.stroke();
1416
+ return;
1417
+ }
1418
+ if (sprite.kind === "rect") {
1419
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
1420
+ const rh = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
1421
+ ctx.save();
1422
+ ctx.translate(p.x, p.y);
1423
+ if (rot) ctx.rotate(rot);
1424
+ ctx.fillStyle = resolvedColor;
1425
+ ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
1426
+ ctx.restore();
1427
+ return;
1428
+ }
880
1429
  if (sprite.kind === "circle") {
881
1430
  const r = sz / 2;
882
1431
  const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
@@ -914,11 +1463,11 @@ var SpecInstance = class {
914
1463
  } else {
915
1464
  ctx.textAlign = sprite.align ?? "center";
916
1465
  ctx.textBaseline = sprite.baseline ?? "middle";
917
- ctx.font = sprite.font ?? `${sz}px system-ui, sans-serif`;
1466
+ ctx.font = sprite.font ? /\dpx/.test(sprite.font) ? sprite.font : composeFontShorthand(sz, sprite.font) : `${sz}px system-ui, sans-serif`;
918
1467
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
919
1468
  const idx = spriteIndexAt(e, t, sprite.strings.length);
920
1469
  const text = sprite.strings[idx] ?? sprite.strings[0];
921
- const mw = sprite.maxWidth ? sprite.maxWidth * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1) : void 0;
1470
+ const mw = sprite.maxWidth ? sprite.maxWidth * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h)) : void 0;
922
1471
  if (mw) ctx.fillText(text, 0, 0, mw);
923
1472
  else ctx.fillText(text, 0, 0);
924
1473
  }
@@ -962,26 +1511,32 @@ var SpecInstance = class {
962
1511
  this.renderFrame(this.lastT, this.seed);
963
1512
  }
964
1513
  }
965
- drawLinks(built, t) {
1514
+ drawLinks(built, t, lifeA, parentE) {
966
1515
  const { links } = built.layer;
967
1516
  if (!links) return;
968
1517
  const { ctx } = this;
969
- const wrap2 = built.layer.wrap !== false;
970
- const positions = built.entities.map((e) => positionAt(e, t, this.w, this.h));
971
- const pairs = linkPairs(positions, links.k, links.maxDist * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1), wrap2, this.w, this.h);
972
- const lw = (links.width ?? 1) * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1);
1518
+ const motionWraps = ["drift", "rise", "wander"].includes(built.layer.motion.type);
1519
+ const wrap2 = built.layer.wrap !== false && motionWraps;
1520
+ const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
1521
+ const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
1522
+ const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
1523
+ const lw = (links.width ?? 1) * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
973
1524
  ctx.lineWidth = lw;
974
- for (const [i, j] of pairs) {
1525
+ ctx.lineCap = "butt";
1526
+ for (const { i, j, dist } of edges) {
975
1527
  const pi = positions[i];
976
1528
  const pj = positions[j];
977
1529
  const ei = built.entities[i];
978
1530
  let resolvedColor = links.color;
979
1531
  if (!resolvedColor) {
980
1532
  const sprite = built.layer.sprite;
981
- if (sprite.kind === "circle") resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
982
- else resolvedColor = "#e6e8ef";
1533
+ if (sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect") {
1534
+ resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
1535
+ } else resolvedColor = "#e6e8ef";
983
1536
  }
984
- ctx.globalAlpha = links.alpha ?? alphaAt(ei, t);
1537
+ let a = links.alpha ?? alphaAt(ei, t);
1538
+ if (links.falloff) a *= Math.max(0, 1 - dist / maxDistPx);
1539
+ ctx.globalAlpha = a * lifeA;
985
1540
  ctx.strokeStyle = resolvedColor;
986
1541
  let dx = pj.x - pi.x;
987
1542
  let dy = pj.y - pi.y;
@@ -995,24 +1550,57 @@ var SpecInstance = class {
995
1550
  ctx.stroke();
996
1551
  }
997
1552
  }
998
- /** Deterministic, frame-addressable render (shared by the rAF loop). */
999
- renderFrame(t, _seed) {
1000
- this.stepTransition(t);
1553
+ /** Paint one composite pass at time t. `bgAlpha` < 1 leaves the previous frame
1554
+ * showing through — the ghosting smear. */
1555
+ paintFrame(t, bgAlpha) {
1001
1556
  const { ctx } = this;
1002
- ctx.globalAlpha = 1;
1557
+ ctx.globalAlpha = bgAlpha;
1003
1558
  ctx.globalCompositeOperation = "source-over";
1004
1559
  this.drawBackground(t);
1560
+ ctx.globalAlpha = 1;
1005
1561
  for (const built of this.layers) {
1562
+ const lifeA = lifeAlphaAt(built.layer.life, t);
1563
+ if (lifeA <= 0) continue;
1564
+ const parentE = this.parentEntityFor(built);
1006
1565
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
1007
- this.drawLinks(built, t);
1566
+ this.drawLinks(built, t, lifeA, parentE);
1008
1567
  for (const e of built.entities) {
1009
- this.drawTrail(built, e, t);
1010
- this.drawEntity(built, e, t);
1568
+ this.drawTrail(built, e, t, lifeA, parentE);
1569
+ this.drawEntity(built, e, t, lifeA, parentE);
1011
1570
  }
1012
1571
  }
1013
1572
  ctx.globalAlpha = 1;
1014
1573
  ctx.globalCompositeOperation = "source-over";
1015
1574
  }
1575
+ /**
1576
+ * Deterministic, frame-addressable render (shared by the rAF loop).
1577
+ *
1578
+ * With `ghosting` set, contiguous frames composite over the previous frame with a
1579
+ * refresh-rate-normalized fade. A non-contiguous seek (pause render, e2e frame
1580
+ * addressing) replays a bounded fixed-step warm-up from a full clear, so the
1581
+ * accumulated smear at time t is identical on every run — the determinism proof
1582
+ * survives statefulness because the "state" is reconstructed from pure history.
1583
+ */
1584
+ renderFrame(t, _seed) {
1585
+ this.stepTransition(t);
1586
+ const g = this.effSpec.ghosting ?? 0;
1587
+ if (g > 0) {
1588
+ const dt = 1e3 / 60;
1589
+ const contiguous = this.lastRenderT !== Number.NEGATIVE_INFINITY && t > this.lastRenderT && t - this.lastRenderT <= 250;
1590
+ if (contiguous) {
1591
+ this.paintFrame(t, 1 - Math.pow(g, (t - this.lastRenderT) / dt));
1592
+ } else {
1593
+ const k = Math.min(Math.ceil(Math.log(1 / 255) / Math.log(g)), LIMITS.maxGhostReplayFrames);
1594
+ const t0 = Math.max(0, t - k * dt);
1595
+ this.paintFrame(t0, 1);
1596
+ for (let ft = t0 + dt; ft < t - dt / 2; ft += dt) this.paintFrame(ft, 1 - g);
1597
+ if (t > t0) this.paintFrame(t, 1 - g);
1598
+ }
1599
+ } else {
1600
+ this.paintFrame(t, 1);
1601
+ }
1602
+ this.lastRenderT = t;
1603
+ }
1016
1604
  setPaused(paused) {
1017
1605
  this.paused = paused;
1018
1606
  if (paused) {
@@ -1049,8 +1637,9 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1049
1637
  const warnings = [];
1050
1638
  const w = viewport.width;
1051
1639
  const h = viewport.height;
1052
- const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
1053
- const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
1640
+ const scale = spec.units === "px" ? 1 : Math.min(w, h);
1641
+ const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
1642
+ const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
1054
1643
  const rng = createRng2(spec.seed ?? 42);
1055
1644
  const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
1056
1645
  let totalEntities = 0;
@@ -1071,6 +1660,20 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1071
1660
  message: "trail has no effect on static entities \u2014 they have no past positions to draw"
1072
1661
  });
1073
1662
  }
1663
+ if (layer.sprite.kind === "streak" && layer.motion.type === "static") {
1664
+ warnings.push({
1665
+ path: `layers[${li}].sprite`,
1666
+ code: "streak-on-static",
1667
+ message: "streak sprites orient along the motion heading \u2014 static entities have none and will render at angle 0"
1668
+ });
1669
+ }
1670
+ if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
1671
+ warnings.push({
1672
+ path: `layers[${li}].motion.coherence`,
1673
+ code: "full-coherence",
1674
+ message: "coherence 1 makes every entity share identical harmonics \u2014 the layer moves as a rigid sheet. 0.5\u20130.8 reads as schooling"
1675
+ });
1676
+ }
1074
1677
  if (layer.sprite.kind === "circle") {
1075
1678
  const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
1076
1679
  const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
@@ -1092,6 +1695,14 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1092
1695
  const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
1093
1696
  totalCoverage += pixArea * e.alpha / (w * h);
1094
1697
  }
1698
+ if (layer.links) {
1699
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
1700
+ const maxDistPx = layer.links.maxDist * scale;
1701
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
1702
+ const lwPx = (layer.links.width ?? 1) * scale;
1703
+ const la = layer.links.alpha ?? 1;
1704
+ for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
1705
+ }
1095
1706
  }
1096
1707
  if (totalCoverage < 5e-4 && spec.layers.length > 0) {
1097
1708
  warnings.push({
@@ -1119,7 +1730,7 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1119
1730
  }
1120
1731
  for (let li = 0; li < spec.layers.length; li++) {
1121
1732
  const layer = spec.layers[li];
1122
- if (!layer.links) continue;
1733
+ if (!layer.links || layer.links.mode === "chain") continue;
1123
1734
  const entities = allEntities[li];
1124
1735
  const positions = entities.map((e) => positionAt(e, 0, w, h));
1125
1736
  const maxDist = layer.links.maxDist * scale;
@@ -1150,8 +1761,10 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1150
1761
  });
1151
1762
  }
1152
1763
  }
1153
- if (totalEntities >= 8) {
1154
- const positions = allEntities.flatMap((ents) => ents.map((e) => positionAt(e, 0, w, h)));
1764
+ const composed = spec.layers.map((l, li) => ({ l, ents: allEntities[li] })).filter(({ l }) => !["bounce", "warp", "path"].includes(l.motion.type)).filter(({ l }) => !(l.motion.type === "orbit" && l.motion.center && "layer" in l.motion.center));
1765
+ const composedCount = composed.reduce((s, c) => s + c.ents.length, 0);
1766
+ if (composedCount >= 8) {
1767
+ const positions = composed.flatMap(({ ents }) => ents.map((e) => positionAt(e, 0, w, h)));
1155
1768
  const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
1156
1769
  const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
1157
1770
  const offX = Math.abs(cx / w - 0.5);
@@ -1174,7 +1787,8 @@ function describeScene(spec, options = {}) {
1174
1787
  const seed = options.seed ?? spec.seed ?? 42;
1175
1788
  const times = options.times ?? [0, 5e3, 15e3];
1176
1789
  const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
1177
- const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
1790
+ const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
1791
+ const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
1178
1792
  const rng = createRng3(seed);
1179
1793
  const builtLayers = spec.layers.map((layer) => ({
1180
1794
  layer,
@@ -1259,6 +1873,418 @@ function describeScene(spec, options = {}) {
1259
1873
  };
1260
1874
  }
1261
1875
 
1876
+ // src/perceive.ts
1877
+ import { createRng as createRng4 } from "@idle-screens/core";
1878
+ function buildScene(spec, opts) {
1879
+ const { width: w, height: h } = opts.viewport ?? { width: 1920, height: 1080 };
1880
+ const seed = opts.seed ?? spec.seed ?? 42;
1881
+ const scale = spec.units === "px" ? 1 : Math.min(w, h);
1882
+ const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
1883
+ let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
1884
+ if (countScale > 1) {
1885
+ const rawTotal = spec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
1886
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
1887
+ }
1888
+ const rng = createRng4(seed);
1889
+ const layers = spec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, w, h, scale, countScale) }));
1890
+ const byKey = /* @__PURE__ */ new Map();
1891
+ for (const { layer, entities } of layers) if (layer.key) byKey.set(layer.key, entities);
1892
+ return { w, h, scale, layers, byKey };
1893
+ }
1894
+ function posOf(scene, e, t) {
1895
+ const p = positionAt(e, t, scene.w, scene.h);
1896
+ if (e.orbitParent) {
1897
+ const parent = scene.byKey.get(e.orbitParent)?.[0];
1898
+ if (parent) {
1899
+ const pp = positionAt(parent, t, scene.w, scene.h);
1900
+ p.x += pp.x;
1901
+ p.y += pp.y;
1902
+ }
1903
+ }
1904
+ return p;
1905
+ }
1906
+ function hexLuma(hex) {
1907
+ const h = hex.length === 4 ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` : hex;
1908
+ const n = parseInt(h.slice(1), 16);
1909
+ if (Number.isNaN(n)) return 0.7;
1910
+ const r = (n >> 16 & 255) / 255;
1911
+ const g = (n >> 8 & 255) / 255;
1912
+ const b = (n & 255) / 255;
1913
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1914
+ }
1915
+ function spriteLuma(layer, e) {
1916
+ const s = layer.sprite;
1917
+ if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
1918
+ return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
1919
+ }
1920
+ if (s.kind === "text") return hexLuma(s.color ?? "#e6e8ef");
1921
+ return 0.75;
1922
+ }
1923
+ function textBox(s, e, p) {
1924
+ const m = s.font ? /(\d+(?:\.\d+)?)px/.exec(s.font) : null;
1925
+ const fh = m ? Number(m[1]) : e.size;
1926
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
1927
+ const fw = Math.max(fh, 0.62 * fh * Math.min(str.length, 48));
1928
+ const align = s.align ?? "center";
1929
+ const baseline = s.baseline ?? "middle";
1930
+ return {
1931
+ cx: align === "left" ? p.x + fw / 2 : align === "right" ? p.x - fw / 2 : p.x,
1932
+ cy: baseline === "top" ? p.y + fh * 0.6 : baseline === "bottom" ? p.y - fh * 0.6 : p.y,
1933
+ halfX: fw / 2,
1934
+ halfY: fh * 0.6
1935
+ };
1936
+ }
1937
+ function luminanceGrid(spec, opts = {}) {
1938
+ const scene = buildScene(spec, opts);
1939
+ const t = opts.t ?? 5e3;
1940
+ const cols = Math.max(8, Math.min(200, opts.cols ?? 80));
1941
+ const rows = Math.max(8, Math.min(200, opts.rows ?? 48));
1942
+ const { w, h, scale } = scene;
1943
+ const cellW = w / cols;
1944
+ const cellH = h / rows;
1945
+ const bg = spec.background;
1946
+ const bgRow = new Array(rows).fill(0);
1947
+ if (!bg || bg.type === "solid") {
1948
+ bgRow.fill(hexLuma(bg?.color ?? "#05050a"));
1949
+ } else {
1950
+ const stops = [...bg.stops].sort((a, b) => a.at - b.at);
1951
+ for (let r = 0; r < rows; r++) {
1952
+ const y = (r + 0.5) / rows;
1953
+ let lum;
1954
+ if (y <= stops[0].at) lum = hexLuma(stops[0].color);
1955
+ else if (y >= stops[stops.length - 1].at) lum = hexLuma(stops[stops.length - 1].color);
1956
+ else {
1957
+ let i = 0;
1958
+ while (i < stops.length - 1 && stops[i + 1].at < y) i++;
1959
+ const a = stops[i];
1960
+ const b = stops[i + 1];
1961
+ const k = b.at === a.at ? 0 : (y - a.at) / (b.at - a.at);
1962
+ lum = hexLuma(a.color) + (hexLuma(b.color) - hexLuma(a.color)) * k;
1963
+ }
1964
+ bgRow[r] = lum;
1965
+ }
1966
+ if (bg.band) {
1967
+ const bandPx = bg.band.height * (spec.units === "px" ? 1 : Math.min(w, h));
1968
+ const fromRow = Math.max(0, Math.floor((h - bandPx) / cellH));
1969
+ for (let r = fromRow; r < rows; r++) bgRow[r] = hexLuma(bg.band.color);
1970
+ }
1971
+ }
1972
+ const cells = new Array(cols * rows);
1973
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells[r * cols + c] = bgRow[r];
1974
+ const compose = (idx, lum, a, blend) => {
1975
+ const cur = cells[idx];
1976
+ if (blend === "lighter" || blend === "screen") cells[idx] = Math.min(1, cur + lum * a);
1977
+ else if (blend === "multiply") cells[idx] = cur * (1 - a * (1 - lum));
1978
+ else cells[idx] = cur * (1 - a) + lum * a;
1979
+ };
1980
+ for (const { layer, entities } of scene.layers) {
1981
+ const lifeA = lifeAlphaAt(layer.life, t);
1982
+ if (lifeA <= 0) continue;
1983
+ for (const e of entities) {
1984
+ const a = Math.min(1, alphaAt(e, t) * lifeA);
1985
+ if (a <= 4e-3) continue;
1986
+ const lum = spriteLuma(layer, e);
1987
+ const p = posOf(scene, e, t);
1988
+ const sz = sizeAt(e, t);
1989
+ const s = layer.sprite;
1990
+ if (s.kind === "streak") {
1991
+ const heading = headingAt(e, t, w, h) ?? 0;
1992
+ const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
1993
+ const wgt = Math.min(1, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale / cellH);
1994
+ for (let i = 0; i <= steps; i++) {
1995
+ const k = i / steps;
1996
+ const x = p.x - Math.cos(heading) * sz * (1 - k);
1997
+ const y = p.y - Math.sin(heading) * sz * (1 - k);
1998
+ const c = Math.floor(x / cellW);
1999
+ const r = Math.floor(y / cellH);
2000
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
2001
+ compose(r * cols + c, lum, a * k * wgt, layer.blend);
2002
+ }
2003
+ continue;
2004
+ }
2005
+ let centerX = p.x;
2006
+ let centerY = p.y;
2007
+ let halfX = sz / 2;
2008
+ let halfY = s.kind === "rect" ? (e.size2 ?? sz) * (e.size > 0 ? sz / e.size : 1) / 2 : sz / 2;
2009
+ if (s.kind === "text") {
2010
+ const box = textBox(s, e, p);
2011
+ centerX = box.cx;
2012
+ centerY = box.cy;
2013
+ halfX = box.halfX;
2014
+ halfY = box.halfY;
2015
+ }
2016
+ const c0 = Math.max(0, Math.floor((centerX - halfX) / cellW));
2017
+ const c1 = Math.min(cols - 1, Math.floor((centerX + halfX) / cellW));
2018
+ const r0 = Math.max(0, Math.floor((centerY - halfY) / cellH));
2019
+ const r1 = Math.min(rows - 1, Math.floor((centerY + halfY) / cellH));
2020
+ const circular = s.kind === "circle" || s.kind === "ring";
2021
+ const soft = s.kind === "circle" && s.soft;
2022
+ const inkWeight = s.kind === "text" || s.kind === "emoji" ? 0.55 : 1;
2023
+ for (let r = r0; r <= r1; r++) {
2024
+ for (let c = c0; c <= c1; c++) {
2025
+ const dx = (c + 0.5) * cellW - centerX;
2026
+ const dy = (r + 0.5) * cellH - centerY;
2027
+ if (circular) {
2028
+ const d = Math.sqrt(dx * dx + dy * dy);
2029
+ if (d > halfX + Math.min(cellW, cellH) / 2) continue;
2030
+ if (s.kind === "ring") {
2031
+ const strokeW = (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale;
2032
+ if (d < halfX - strokeW) continue;
2033
+ }
2034
+ const wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
2035
+ compose(r * cols + c, lum, a * wgt, layer.blend);
2036
+ } else {
2037
+ compose(r * cols + c, lum, a * inkWeight, layer.blend);
2038
+ }
2039
+ }
2040
+ }
2041
+ }
2042
+ if (layer.links) {
2043
+ const positions = entities.map((e) => posOf(scene, e, t));
2044
+ const maxDistPx = layer.links.maxDist * scale;
2045
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
2046
+ const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
2047
+ const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
2048
+ const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
2049
+ const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la;
2050
+ for (const edge of edges) {
2051
+ const pi = positions[edge.i];
2052
+ const pj = positions[edge.j];
2053
+ const steps = Math.max(1, Math.ceil(edge.dist / Math.min(cellW, cellH)));
2054
+ for (let i = 0; i <= steps; i++) {
2055
+ const k = i / steps;
2056
+ const c = Math.floor((pi.x + (pj.x - pi.x) * k) / cellW);
2057
+ const r = Math.floor((pi.y + (pj.y - pi.y) * k) / cellH);
2058
+ if (c < 0 || c >= cols || r < 0 || r >= rows) continue;
2059
+ compose(r * cols + c, lum, wgt, layer.blend);
2060
+ }
2061
+ }
2062
+ }
2063
+ }
2064
+ let sum = 0;
2065
+ let covered = 0;
2066
+ let devSum = 0;
2067
+ let cxAcc = 0;
2068
+ let cyAcc = 0;
2069
+ const rowProfile = new Array(rows).fill(0);
2070
+ const colProfile = new Array(cols).fill(0);
2071
+ for (let r = 0; r < rows; r++) {
2072
+ for (let c = 0; c < cols; c++) {
2073
+ const v = cells[r * cols + c];
2074
+ sum += v;
2075
+ const dev = Math.abs(v - bgRow[r]);
2076
+ if (dev > 0.03) covered++;
2077
+ devSum += dev;
2078
+ cxAcc += dev * (c + 0.5);
2079
+ cyAcc += dev * (r + 0.5);
2080
+ rowProfile[r] += dev;
2081
+ colProfile[c] += dev;
2082
+ }
2083
+ }
2084
+ for (let r = 0; r < rows; r++) rowProfile[r] = rowProfile[r] / cols;
2085
+ for (let c = 0; c < cols; c++) colProfile[c] = colProfile[c] / rows;
2086
+ return {
2087
+ cols,
2088
+ rows,
2089
+ cells,
2090
+ background: bgRow,
2091
+ meanLuminance: sum / (cols * rows),
2092
+ coverage: covered / (cols * rows),
2093
+ centroid: devSum > 1e-6 ? { x: cxAcc / devSum / cols, y: cyAcc / devSum / rows } : null,
2094
+ rowProfile,
2095
+ colProfile
2096
+ };
2097
+ }
2098
+ var DITHER = [
2099
+ [0.5 / 8, 4.5 / 8],
2100
+ [2.5 / 8, 6.5 / 8],
2101
+ [1.5 / 8, 5.5 / 8],
2102
+ [3.5 / 8, 7.5 / 8]
2103
+ ];
2104
+ var DOT_BIT = [
2105
+ [1, 8],
2106
+ [2, 16],
2107
+ [4, 32],
2108
+ [64, 128]
2109
+ ];
2110
+ function renderBrailleMap(grid) {
2111
+ const sorted = [...grid.cells].sort((a, b) => a - b);
2112
+ const lo = sorted[0];
2113
+ const hi = Math.max(sorted[Math.floor(sorted.length * 0.98)], lo + 0.08);
2114
+ const expose = (v) => Math.pow(Math.max(0, Math.min(1, (v - lo) / (hi - lo))), 0.8);
2115
+ const outRows = [];
2116
+ for (let br = 0; br < Math.floor(grid.rows / 4); br++) {
2117
+ let line = "";
2118
+ for (let bc = 0; bc < Math.floor(grid.cols / 2); bc++) {
2119
+ let bits = 0;
2120
+ for (let dr = 0; dr < 4; dr++) {
2121
+ for (let dc = 0; dc < 2; dc++) {
2122
+ const v = expose(grid.cells[(br * 4 + dr) * grid.cols + (bc * 2 + dc)]);
2123
+ if (v > DITHER[dr][dc]) bits |= DOT_BIT[dr][dc];
2124
+ }
2125
+ }
2126
+ line += String.fromCharCode(10240 + bits);
2127
+ }
2128
+ outRows.push(line);
2129
+ }
2130
+ return outRows.join("\n");
2131
+ }
2132
+ function dominanceRanking(spec, opts = {}) {
2133
+ const scene = buildScene(spec, opts);
2134
+ const t = opts.t ?? 5e3;
2135
+ const { w, h, scale } = scene;
2136
+ const bgMean = (() => {
2137
+ const bg = spec.background;
2138
+ if (!bg || bg.type === "solid") return hexLuma(bg?.color ?? "#05050a");
2139
+ return bg.stops.reduce((s, st) => s + hexLuma(st.color), 0) / bg.stops.length;
2140
+ })();
2141
+ const raw = scene.layers.map(({ layer, entities }, layerIndex) => {
2142
+ const lifeA = lifeAlphaAt(layer.life, t);
2143
+ let area = 0;
2144
+ let lumAcc = 0;
2145
+ for (const e of entities) {
2146
+ const a = alphaAt(e, t) * lifeA;
2147
+ const sz = sizeAt(e, t);
2148
+ const s = layer.sprite;
2149
+ let entArea;
2150
+ if (s.kind === "circle") entArea = Math.PI * (sz / 2) * (sz / 2);
2151
+ else if (s.kind === "ring") entArea = Math.PI * sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
2152
+ else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
2153
+ else if (s.kind === "rect") {
2154
+ const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
2155
+ entArea = sz * h2;
2156
+ } else if (s.kind === "text") {
2157
+ const box = textBox(s, e, { x: 0, y: 0 });
2158
+ entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
2159
+ } else entArea = sz * sz * 0.55;
2160
+ area += entArea * a;
2161
+ lumAcc += spriteLuma(layer, e);
2162
+ }
2163
+ if (layer.links && entities.length > 1) {
2164
+ const positions = entities.map((e) => posOf(scene, e, t));
2165
+ const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
2166
+ const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
2167
+ const la = (layer.links.alpha ?? 0.6) * lifeA;
2168
+ const lw = (layer.links.width ?? 1) * scale;
2169
+ for (const edge of edges) area += edge.dist * lw * la;
2170
+ }
2171
+ const meanLuma = entities.length ? lumAcc / entities.length : 0;
2172
+ const contrast = Math.abs(meanLuma - bgMean) + 0.1;
2173
+ const blendBoost = layer.blend === "lighter" || layer.blend === "screen" ? 1.3 : layer.blend === "multiply" ? 1.1 : 1;
2174
+ const motionBoost = layer.motion.type === "static" ? 1 : 1.2;
2175
+ const weight = area / (w * h) * contrast * blendBoost * motionBoost;
2176
+ return { layerIndex, key: layer.key, weight, entityCount: entities.length, meanLuma, factors: { area: area / (w * h), contrast, blendBoost, motionBoost } };
2177
+ });
2178
+ const total = raw.reduce((s, r) => s + r.weight, 0) || 1;
2179
+ return raw.slice().sort((a, b) => b.weight - a.weight).map((r, i) => ({
2180
+ rank: i + 1,
2181
+ layerIndex: r.layerIndex,
2182
+ key: r.key,
2183
+ share: r.weight / total,
2184
+ entityCount: r.entityCount,
2185
+ meanLuma: r.meanLuma,
2186
+ factors: r.factors
2187
+ }));
2188
+ }
2189
+ function motionStats(spec, opts = {}) {
2190
+ const scene = buildScene(spec, opts);
2191
+ const t = opts.t ?? 5e3;
2192
+ const dt = opts.dt ?? 500;
2193
+ const { w, h } = scene;
2194
+ return scene.layers.map(({ layer, entities }, layerIndex) => {
2195
+ let acc = 0;
2196
+ let max = 0;
2197
+ let n = 0;
2198
+ for (const e of entities) {
2199
+ const p0 = posOf(scene, e, t);
2200
+ const p1 = posOf(scene, e, t + dt);
2201
+ const dx = p1.x - p0.x;
2202
+ const dy = p1.y - p0.y;
2203
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) continue;
2204
+ const speed = Math.sqrt(dx * dx + dy * dy) / dt * 1e3;
2205
+ acc += speed;
2206
+ max = Math.max(max, speed);
2207
+ n++;
2208
+ }
2209
+ const meanSpeed = n ? acc / n : 0;
2210
+ return { layerIndex, key: layer.key, meanSpeed, maxSpeed: max, moving: meanSpeed > 0.5 };
2211
+ });
2212
+ }
2213
+ var REGION_NAMES = [
2214
+ "top-left",
2215
+ "top-center",
2216
+ "top-right",
2217
+ "mid-left",
2218
+ "center",
2219
+ "mid-right",
2220
+ "bottom-left",
2221
+ "bottom-center",
2222
+ "bottom-right"
2223
+ ];
2224
+ function regionMeans(grid) {
2225
+ const out = new Array(9).fill(0);
2226
+ const cnt = new Array(9).fill(0);
2227
+ for (let r = 0; r < grid.rows; r++) {
2228
+ for (let c = 0; c < grid.cols; c++) {
2229
+ const region = Math.min(2, Math.floor(r / grid.rows * 3)) * 3 + Math.min(2, Math.floor(c / grid.cols * 3));
2230
+ out[region] += Math.abs(grid.cells[r * grid.cols + c] - grid.background[r]);
2231
+ cnt[region]++;
2232
+ }
2233
+ }
2234
+ return out.map((v, i) => cnt[i] ? v / cnt[i] : 0);
2235
+ }
2236
+ function diffScenes(a, b, opts = {}) {
2237
+ const t = opts.t ?? 5e3;
2238
+ const gridA = luminanceGrid(a, opts);
2239
+ const gridB = luminanceGrid(b, opts);
2240
+ const regsA = regionMeans(gridA);
2241
+ const regsB = regionMeans(gridB);
2242
+ const domA = dominanceRanking(a, opts);
2243
+ const domB = dominanceRanking(b, opts);
2244
+ const nameOf = (d) => d.key ?? `layer-${d.layerIndex}`;
2245
+ const names = /* @__PURE__ */ new Set([...domA.map(nameOf), ...domB.map(nameOf)]);
2246
+ const dominance = [...names].map((key) => {
2247
+ const inA = domA.find((d) => nameOf(d) === key);
2248
+ const inB = domB.find((d) => nameOf(d) === key);
2249
+ return {
2250
+ key,
2251
+ rankA: inA?.rank ?? null,
2252
+ rankB: inB?.rank ?? null,
2253
+ shareA: inA?.share ?? 0,
2254
+ shareB: inB?.share ?? 0
2255
+ };
2256
+ }).sort((x, y) => Math.abs(y.shareB - y.shareA) - Math.abs(x.shareB - x.shareA));
2257
+ const codesA = new Set(adviseSpec(a).map((w) => w.code));
2258
+ const codesB = new Set(adviseSpec(b).map((w) => w.code));
2259
+ return {
2260
+ t,
2261
+ coverage: { a: gridA.coverage, b: gridB.coverage, delta: gridB.coverage - gridA.coverage },
2262
+ meanLuminance: { a: gridA.meanLuminance, b: gridB.meanLuminance, delta: gridB.meanLuminance - gridA.meanLuminance },
2263
+ balanceShift: gridA.centroid && gridB.centroid ? { dx: gridB.centroid.x - gridA.centroid.x, dy: gridB.centroid.y - gridA.centroid.y } : null,
2264
+ regions: REGION_NAMES.map((region, i) => ({ region, a: regsA[i], b: regsB[i], delta: regsB[i] - regsA[i] })),
2265
+ dominance,
2266
+ advisories: {
2267
+ added: [...codesB].filter((c) => !codesA.has(c)),
2268
+ removed: [...codesA].filter((c) => !codesB.has(c))
2269
+ }
2270
+ };
2271
+ }
2272
+ function perceiveScene(spec, opts = {}) {
2273
+ const grid = luminanceGrid(spec, opts);
2274
+ return {
2275
+ t: opts.t ?? 5e3,
2276
+ braille: renderBrailleMap(grid),
2277
+ coverage: grid.coverage,
2278
+ meanLuminance: grid.meanLuminance,
2279
+ centroid: grid.centroid,
2280
+ rowProfile: grid.rowProfile,
2281
+ colProfile: grid.colProfile,
2282
+ dominance: dominanceRanking(spec, opts),
2283
+ motion: motionStats(spec, opts),
2284
+ advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 })
2285
+ };
2286
+ }
2287
+
1262
2288
  // src/examples/aquarium.ts
1263
2289
  var AQUARIUM_SPEC = {
1264
2290
  schemaVersion: 1,
@@ -1273,20 +2299,95 @@ var AQUARIUM_SPEC = {
1273
2299
  { at: 0.6, color: "#062534" },
1274
2300
  { at: 1, color: "#02141d" }
1275
2301
  ],
1276
- band: { color: "#0b1a12", height: 24 }
2302
+ band: { color: "#0b1a12", height: 0.02222 }
1277
2303
  },
1278
2304
  layers: [
1279
2305
  {
1280
2306
  count: 14,
1281
2307
  sprite: { kind: "emoji", glyphs: ["\u{1F41F}", "\u{1F420}", "\u{1F421}", "\u{1F988}", "\u{1F419}"] },
1282
- size: [34, 72],
1283
- motion: { type: "drift", speed: [30, 110], bidirectional: true, bob: 6 },
2308
+ size: [0.03148, 0.06667],
2309
+ motion: { type: "drift", speed: [0.02778, 0.1019], bidirectional: true, bob: 5556e-6 },
1284
2310
  flip: true
1285
2311
  },
1286
2312
  {
1287
2313
  count: 22,
1288
- sprite: { kind: "circle", radius: [2, 7], color: "#c8ebff" },
1289
- motion: { type: "rise", speed: [24, 78], sway: 8 }
2314
+ sprite: { kind: "circle", radius: [1852e-6, 6481e-6], color: "#c8ebff" },
2315
+ motion: { type: "rise", speed: [0.02222, 0.07222], sway: 7407e-6 }
2316
+ }
2317
+ ]
2318
+ };
2319
+
2320
+ // src/examples/aurora.ts
2321
+ var AURORA_SPEC = {
2322
+ schemaVersion: 1,
2323
+ id: "aurora",
2324
+ label: "Aurora",
2325
+ seed: 66,
2326
+ motionIntensity: "calm",
2327
+ ghosting: 0.85,
2328
+ background: {
2329
+ type: "gradient",
2330
+ stops: [
2331
+ { at: 0, color: "#02030d" },
2332
+ { at: 0.65, color: "#050a18" },
2333
+ { at: 1, color: "#0a1220" }
2334
+ ],
2335
+ drift: { period: 4e4, amount: 0.1 }
2336
+ },
2337
+ layers: [
2338
+ {
2339
+ key: "stars",
2340
+ count: 110,
2341
+ sprite: { kind: "circle", radius: [5e-4, 15e-4], color: "#e8ecff", soft: true },
2342
+ motion: { type: "static" },
2343
+ region: { y: [0, 0.75] },
2344
+ alpha: [0.2, 0.6],
2345
+ // A slow shimmer travels across the sky instead of stars twinkling independently.
2346
+ pulse: { amp: 0.25, period: 6e3, wave: { wavelength: 0.5, angle: 15 } }
2347
+ },
2348
+ {
2349
+ key: "curtain-green",
2350
+ count: 100,
2351
+ sprite: {
2352
+ kind: "circle",
2353
+ radius: [0.055, 0.11],
2354
+ color: "#1fd68a",
2355
+ soft: true,
2356
+ colors: ["#1fd68a", "#2ee6c8", "#4bd1ff"],
2357
+ colorWeights: [5, 2, 1]
2358
+ },
2359
+ motion: { type: "wander", speed: [4e-3, 0.012], angle: 0, meander: 0.13, coherence: 0.7 },
2360
+ region: { y: [0.1, 0.42] },
2361
+ alpha: [0.04, 0.1],
2362
+ blend: "lighter",
2363
+ grow: { amp: 0.25, period: 9e3 }
2364
+ },
2365
+ {
2366
+ key: "curtain-violet",
2367
+ count: 60,
2368
+ sprite: {
2369
+ kind: "circle",
2370
+ radius: [0.045, 0.09],
2371
+ color: "#8d5bff",
2372
+ soft: true,
2373
+ colors: ["#8d5bff", "#d14bff", "#ff4bd8"],
2374
+ colorWeights: [4, 1.5, 0.5]
2375
+ },
2376
+ motion: { type: "wander", speed: [3e-3, 0.01], angle: 180, meander: 0.1, coherence: 0.55 },
2377
+ region: { y: [0.04, 0.28] },
2378
+ alpha: [0.03, 0.08],
2379
+ blend: "lighter",
2380
+ grow: { amp: 0.3, period: 12e3 }
2381
+ },
2382
+ {
2383
+ key: "fireflies",
2384
+ count: 14,
2385
+ sprite: { kind: "circle", radius: [1e-3, 3e-3], color: "#b8ffe1", soft: true },
2386
+ motion: { type: "wander", speed: [6e-3, 0.02], meander: 0.06 },
2387
+ region: { y: [0.55, 0.95] },
2388
+ alpha: [0.2, 0.55],
2389
+ pulse: { amp: 0.3, period: 3500 },
2390
+ blend: "lighter"
1290
2391
  }
1291
2392
  ]
1292
2393
  };
@@ -1311,7 +2412,7 @@ var COMETS_SPEC = {
1311
2412
  {
1312
2413
  key: "stars",
1313
2414
  count: 120,
1314
- sprite: { kind: "circle", radius: [0.5, 2], color: "#ffffff", soft: true },
2415
+ sprite: { kind: "circle", radius: [463e-6, 1852e-6], color: "#ffffff", soft: true },
1315
2416
  motion: { type: "static" },
1316
2417
  alpha: [0.3, 0.7],
1317
2418
  pulse: { amp: 0.15, period: 3e3 }
@@ -1319,8 +2420,8 @@ var COMETS_SPEC = {
1319
2420
  {
1320
2421
  key: "comets",
1321
2422
  count: 8,
1322
- sprite: { kind: "circle", radius: [3, 8], color: "#88ccff", soft: true },
1323
- motion: { type: "drift", speed: [80, 200], angle: 225 },
2423
+ sprite: { kind: "circle", radius: [2778e-6, 7407e-6], color: "#88ccff", soft: true },
2424
+ motion: { type: "drift", speed: [0.07407, 0.1852], angle: 225 },
1324
2425
  alpha: [0.7, 1],
1325
2426
  blend: "lighter",
1326
2427
  trail: { length: 1500, fade: 1 }
@@ -1328,8 +2429,8 @@ var COMETS_SPEC = {
1328
2429
  {
1329
2430
  key: "fireflies",
1330
2431
  count: 20,
1331
- sprite: { kind: "circle", radius: [2, 5], color: "#ffdd44", soft: true },
1332
- motion: { type: "drift", speed: [10, 30], bidirectional: true, bob: 20 },
2432
+ sprite: { kind: "circle", radius: [1852e-6, 463e-5], color: "#ffdd44", soft: true },
2433
+ motion: { type: "drift", speed: [9259e-6, 0.02778], bidirectional: true, bob: 0.01852 },
1333
2434
  alpha: [0.4, 0.8],
1334
2435
  pulse: { amp: 0.3, period: 2e3 },
1335
2436
  blend: "lighter",
@@ -1357,7 +2458,7 @@ var CONSTELLATION_SPEC = {
1357
2458
  // Distant static stars (no links, just atmosphere)
1358
2459
  {
1359
2460
  count: 100,
1360
- sprite: { kind: "circle", radius: [1, 3], color: "#667799", colors: ["#8899cc", "#99aadd", "#aabbee", "#7788aa"] },
2461
+ sprite: { kind: "circle", radius: [9259e-7, 2778e-6], color: "#667799", colors: ["#8899cc", "#99aadd", "#aabbee", "#7788aa"] },
1361
2462
  alpha: [0.2, 0.7],
1362
2463
  pulse: { amp: 0.2, period: 3e3 },
1363
2464
  motion: { type: "static" }
@@ -1366,19 +2467,19 @@ var CONSTELLATION_SPEC = {
1366
2467
  {
1367
2468
  key: "nodes",
1368
2469
  count: 40,
1369
- sprite: { kind: "circle", radius: [2, 5], color: "#88aaff", colors: ["#88aaff", "#aaccff", "#ffcc88", "#ff8866"], soft: true },
2470
+ sprite: { kind: "circle", radius: [1852e-6, 463e-5], color: "#88aaff", colors: ["#88aaff", "#aaccff", "#ffcc88", "#ff8866"], soft: true },
1370
2471
  alpha: [0.5, 0.9],
1371
2472
  blend: "lighter",
1372
- motion: { type: "drift", speed: [2, 8], bidirectional: true, bob: 3 },
1373
- links: { k: 3, maxDist: 200, alpha: 0.15, width: 0.5 }
2473
+ motion: { type: "drift", speed: [1852e-6, 7407e-6], bidirectional: true, bob: 2778e-6 },
2474
+ links: { k: 3, maxDist: 0.1852, alpha: 0.15, width: 463e-6 }
1374
2475
  },
1375
2476
  // Cycling asterism markers
1376
2477
  {
1377
2478
  count: 6,
1378
2479
  sprite: { kind: "emoji", glyphs: ["\u2726", "\u2727", "\u22C6", "\u2217"], cycle: { period: 2e3 } },
1379
- size: [14, 22],
2480
+ size: [0.01296, 0.02037],
1380
2481
  alpha: [0.4, 0.7],
1381
- motion: { type: "drift", speed: [1, 4], bidirectional: true }
2482
+ motion: { type: "drift", speed: [9259e-7, 3704e-6], bidirectional: true }
1382
2483
  }
1383
2484
  ]
1384
2485
  };
@@ -1387,7 +2488,7 @@ var CONSTELLATION_SPEC = {
1387
2488
  var DASHBOARD_SPEC = {
1388
2489
  schemaVersion: 1,
1389
2490
  id: "dev-dashboard",
1390
- label: "Dev Dashboard",
2491
+ label: "Control Center",
1391
2492
  seed: 256,
1392
2493
  motionIntensity: "calm",
1393
2494
  background: {
@@ -1401,473 +2502,490 @@ var DASHBOARD_SPEC = {
1401
2502
  layers: [
1402
2503
  // --- ambient depth ---
1403
2504
  {
1404
- count: 50,
1405
- sprite: { kind: "circle", radius: [0.2, 0.8], color: "#1a2538" },
2505
+ key: "dust",
2506
+ count: 40,
2507
+ sprite: { kind: "circle", radius: [1852e-7, 7407e-7], color: "#1a2538" },
1406
2508
  alpha: [0.1, 0.25],
1407
- motion: { type: "drift", speed: [0.3, 1.2], bob: 0.3 }
2509
+ motion: { type: "drift", speed: [2778e-7, 1111e-6], bob: 2778e-7 }
1408
2510
  },
1409
2511
  {
1410
- count: 12,
1411
- sprite: { kind: "circle", radius: [1, 2.5], color: "#1a3050", soft: true },
1412
- alpha: [0.08, 0.18],
1413
- blend: "lighter",
1414
- pulse: { amp: 0.08, period: 4e3 },
1415
- motion: { type: "drift", speed: [0.5, 1.5], bob: 0.5 }
2512
+ key: "led-wall",
2513
+ count: 140,
2514
+ sprite: { kind: "circle", radius: [8e-4, 12e-4], color: "#274058" },
2515
+ alpha: [0.05, 0.11],
2516
+ layout: { type: "grid", columns: 20, jitter: 0.15 },
2517
+ // A slow breath travels diagonally across the wall of dots.
2518
+ pulse: { amp: 0.3, period: 5e3, wave: { wavelength: 0.35, angle: 20 } },
2519
+ motion: { type: "static" }
1416
2520
  },
1417
- // --- header ---
2521
+ // --- header (boots first) ---
1418
2522
  {
1419
2523
  key: "title",
1420
2524
  count: 1,
1421
- position: { x: 0.5, y: 0.04 },
2525
+ position: { x: 0.5, y: 0.045 },
1422
2526
  sprite: {
1423
2527
  kind: "text",
1424
- strings: ["DEV PULSE"],
2528
+ strings: ["CONTROL CENTER"],
1425
2529
  color: "#556677",
1426
2530
  font: "600 13px 'SF Mono', monospace",
1427
2531
  align: "center"
1428
2532
  },
1429
- motion: { type: "static" }
2533
+ motion: { type: "static" },
2534
+ life: { enter: 200, fade: 600 }
1430
2535
  },
1431
2536
  {
1432
2537
  key: "status",
1433
2538
  count: 1,
1434
- position: { x: 0.92, y: 0.04 },
2539
+ position: { x: 0.94, y: 0.045 },
1435
2540
  sprite: {
1436
2541
  kind: "text",
1437
- strings: ["\u25CF ACTIVE"],
2542
+ strings: ["\u25CF ALL SYSTEMS GO"],
1438
2543
  color: "#44cc66",
1439
2544
  font: "bold 10px monospace",
1440
2545
  align: "right"
1441
2546
  },
1442
- motion: { type: "static" }
2547
+ pulse: { amp: 0.2, period: 2400 },
2548
+ motion: { type: "static" },
2549
+ life: { enter: 200, fade: 600 }
1443
2550
  },
1444
2551
  {
1445
- key: "uptime",
2552
+ key: "clock",
1446
2553
  count: 1,
1447
- position: { x: 0.08, y: 0.04 },
2554
+ position: { x: 0.06, y: 0.045 },
1448
2555
  sprite: {
1449
2556
  kind: "text",
1450
- strings: ["up 2h 14m"],
1451
- color: "#445566",
1452
- font: "10px monospace",
2557
+ strings: ["21:47"],
2558
+ color: "#8899aa",
2559
+ font: "bold 12px monospace",
1453
2560
  align: "left"
1454
2561
  },
1455
- motion: { type: "static" }
2562
+ motion: { type: "static" },
2563
+ life: { enter: 200, fade: 600 }
1456
2564
  },
1457
- // --- left column: metrics ---
2565
+ // --- left column: readings (boots second) ---
1458
2566
  {
1459
- key: "tools-label",
2567
+ key: "power-label",
1460
2568
  count: 1,
1461
- position: { x: 0.06, y: 0.12 },
2569
+ position: { x: 0.06, y: 0.14 },
1462
2570
  sprite: {
1463
2571
  kind: "text",
1464
- strings: ["TOOL CALLS"],
2572
+ strings: ["POWER"],
1465
2573
  color: "#445566",
1466
2574
  font: "9px monospace",
1467
2575
  align: "left",
1468
2576
  baseline: "top"
1469
2577
  },
1470
- motion: { type: "static" }
2578
+ motion: { type: "static" },
2579
+ life: { enter: 700, fade: 600 }
1471
2580
  },
1472
2581
  {
1473
- key: "tools-value",
2582
+ key: "power-value",
1474
2583
  count: 1,
1475
- position: { x: 0.06, y: 0.16 },
2584
+ position: { x: 0.06, y: 0.18 },
1476
2585
  sprite: {
1477
2586
  kind: "text",
1478
- strings: ["147"],
1479
- color: "#44aaff",
1480
- font: "bold 28px monospace",
2587
+ strings: ["3.2 kW"],
2588
+ color: "#ffd166",
2589
+ font: "bold 26px monospace",
1481
2590
  align: "left",
1482
2591
  baseline: "top"
1483
2592
  },
1484
- motion: { type: "static" }
2593
+ motion: { type: "static" },
2594
+ life: { enter: 700, fade: 600 }
1485
2595
  },
1486
2596
  {
1487
- key: "reads-label",
2597
+ key: "network-label",
1488
2598
  count: 1,
1489
- position: { x: 0.06, y: 0.26 },
2599
+ position: { x: 0.06, y: 0.28 },
1490
2600
  sprite: {
1491
2601
  kind: "text",
1492
- strings: ["READS"],
2602
+ strings: ["NETWORK"],
1493
2603
  color: "#445566",
1494
2604
  font: "9px monospace",
1495
2605
  align: "left",
1496
2606
  baseline: "top"
1497
2607
  },
1498
- motion: { type: "static" }
2608
+ motion: { type: "static" },
2609
+ life: { enter: 700, fade: 600 }
1499
2610
  },
1500
2611
  {
1501
- key: "reads-value",
2612
+ key: "network-value",
1502
2613
  count: 1,
1503
- position: { x: 0.06, y: 0.3 },
2614
+ position: { x: 0.06, y: 0.32 },
1504
2615
  sprite: {
1505
2616
  kind: "text",
1506
- strings: ["89"],
1507
- color: "#44ddcc",
1508
- font: "bold 28px monospace",
2617
+ strings: ["940 Mb/s"],
2618
+ color: "#44aaff",
2619
+ font: "bold 26px monospace",
1509
2620
  align: "left",
1510
2621
  baseline: "top"
1511
2622
  },
1512
- motion: { type: "static" }
2623
+ motion: { type: "static" },
2624
+ life: { enter: 700, fade: 600 }
1513
2625
  },
1514
2626
  {
1515
- key: "edits-label",
2627
+ key: "climate-label",
1516
2628
  count: 1,
1517
- position: { x: 0.06, y: 0.4 },
2629
+ position: { x: 0.06, y: 0.42 },
1518
2630
  sprite: {
1519
2631
  kind: "text",
1520
- strings: ["EDITS"],
2632
+ strings: ["CLIMATE"],
1521
2633
  color: "#445566",
1522
2634
  font: "9px monospace",
1523
2635
  align: "left",
1524
2636
  baseline: "top"
1525
2637
  },
1526
- motion: { type: "static" }
2638
+ motion: { type: "static" },
2639
+ life: { enter: 700, fade: 600 }
1527
2640
  },
1528
2641
  {
1529
- key: "edits-value",
2642
+ key: "climate-value",
1530
2643
  count: 1,
1531
- position: { x: 0.06, y: 0.44 },
2644
+ position: { x: 0.06, y: 0.46 },
1532
2645
  sprite: {
1533
2646
  kind: "text",
1534
- strings: ["34"],
1535
- color: "#ff8844",
1536
- font: "bold 28px monospace",
2647
+ strings: ["21.5\xB0"],
2648
+ color: "#44ddcc",
2649
+ font: "bold 26px monospace",
1537
2650
  align: "left",
1538
2651
  baseline: "top"
1539
2652
  },
1540
- motion: { type: "static" }
2653
+ motion: { type: "static" },
2654
+ life: { enter: 700, fade: 600 }
1541
2655
  },
1542
2656
  {
1543
- key: "errors-label",
2657
+ key: "alerts-label",
1544
2658
  count: 1,
1545
- position: { x: 0.06, y: 0.54 },
2659
+ position: { x: 0.06, y: 0.56 },
1546
2660
  sprite: {
1547
2661
  kind: "text",
1548
- strings: ["ERRORS"],
2662
+ strings: ["ALERTS"],
1549
2663
  color: "#445566",
1550
2664
  font: "9px monospace",
1551
2665
  align: "left",
1552
2666
  baseline: "top"
1553
2667
  },
1554
- motion: { type: "static" }
2668
+ motion: { type: "static" },
2669
+ life: { enter: 700, fade: 600 }
1555
2670
  },
1556
2671
  {
1557
- key: "errors-value",
2672
+ key: "alerts-value",
1558
2673
  count: 1,
1559
- position: { x: 0.06, y: 0.58 },
2674
+ position: { x: 0.06, y: 0.6 },
1560
2675
  sprite: {
1561
2676
  kind: "text",
1562
- strings: ["2"],
1563
- color: "#ff4455",
1564
- font: "bold 28px monospace",
2677
+ strings: ["0"],
2678
+ color: "#44cc66",
2679
+ font: "bold 26px monospace",
1565
2680
  align: "left",
1566
2681
  baseline: "top"
1567
2682
  },
1568
- motion: { type: "static" }
2683
+ motion: { type: "static" },
2684
+ life: { enter: 700, fade: 600 }
1569
2685
  },
1570
- // --- center column: system gauges ---
2686
+ // --- center: energy gauge (boots third) ---
1571
2687
  {
1572
- key: "cpu-label",
2688
+ key: "gauge-ring",
1573
2689
  count: 1,
1574
- position: { x: 0.38, y: 0.12 },
1575
- sprite: {
1576
- kind: "text",
1577
- strings: ["CPU"],
1578
- color: "#445566",
1579
- font: "9px monospace",
1580
- align: "center"
1581
- },
1582
- motion: { type: "static" }
2690
+ position: { x: 0.4, y: 0.36 },
2691
+ sprite: { kind: "ring", radius: [0.105, 0.105], color: "#1e3a52", width: 28e-4 },
2692
+ alpha: [0.85, 0.85],
2693
+ motion: { type: "static" },
2694
+ life: { enter: 1200, fade: 700 }
1583
2695
  },
1584
2696
  {
1585
- key: "cpu-value",
2697
+ key: "gauge-glow",
1586
2698
  count: 1,
1587
- position: { x: 0.38, y: 0.22 },
1588
- sprite: {
1589
- kind: "text",
1590
- strings: ["73%"],
1591
- color: "#44ff88",
1592
- font: "bold 36px monospace",
1593
- align: "center"
1594
- },
1595
- motion: { type: "static" }
2699
+ position: { x: 0.4, y: 0.36 },
2700
+ sprite: { kind: "ring", radius: [0.088, 0.088], color: "#2ee6c8", width: 16e-4 },
2701
+ alpha: [0.55, 0.55],
2702
+ blend: "lighter",
2703
+ pulse: { amp: 0.25, period: 4e3 },
2704
+ grow: { amp: 0.05, period: 4e3 },
2705
+ motion: { type: "static" },
2706
+ life: { enter: 1200, fade: 700 }
1596
2707
  },
1597
2708
  {
1598
- key: "mem-label",
2709
+ key: "gauge-label",
1599
2710
  count: 1,
1600
- position: { x: 0.38, y: 0.36 },
2711
+ position: { x: 0.4, y: 0.29 },
1601
2712
  sprite: {
1602
2713
  kind: "text",
1603
- strings: ["MEMORY"],
2714
+ strings: ["ENERGY"],
1604
2715
  color: "#445566",
1605
2716
  font: "9px monospace",
1606
2717
  align: "center"
1607
2718
  },
1608
- motion: { type: "static" }
1609
- },
1610
- {
1611
- key: "mem-value",
1612
- count: 1,
1613
- position: { x: 0.38, y: 0.46 },
1614
- sprite: {
1615
- kind: "text",
1616
- strings: ["4.2 GB"],
1617
- color: "#ddcc44",
1618
- font: "bold 36px monospace",
1619
- align: "center"
1620
- },
1621
- motion: { type: "static" }
2719
+ motion: { type: "static" },
2720
+ life: { enter: 1200, fade: 700 }
1622
2721
  },
1623
2722
  {
1624
- key: "tokens-label",
2723
+ key: "gauge-value",
1625
2724
  count: 1,
1626
- position: { x: 0.38, y: 0.58 },
2725
+ position: { x: 0.4, y: 0.375 },
1627
2726
  sprite: {
1628
2727
  kind: "text",
1629
- strings: ["TOKENS"],
1630
- color: "#445566",
1631
- font: "9px monospace",
2728
+ strings: ["73%"],
2729
+ color: "#2ee6c8",
2730
+ font: "bold 34px monospace",
1632
2731
  align: "center"
1633
2732
  },
1634
- motion: { type: "static" }
2733
+ motion: { type: "static" },
2734
+ life: { enter: 1200, fade: 700 }
1635
2735
  },
1636
2736
  {
1637
- key: "tokens-value",
1638
- count: 1,
1639
- position: { x: 0.38, y: 0.66 },
2737
+ key: "equalizer",
2738
+ count: 22,
1640
2739
  sprite: {
1641
- kind: "text",
1642
- strings: ["128k"],
1643
- color: "#aa88ff",
1644
- font: "bold 28px monospace",
1645
- align: "center"
2740
+ kind: "rect",
2741
+ width: [5e-3, 5e-3],
2742
+ aspect: [0.6, 3.4],
2743
+ color: "#2ee6c8",
2744
+ colors: ["#2ee6c8", "#44aaff", "#aa88ff"],
2745
+ colorWeights: [3, 2, 1]
1646
2746
  },
1647
- motion: { type: "static" }
2747
+ alpha: [0.35, 0.7],
2748
+ layout: { type: "grid", columns: 22 },
2749
+ region: { x: [0.28, 0.52], y: [0.585, 0.615] },
2750
+ // Wave-phased pulse sweeps along the bars — reads as a live meter.
2751
+ pulse: { amp: 0.4, period: 1600, wave: { wavelength: 0.1, angle: 0 } },
2752
+ motion: { type: "static" },
2753
+ life: { enter: 1200, fade: 700 }
1648
2754
  },
1649
- // --- right column: recent files ---
2755
+ // --- right column: agenda + devices (boots fourth) ---
1650
2756
  {
1651
- key: "files-label",
2757
+ key: "agenda-label",
1652
2758
  count: 1,
1653
- position: { x: 0.62, y: 0.12 },
2759
+ position: { x: 0.64, y: 0.14 },
1654
2760
  sprite: {
1655
2761
  kind: "text",
1656
- strings: ["RECENT FILES"],
2762
+ strings: ["AGENDA"],
1657
2763
  color: "#445566",
1658
2764
  font: "9px monospace",
1659
2765
  align: "left",
1660
2766
  baseline: "top"
1661
2767
  },
1662
- motion: { type: "static" }
2768
+ motion: { type: "static" },
2769
+ life: { enter: 1700, fade: 600 }
1663
2770
  },
1664
2771
  {
1665
- key: "file-1",
2772
+ key: "agenda-1",
1666
2773
  count: 1,
1667
- position: { x: 0.62, y: 0.17 },
2774
+ position: { x: 0.64, y: 0.19 },
1668
2775
  sprite: {
1669
2776
  kind: "text",
1670
- strings: ["src/worker.ts"],
2777
+ strings: ["09:00 Standup"],
1671
2778
  color: "#8899aa",
1672
2779
  font: "11px monospace",
1673
2780
  align: "left",
1674
2781
  baseline: "top",
1675
- maxWidth: 280
2782
+ maxWidth: 0.2593
1676
2783
  },
1677
- motion: { type: "static" }
2784
+ motion: { type: "static" },
2785
+ life: { enter: 1700, fade: 600 }
1678
2786
  },
1679
2787
  {
1680
- key: "file-2",
2788
+ key: "agenda-2",
1681
2789
  count: 1,
1682
- position: { x: 0.62, y: 0.21 },
2790
+ position: { x: 0.64, y: 0.23 },
1683
2791
  sprite: {
1684
2792
  kind: "text",
1685
- strings: ["site/src/App.tsx"],
2793
+ strings: ["12:30 Lunch w/ Kai"],
1686
2794
  color: "#778899",
1687
2795
  font: "11px monospace",
1688
2796
  align: "left",
1689
2797
  baseline: "top",
1690
- maxWidth: 280
2798
+ maxWidth: 0.2593
1691
2799
  },
1692
- motion: { type: "static" }
2800
+ motion: { type: "static" },
2801
+ life: { enter: 1700, fade: 600 }
1693
2802
  },
1694
2803
  {
1695
- key: "file-3",
2804
+ key: "agenda-3",
1696
2805
  count: 1,
1697
- position: { x: 0.62, y: 0.25 },
2806
+ position: { x: 0.64, y: 0.27 },
1698
2807
  sprite: {
1699
2808
  kind: "text",
1700
- strings: ["packages/schema/compile.ts"],
2809
+ strings: ["18:00 Gym"],
1701
2810
  color: "#667788",
1702
2811
  font: "11px monospace",
1703
2812
  align: "left",
1704
2813
  baseline: "top",
1705
- maxWidth: 280
2814
+ maxWidth: 0.2593
1706
2815
  },
1707
- motion: { type: "static" }
2816
+ motion: { type: "static" },
2817
+ life: { enter: 1700, fade: 600 }
1708
2818
  },
1709
2819
  {
1710
- key: "file-4",
2820
+ key: "devices-label",
1711
2821
  count: 1,
1712
- position: { x: 0.62, y: 0.29 },
2822
+ position: { x: 0.64, y: 0.36 },
1713
2823
  sprite: {
1714
2824
  kind: "text",
1715
- strings: ["docs/dashboard-spec.md"],
1716
- color: "#556677",
1717
- font: "11px monospace",
2825
+ strings: ["DEVICES"],
2826
+ color: "#445566",
2827
+ font: "9px monospace",
1718
2828
  align: "left",
1719
- baseline: "top",
1720
- maxWidth: 280
2829
+ baseline: "top"
1721
2830
  },
1722
- motion: { type: "static" }
2831
+ motion: { type: "static" },
2832
+ life: { enter: 1700, fade: 600 }
1723
2833
  },
1724
2834
  {
1725
- key: "file-5",
2835
+ key: "devices-1",
1726
2836
  count: 1,
1727
- position: { x: 0.62, y: 0.33 },
2837
+ position: { x: 0.64, y: 0.41 },
1728
2838
  sprite: {
1729
2839
  kind: "text",
1730
- strings: ["packages/core/types.ts"],
1731
- color: "#445566",
2840
+ strings: ["Lights 6 on"],
2841
+ color: "#8899aa",
1732
2842
  font: "11px monospace",
1733
2843
  align: "left",
1734
- baseline: "top",
1735
- maxWidth: 280
2844
+ baseline: "top"
1736
2845
  },
1737
- motion: { type: "static" }
2846
+ motion: { type: "static" },
2847
+ life: { enter: 1700, fade: 600 }
1738
2848
  },
1739
- // --- right column: session info ---
1740
2849
  {
1741
- key: "session-label",
2850
+ key: "devices-2",
1742
2851
  count: 1,
1743
- position: { x: 0.62, y: 0.42 },
2852
+ position: { x: 0.64, y: 0.45 },
1744
2853
  sprite: {
1745
2854
  kind: "text",
1746
- strings: ["SESSION"],
1747
- color: "#445566",
1748
- font: "9px monospace",
2855
+ strings: ["Thermostat eco"],
2856
+ color: "#778899",
2857
+ font: "11px monospace",
1749
2858
  align: "left",
1750
2859
  baseline: "top"
1751
2860
  },
1752
- motion: { type: "static" }
2861
+ motion: { type: "static" },
2862
+ life: { enter: 1700, fade: 600 }
1753
2863
  },
1754
2864
  {
1755
- key: "session-model",
2865
+ key: "devices-3",
1756
2866
  count: 1,
1757
- position: { x: 0.62, y: 0.47 },
2867
+ position: { x: 0.64, y: 0.49 },
1758
2868
  sprite: {
1759
2869
  kind: "text",
1760
- strings: ["claude-opus-4-6"],
1761
- color: "#8888aa",
2870
+ strings: ["Locks secured"],
2871
+ color: "#667788",
1762
2872
  font: "11px monospace",
1763
2873
  align: "left",
1764
2874
  baseline: "top"
1765
2875
  },
1766
- motion: { type: "static" }
2876
+ motion: { type: "static" },
2877
+ life: { enter: 1700, fade: 600 }
1767
2878
  },
1768
2879
  {
1769
- key: "session-cost",
2880
+ key: "devices-4",
1770
2881
  count: 1,
1771
- position: { x: 0.62, y: 0.51 },
2882
+ position: { x: 0.64, y: 0.53 },
1772
2883
  sprite: {
1773
2884
  kind: "text",
1774
- strings: ["$2.47 this session"],
1775
- color: "#667788",
2885
+ strings: ["Media paused"],
2886
+ color: "#556677",
1776
2887
  font: "11px monospace",
1777
2888
  align: "left",
1778
2889
  baseline: "top"
1779
2890
  },
1780
- motion: { type: "static" }
2891
+ motion: { type: "static" },
2892
+ life: { enter: 1700, fade: 600 }
1781
2893
  },
1782
- // --- bottom: event log ---
2894
+ // --- bottom: activity feed (boots last) ---
1783
2895
  {
1784
- key: "log-label",
2896
+ key: "feed-label",
1785
2897
  count: 1,
1786
- position: { x: 0.06, y: 0.76 },
2898
+ position: { x: 0.06, y: 0.74 },
1787
2899
  sprite: {
1788
2900
  kind: "text",
1789
- strings: ["EVENT LOG"],
2901
+ strings: ["ACTIVITY"],
1790
2902
  color: "#445566",
1791
2903
  font: "9px monospace",
1792
2904
  align: "left",
1793
2905
  baseline: "top"
1794
2906
  },
1795
- motion: { type: "static" }
2907
+ motion: { type: "static" },
2908
+ life: { enter: 2200, fade: 600 }
1796
2909
  },
1797
2910
  {
1798
- key: "log-1",
2911
+ key: "feed-1",
1799
2912
  count: 1,
1800
- position: { x: 0.06, y: 0.8 },
2913
+ position: { x: 0.06, y: 0.78 },
1801
2914
  sprite: {
1802
2915
  kind: "text",
1803
- strings: ["14:07 Edit compile.ts +18 -3"],
1804
- color: "#667766",
2916
+ strings: ["21:42 Front door locked"],
2917
+ color: "#7a8a7a",
1805
2918
  font: "10px monospace",
1806
2919
  align: "left",
1807
2920
  baseline: "top",
1808
- maxWidth: 600
2921
+ maxWidth: 0.5556
1809
2922
  },
1810
- motion: { type: "static" }
2923
+ motion: { type: "static" },
2924
+ life: { enter: 2200, fade: 600 }
1811
2925
  },
1812
2926
  {
1813
- key: "log-2",
2927
+ key: "feed-2",
1814
2928
  count: 1,
1815
- position: { x: 0.06, y: 0.84 },
2929
+ position: { x: 0.06, y: 0.82 },
1816
2930
  sprite: {
1817
2931
  kind: "text",
1818
- strings: ["14:06 Bash pnpm test \u2713 125 passed"],
1819
- color: "#556655",
2932
+ strings: ["21:36 Thermostat \u2192 eco"],
2933
+ color: "#6a7a6a",
1820
2934
  font: "10px monospace",
1821
2935
  align: "left",
1822
2936
  baseline: "top",
1823
- maxWidth: 600
2937
+ maxWidth: 0.5556
1824
2938
  },
1825
- motion: { type: "static" }
2939
+ motion: { type: "static" },
2940
+ life: { enter: 2200, fade: 600 }
1826
2941
  },
1827
2942
  {
1828
- key: "log-3",
2943
+ key: "feed-3",
1829
2944
  count: 1,
1830
- position: { x: 0.06, y: 0.88 },
2945
+ position: { x: 0.06, y: 0.86 },
1831
2946
  sprite: {
1832
2947
  kind: "text",
1833
- strings: ["14:05 Read types.ts (121 lines)"],
1834
- color: "#4d5e4d",
2948
+ strings: ["21:14 Backup completed"],
2949
+ color: "#5a6a5a",
1835
2950
  font: "10px monospace",
1836
2951
  align: "left",
1837
2952
  baseline: "top",
1838
- maxWidth: 600
2953
+ maxWidth: 0.5556
1839
2954
  },
1840
- motion: { type: "static" }
2955
+ motion: { type: "static" },
2956
+ life: { enter: 2200, fade: 600 }
1841
2957
  },
1842
2958
  {
1843
- key: "log-4",
2959
+ key: "feed-4",
1844
2960
  count: 1,
1845
- position: { x: 0.06, y: 0.92 },
2961
+ position: { x: 0.06, y: 0.9 },
1846
2962
  sprite: {
1847
2963
  kind: "text",
1848
- strings: ["14:03 Edit examples/dev-dashboard.ts +84 -1"],
1849
- color: "#445544",
2964
+ strings: ["20:58 Motion \xB7 driveway"],
2965
+ color: "#4d5e4d",
1850
2966
  font: "10px monospace",
1851
2967
  align: "left",
1852
2968
  baseline: "top",
1853
- maxWidth: 600
2969
+ maxWidth: 0.5556
1854
2970
  },
1855
- motion: { type: "static" }
2971
+ motion: { type: "static" },
2972
+ life: { enter: 2200, fade: 600 }
1856
2973
  },
1857
2974
  {
1858
- key: "log-5",
2975
+ key: "feed-5",
1859
2976
  count: 1,
1860
- position: { x: 0.06, y: 0.96 },
2977
+ position: { x: 0.06, y: 0.94 },
1861
2978
  sprite: {
1862
2979
  kind: "text",
1863
- strings: ["14:01 Bash pnpm build \u2713 6 packages"],
2980
+ strings: ["20:41 Sunset scene on"],
1864
2981
  color: "#3d4e3d",
1865
2982
  font: "10px monospace",
1866
2983
  align: "left",
1867
2984
  baseline: "top",
1868
- maxWidth: 600
2985
+ maxWidth: 0.5556
1869
2986
  },
1870
- motion: { type: "static" }
2987
+ motion: { type: "static" },
2988
+ life: { enter: 2200, fade: 600 }
1871
2989
  }
1872
2990
  ]
1873
2991
  };
@@ -1891,58 +3009,147 @@ var LANTERNS_SPEC = {
1891
3009
  layers: [
1892
3010
  {
1893
3011
  count: 60,
1894
- sprite: { kind: "circle", radius: [0.5, 1.4], color: "#8fa0c8" },
3012
+ sprite: { kind: "circle", radius: [463e-6, 1296e-6], color: "#8fa0c8" },
1895
3013
  alpha: [0.35, 1],
1896
3014
  region: { y: [0, 0.62] },
1897
- motion: { type: "drift", speed: [0.5, 2], bob: 1 }
3015
+ motion: { type: "drift", speed: [463e-6, 1852e-6], bob: 9259e-7 }
1898
3016
  },
1899
3017
  {
1900
3018
  count: 36,
1901
3019
  sprite: {
1902
3020
  kind: "circle",
1903
- radius: [1.5, 3],
3021
+ radius: [1389e-6, 2778e-6],
1904
3022
  color: "#b06a2a",
1905
3023
  soft: true
1906
3024
  },
1907
3025
  alpha: [0.5, 0.9],
1908
3026
  blend: "lighter",
1909
3027
  pulse: { amp: 0.18, period: 2800 },
1910
- motion: { type: "rise", speed: [6, 14], sway: 4 }
3028
+ motion: { type: "rise", speed: [5556e-6, 0.01296], sway: 3704e-6 }
1911
3029
  },
1912
3030
  {
1913
3031
  count: 14,
1914
3032
  sprite: {
1915
3033
  kind: "circle",
1916
- radius: [3.5, 6.5],
3034
+ radius: [3241e-6, 6019e-6],
1917
3035
  color: "#e08a34",
1918
3036
  soft: true
1919
3037
  },
1920
3038
  alpha: [0.6, 1],
1921
3039
  blend: "lighter",
1922
3040
  pulse: { amp: 0.22, period: 3400 },
1923
- motion: { type: "rise", speed: [16, 28], sway: 7 }
3041
+ motion: { type: "rise", speed: [0.01481, 0.02593], sway: 6481e-6 }
1924
3042
  },
1925
3043
  {
1926
3044
  count: 8,
1927
3045
  sprite: { kind: "emoji", glyphs: ["\u{1F3EE}"] },
1928
- size: [26, 44],
3046
+ size: [0.02407, 0.04074],
1929
3047
  alpha: [0.85, 1],
1930
- motion: { type: "rise", speed: [30, 48], sway: 10 }
3048
+ motion: { type: "rise", speed: [0.02778, 0.04444], sway: 9259e-6 }
1931
3049
  },
1932
3050
  {
1933
3051
  count: 4,
1934
3052
  sprite: { kind: "emoji", glyphs: ["\u{1F3EE}"] },
1935
- size: [58, 84],
1936
- motion: { type: "rise", speed: [52, 74], sway: 14 }
3053
+ size: [0.0537, 0.07778],
3054
+ motion: { type: "rise", speed: [0.04815, 0.06852], sway: 0.01296 }
1937
3055
  },
1938
3056
  {
1939
3057
  count: 12,
1940
- sprite: { kind: "circle", radius: [2, 5], color: "#e08a34", soft: true },
3058
+ sprite: { kind: "circle", radius: [1852e-6, 463e-5], color: "#e08a34", soft: true },
1941
3059
  alpha: [0.12, 0.35],
1942
3060
  blend: "lighter",
1943
3061
  region: { y: [0.93, 1] },
1944
3062
  pulse: { amp: 0.1, period: 2200 },
1945
- motion: { type: "drift", speed: [2, 6], bob: 2 }
3063
+ motion: { type: "drift", speed: [1852e-6, 5556e-6], bob: 1852e-6 }
3064
+ }
3065
+ ]
3066
+ };
3067
+
3068
+ // src/examples/matrix-rain.ts
3069
+ var MATRIX_RAIN_SPEC = {
3070
+ schemaVersion: 1,
3071
+ id: "matrix-rain",
3072
+ label: "Matrix Rain",
3073
+ seed: 1999,
3074
+ motionIntensity: "moderate",
3075
+ ghosting: 0.88,
3076
+ background: { type: "solid", color: "#010401" },
3077
+ layers: [
3078
+ {
3079
+ key: "rain-far",
3080
+ count: 64,
3081
+ sprite: {
3082
+ kind: "text",
3083
+ strings: ["\u30A2", "\u30AB", "\u30B5", "\u30BF", "\u30CA", "\u30CF", "\u30DE", "\u30E4", "\u30E9", "\u30EF", "0", "1", "7", "Z", "X", "K"],
3084
+ color: "#1c7a2e",
3085
+ font: "bold monospace",
3086
+ cycle: { period: 900 }
3087
+ },
3088
+ size: [0.014, 0.02],
3089
+ layout: { type: "grid", columns: 32, jitter: { y: 1 } },
3090
+ motion: { type: "drift", speed: [0.06, 0.14], angle: 90 },
3091
+ alpha: [0.35, 0.6]
3092
+ },
3093
+ {
3094
+ key: "rain-near",
3095
+ count: 40,
3096
+ sprite: {
3097
+ kind: "text",
3098
+ strings: ["\u30DF", "\u30CD", "\u30EA", "\u30BD", "\u30C4", "\u30CC", "\u30DB", "\u30E6", "3", "8", "9", "V", "N"],
3099
+ color: "#2ecc40",
3100
+ font: "bold monospace",
3101
+ cycle: { period: 700 }
3102
+ },
3103
+ size: [0.022, 0.03],
3104
+ layout: { type: "grid", columns: 20, jitter: { y: 1 } },
3105
+ motion: { type: "drift", speed: [0.16, 0.3], angle: 90 },
3106
+ alpha: [0.6, 0.9]
3107
+ },
3108
+ {
3109
+ key: "heads",
3110
+ count: 12,
3111
+ sprite: {
3112
+ kind: "text",
3113
+ strings: ["\u30B1", "\u30E1", "\u30A8", "\u30E0", "\u30F2", "5", "2"],
3114
+ color: "#c8ffd4",
3115
+ font: "bold monospace",
3116
+ cycle: { period: 500 }
3117
+ },
3118
+ size: [0.024, 0.032],
3119
+ layout: { type: "grid", columns: 12, jitter: { y: 1 } },
3120
+ motion: { type: "drift", speed: [0.2, 0.34], angle: 90 },
3121
+ alpha: [0.9, 1]
3122
+ }
3123
+ ]
3124
+ };
3125
+
3126
+ // src/examples/polygons.ts
3127
+ var POLYGONS_SPEC = {
3128
+ schemaVersion: 1,
3129
+ id: "polygons",
3130
+ label: "Polygons",
3131
+ seed: 1995,
3132
+ motionIntensity: "moderate",
3133
+ ghosting: 0.95,
3134
+ background: { type: "solid", color: "#04040a" },
3135
+ layers: [
3136
+ {
3137
+ key: "poly-cyan",
3138
+ count: 4,
3139
+ sprite: { kind: "circle", radius: [25e-4, 25e-4], color: "#37e5d7" },
3140
+ motion: { type: "bounce", speed: [0.14, 0.24] },
3141
+ wrap: false,
3142
+ alpha: [0.9, 0.9],
3143
+ links: { k: 1, maxDist: 3, mode: "chain", closed: true, color: "#37e5d7", alpha: 0.85, width: 18e-4 }
3144
+ },
3145
+ {
3146
+ key: "poly-magenta",
3147
+ count: 4,
3148
+ sprite: { kind: "circle", radius: [25e-4, 25e-4], color: "#e83e9c" },
3149
+ motion: { type: "bounce", speed: [0.1, 0.2] },
3150
+ wrap: false,
3151
+ alpha: [0.9, 0.9],
3152
+ links: { k: 1, maxDist: 3, mode: "chain", closed: true, color: "#e83e9c", alpha: 0.85, width: 18e-4 }
1946
3153
  }
1947
3154
  ]
1948
3155
  };
@@ -1966,7 +3173,7 @@ var ORRERY_SPEC = {
1966
3173
  // Distant stars
1967
3174
  {
1968
3175
  count: 80,
1969
- sprite: { kind: "circle", radius: [0.3, 1.2], color: "#8899cc" },
3176
+ sprite: { kind: "circle", radius: [2778e-7, 1111e-6], color: "#8899cc" },
1970
3177
  alpha: [0.2, 0.8],
1971
3178
  pulse: { amp: 0.3, period: 3e3 },
1972
3179
  motion: { type: "static" }
@@ -1975,7 +3182,7 @@ var ORRERY_SPEC = {
1975
3182
  {
1976
3183
  key: "corona",
1977
3184
  count: 1,
1978
- sprite: { kind: "circle", radius: [60, 60], color: "#ffaa22", soft: true },
3185
+ sprite: { kind: "circle", radius: [0.05556, 0.05556], color: "#ffaa22", soft: true },
1979
3186
  blend: "lighter",
1980
3187
  alpha: [0.6, 0.6],
1981
3188
  grow: { amp: 0.15, period: 4e3 },
@@ -1987,7 +3194,7 @@ var ORRERY_SPEC = {
1987
3194
  key: "sun",
1988
3195
  count: 1,
1989
3196
  sprite: { kind: "emoji", glyphs: ["\u2600\uFE0F"] },
1990
- size: [48, 48],
3197
+ size: [0.04444, 0.04444],
1991
3198
  grow: { amp: 0.08, period: 4e3 },
1992
3199
  motion: { type: "static" },
1993
3200
  position: { x: 0.5, y: 0.5 }
@@ -1997,36 +3204,114 @@ var ORRERY_SPEC = {
1997
3204
  key: "inner",
1998
3205
  count: 3,
1999
3206
  sprite: { kind: "emoji", glyphs: ["\u{1FAA8}", "\u{1F534}", "\u{1F7E0}"] },
2000
- size: [16, 22],
3207
+ size: [0.01481, 0.02037],
2001
3208
  spin: 30,
2002
- motion: { type: "orbit", speed: [28, 42], radius: [80, 120] }
3209
+ motion: { type: "orbit", speed: [28, 42], radius: [0.07407, 0.1111] }
2003
3210
  },
2004
3211
  // Middle ring — gas giants
2005
3212
  {
2006
3213
  key: "middle",
2007
3214
  count: 2,
2008
3215
  sprite: { kind: "emoji", glyphs: ["\u{1F7E4}", "\u{1FA90}"] },
2009
- size: [28, 38],
3216
+ size: [0.02593, 0.03519],
2010
3217
  spin: -15,
2011
- motion: { type: "orbit", speed: [12, 20], radius: [160, 200] }
3218
+ motion: { type: "orbit", speed: [12, 20], radius: [0.1481, 0.1852] }
2012
3219
  },
2013
3220
  // Outer ring — ice giants
2014
3221
  {
2015
3222
  key: "outer",
2016
3223
  count: 2,
2017
3224
  sprite: { kind: "emoji", glyphs: ["\u{1F535}", "\u{1F48E}"] },
2018
- size: [22, 30],
3225
+ size: [0.02037, 0.02778],
2019
3226
  spin: 10,
2020
- motion: { type: "orbit", speed: [5, 10], radius: [240, 300] }
3227
+ motion: { type: "orbit", speed: [5, 10], radius: [0.2222, 0.2778] }
2021
3228
  },
2022
3229
  // Orbital dust ring (soft glow particles orbiting slowly)
2023
3230
  {
2024
3231
  count: 24,
2025
- sprite: { kind: "circle", radius: [1, 3], color: "#ccaa66", soft: true },
3232
+ sprite: { kind: "circle", radius: [9259e-7, 2778e-6], color: "#ccaa66", soft: true },
2026
3233
  alpha: [0.15, 0.4],
2027
3234
  blend: "lighter",
2028
3235
  pulse: { amp: 0.2, period: 2500 },
2029
- motion: { type: "orbit", speed: [2, 8], radius: [100, 320] }
3236
+ motion: { type: "orbit", speed: [2, 8], radius: [0.09259, 0.2963] }
3237
+ }
3238
+ ]
3239
+ };
3240
+
3241
+ // src/examples/procession.ts
3242
+ var PROCESSION_SPEC = {
3243
+ schemaVersion: 1,
3244
+ id: "procession",
3245
+ label: "Night Procession",
3246
+ seed: 8,
3247
+ motionIntensity: "calm",
3248
+ background: {
3249
+ type: "gradient",
3250
+ stops: [
3251
+ { at: 0, color: "#060612" },
3252
+ { at: 0.8, color: "#101024" },
3253
+ { at: 1, color: "#1a1428" }
3254
+ ],
3255
+ drift: { period: 3e4, amount: 0.08 }
3256
+ },
3257
+ layers: [
3258
+ {
3259
+ key: "stars",
3260
+ count: 90,
3261
+ sprite: { kind: "circle", radius: [6e-4, 16e-4], color: "#f0ead8", soft: true },
3262
+ motion: { type: "static" },
3263
+ region: { y: [0, 0.6] },
3264
+ alpha: [0.25, 0.6],
3265
+ pulse: { amp: 0.15, period: 5e3 }
3266
+ },
3267
+ {
3268
+ key: "lanterns",
3269
+ count: 9,
3270
+ sprite: {
3271
+ kind: "rect",
3272
+ width: [0.012, 0.02],
3273
+ aspect: [1.3, 1.7],
3274
+ color: "#ffb347",
3275
+ colors: ["#ffb347", "#ff8c69", "#ffd97a"],
3276
+ colorWeights: [3, 1, 2]
3277
+ },
3278
+ motion: {
3279
+ type: "path",
3280
+ points: [
3281
+ { x: 0.12, y: 0.72 },
3282
+ { x: 0.38, y: 0.58 },
3283
+ { x: 0.62, y: 0.78 },
3284
+ { x: 0.88, y: 0.62 },
3285
+ { x: 0.62, y: 0.5 },
3286
+ { x: 0.38, y: 0.72 }
3287
+ ],
3288
+ duration: 36e3,
3289
+ curve: "smooth",
3290
+ closed: true,
3291
+ scatter: 0.02
3292
+ },
3293
+ alpha: [0.65, 0.9],
3294
+ pulse: { amp: 0.15, period: 2600 },
3295
+ life: { enter: 4e3, fade: 2500 },
3296
+ trail: { length: 1200, fade: 1 }
3297
+ },
3298
+ {
3299
+ key: "bearer",
3300
+ count: 1,
3301
+ sprite: { kind: "circle", radius: [0.014, 0.014], color: "#ffe9b0", soft: true },
3302
+ motion: { type: "wander", speed: [8e-3, 0.012], meander: 0.08 },
3303
+ alpha: [0.8, 0.8],
3304
+ blend: "lighter",
3305
+ life: { enter: 4e3, fade: 2500 }
3306
+ },
3307
+ {
3308
+ key: "moths",
3309
+ count: 7,
3310
+ sprite: { kind: "ring", radius: [2e-3, 5e-3], color: "#d8f6ff", width: 1e-3 },
3311
+ motion: { type: "orbit", speed: [20, 55], radius: [0.03, 0.09], center: { layer: "bearer" } },
3312
+ alpha: [0.3, 0.7],
3313
+ life: { enter: 7e3, fade: 2e3 },
3314
+ trail: { length: 600 }
2030
3315
  }
2031
3316
  ]
2032
3317
  };
@@ -2043,8 +3328,8 @@ var RAIN_SPEC = {
2043
3328
  {
2044
3329
  count: 140,
2045
3330
  sprite: { kind: "text", strings: ["\u2502"], color: "#9fd0ff" },
2046
- size: [12, 26],
2047
- motion: { type: "drift", angle: 90, speed: [520, 900] }
3331
+ size: [0.01111, 0.02407],
3332
+ motion: { type: "drift", angle: 90, speed: [0.4815, 0.8333] }
2048
3333
  }
2049
3334
  ]
2050
3335
  };
@@ -2061,50 +3346,122 @@ var SAKURA_SPEC = {
2061
3346
  stops: [
2062
3347
  { at: 0, color: "#1b1d38" },
2063
3348
  { at: 0.45, color: "#3a2645" },
2064
- { at: 0.8, color: "#7d3d4e" },
2065
- { at: 1, color: "#0f140d" }
3349
+ { at: 0.78, color: "#7d3d4e" },
3350
+ { at: 0.92, color: "#4a2438" },
3351
+ { at: 1, color: "#1c1018" }
2066
3352
  ],
2067
- band: { color: "#0c140d", height: 44 }
3353
+ drift: { period: 35e3, amount: 0.08 }
2068
3354
  },
2069
3355
  layers: [
2070
3356
  {
3357
+ key: "blossom-glow",
2071
3358
  count: 60,
2072
- sprite: { kind: "circle", radius: [0.6, 1.6], color: "#ffe3ec", soft: true },
3359
+ sprite: {
3360
+ kind: "circle",
3361
+ radius: [5556e-7, 1481e-6],
3362
+ color: "#ffe3ec",
3363
+ soft: true
3364
+ },
2073
3365
  alpha: [0.3, 0.9],
2074
3366
  blend: "lighter",
2075
3367
  region: { y: [0, 0.72] },
2076
- pulse: { amp: 0.22, period: 3e3 },
2077
- motion: { type: "drift", speed: [0.3, 1.2], bob: 1 }
3368
+ // Shimmer travels across the sky instead of orbs twinkling independently.
3369
+ pulse: { amp: 0.22, period: 4e3, wave: { wavelength: 0.45, angle: 10 } },
3370
+ motion: { type: "drift", speed: [2778e-7, 1111e-6], bob: 9259e-7 }
2078
3371
  },
2079
3372
  {
3373
+ key: "petals-far",
2080
3374
  count: 28,
2081
3375
  sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
2082
- size: [13, 20],
3376
+ size: [0.01204, 0.01852],
2083
3377
  alpha: [0.5, 0.8],
2084
- motion: { type: "drift", speed: [12, 26], angle: 100, bob: 3 }
3378
+ spin: 16,
3379
+ motion: {
3380
+ type: "wander",
3381
+ speed: [0.011, 0.024],
3382
+ angle: 100,
3383
+ meander: 0.025,
3384
+ coherence: 0.35
3385
+ }
2085
3386
  },
2086
3387
  {
3388
+ key: "petals-mid",
2087
3389
  count: 18,
2088
3390
  sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
2089
- size: [20, 32],
3391
+ size: [0.01852, 0.02963],
2090
3392
  alpha: [0.7, 0.95],
2091
- motion: { type: "drift", speed: [24, 44], angle: 100, bob: 6 }
3393
+ spin: 24,
3394
+ motion: {
3395
+ type: "wander",
3396
+ speed: [0.022, 0.041],
3397
+ angle: 100,
3398
+ meander: 0.045,
3399
+ coherence: 0.35
3400
+ }
2092
3401
  },
2093
3402
  {
3403
+ key: "petals-near",
2094
3404
  count: 10,
2095
3405
  sprite: { kind: "emoji", glyphs: ["\u{1F338}"] },
2096
- size: [34, 54],
3406
+ size: [0.03148, 0.05],
2097
3407
  alpha: [0.85, 1],
2098
- motion: { type: "drift", speed: [42, 68], angle: 100, bob: 11 }
3408
+ spin: 32,
3409
+ motion: {
3410
+ type: "wander",
3411
+ speed: [0.039, 0.063],
3412
+ angle: 100,
3413
+ meander: 0.07,
3414
+ coherence: 0.35
3415
+ }
3416
+ },
3417
+ {
3418
+ key: "gusts",
3419
+ count: 7,
3420
+ sprite: {
3421
+ kind: "streak",
3422
+ length: [0.02, 0.045],
3423
+ width: 12e-4,
3424
+ color: "#ffd9e6"
3425
+ },
3426
+ alpha: [0.1, 0.22],
3427
+ blend: "lighter",
3428
+ motion: { type: "drift", speed: [0.09, 0.14], angle: 100 }
3429
+ },
3430
+ {
3431
+ key: "fireflies",
3432
+ count: 6,
3433
+ sprite: {
3434
+ kind: "circle",
3435
+ radius: [9e-4, 2e-3],
3436
+ color: "#ffe9a8",
3437
+ soft: true
3438
+ },
3439
+ alpha: [0.15, 0.5],
3440
+ blend: "lighter",
3441
+ region: { y: [0.6, 0.9] },
3442
+ pulse: { amp: 0.3, period: 2800 },
3443
+ motion: { type: "wander", speed: [4e-3, 0.01], meander: 0.05 }
2099
3444
  },
2100
3445
  {
3446
+ key: "fallen-petals",
2101
3447
  count: 16,
2102
- sprite: { kind: "circle", radius: [1.5, 4], color: "#ff9bb5", soft: true },
3448
+ sprite: {
3449
+ kind: "circle",
3450
+ radius: [1389e-6, 3704e-6],
3451
+ color: "#ff9bb5",
3452
+ soft: true
3453
+ },
2103
3454
  alpha: [0.1, 0.3],
2104
3455
  blend: "lighter",
2105
3456
  region: { y: [0.9, 1] },
2106
- pulse: { amp: 0.15, period: 2600 },
2107
- motion: { type: "drift", speed: [0.3, 1.5], angle: 180, bob: 1 }
3457
+ // Settled petals catch the last light in a slow wave along the ground.
3458
+ pulse: { amp: 0.15, period: 3200, wave: { wavelength: 0.3, angle: 0 } },
3459
+ motion: {
3460
+ type: "drift",
3461
+ speed: [2778e-7, 1389e-6],
3462
+ angle: 180,
3463
+ bob: 9259e-7
3464
+ }
2108
3465
  }
2109
3466
  ]
2110
3467
  };
@@ -2127,18 +3484,71 @@ var SNOWFALL_SPEC = {
2127
3484
  layers: [
2128
3485
  {
2129
3486
  count: 50,
2130
- sprite: { kind: "circle", radius: [1, 2.5], color: "#8899aa" },
2131
- motion: { type: "drift", angle: 90, speed: [15, 40], bob: 3 }
3487
+ sprite: { kind: "circle", radius: [9259e-7, 2315e-6], color: "#8899aa" },
3488
+ motion: { type: "drift", angle: 90, speed: [0.01389, 0.03704], bob: 2778e-6 }
2132
3489
  },
2133
3490
  {
2134
3491
  count: 35,
2135
- sprite: { kind: "circle", radius: [2, 4.5], color: "#c0cdd8" },
2136
- motion: { type: "drift", angle: 85, speed: [35, 80], bob: 6 }
3492
+ sprite: { kind: "circle", radius: [1852e-6, 4167e-6], color: "#c0cdd8" },
3493
+ motion: { type: "drift", angle: 85, speed: [0.03241, 0.07407], bob: 5556e-6 }
2137
3494
  },
2138
3495
  {
2139
3496
  count: 15,
2140
- sprite: { kind: "circle", radius: [3.5, 7], color: "#e8eff5" },
2141
- motion: { type: "drift", angle: 82, speed: [55, 120], bob: 10 }
3497
+ sprite: { kind: "circle", radius: [3241e-6, 6481e-6], color: "#e8eff5" },
3498
+ motion: { type: "drift", angle: 82, speed: [0.05093, 0.1111], bob: 9259e-6 }
3499
+ }
3500
+ ]
3501
+ };
3502
+
3503
+ // src/examples/warp-tunnel.ts
3504
+ var WARP_TUNNEL_SPEC = {
3505
+ schemaVersion: 1,
3506
+ id: "warp-tunnel",
3507
+ label: "Warp Tunnel",
3508
+ seed: 1701,
3509
+ motionIntensity: "energetic",
3510
+ ghosting: 0.55,
3511
+ background: { type: "solid", color: "#020208" },
3512
+ layers: [
3513
+ {
3514
+ key: "far-stars",
3515
+ count: 150,
3516
+ sprite: {
3517
+ kind: "streak",
3518
+ length: [8e-3, 0.02],
3519
+ width: 15e-4,
3520
+ color: "#cfd8ff",
3521
+ colors: ["#cfd8ff", "#ffffff", "#8ab4ff", "#ffd9a0"],
3522
+ colorWeights: [5, 3, 1.5, 0.5]
3523
+ },
3524
+ motion: { type: "warp", speed: [0.06, 0.16] },
3525
+ alpha: [0.5, 0.9],
3526
+ blend: "lighter"
3527
+ },
3528
+ {
3529
+ key: "near-stars",
3530
+ count: 50,
3531
+ sprite: {
3532
+ kind: "streak",
3533
+ length: [0.025, 0.06],
3534
+ width: 25e-4,
3535
+ color: "#ffffff",
3536
+ colors: ["#ffffff", "#a8c8ff"],
3537
+ colorWeights: [3, 1]
3538
+ },
3539
+ motion: { type: "warp", speed: [0.3, 0.55] },
3540
+ alpha: [0.7, 1],
3541
+ blend: "lighter"
3542
+ },
3543
+ {
3544
+ key: "core-glow",
3545
+ count: 1,
3546
+ sprite: { kind: "circle", radius: [0.06, 0.06], color: "#3a4a8f", soft: true },
3547
+ motion: { type: "static" },
3548
+ position: { x: 0.5, y: 0.5 },
3549
+ alpha: [0.35, 0.35],
3550
+ pulse: { amp: 0.15, period: 5e3 },
3551
+ blend: "lighter"
2142
3552
  }
2143
3553
  ]
2144
3554
  };
@@ -2150,10 +3560,15 @@ var SCHEMA_EXAMPLES = [
2150
3560
  { id: "snowfall", label: "Snowfall", spec: SNOWFALL_SPEC },
2151
3561
  { id: "lanterns", label: "Night Lanterns", spec: LANTERNS_SPEC },
2152
3562
  { id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
2153
- { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
3563
+ { id: "dev-dashboard", label: "Control Center", spec: DASHBOARD_SPEC },
2154
3564
  { id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
2155
3565
  { id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC },
2156
- { id: "comets", label: "Comet Shower", spec: COMETS_SPEC }
3566
+ { id: "comets", label: "Comet Shower", spec: COMETS_SPEC },
3567
+ { id: "aurora", label: "Aurora", spec: AURORA_SPEC },
3568
+ { id: "warp-tunnel", label: "Warp Tunnel", spec: WARP_TUNNEL_SPEC },
3569
+ { id: "polygons", label: "Polygons", spec: POLYGONS_SPEC },
3570
+ { id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
3571
+ { id: "procession", label: "Night Procession", spec: PROCESSION_SPEC }
2157
3572
  ];
2158
3573
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
2159
3574
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -2161,6 +3576,7 @@ var EXAMPLE_BY_ID = Object.fromEntries(
2161
3576
  );
2162
3577
  export {
2163
3578
  AQUARIUM_SPEC,
3579
+ AURORA_SPEC,
2164
3580
  COMETS_SPEC,
2165
3581
  CONSTELLATION_SPEC,
2166
3582
  DASHBOARD_SPEC,
@@ -2168,12 +3584,17 @@ export {
2168
3584
  EXAMPLE_SPECS,
2169
3585
  LANTERNS_SPEC,
2170
3586
  LIMITS,
3587
+ MATRIX_RAIN_SPEC,
2171
3588
  ORRERY_SPEC,
3589
+ POLYGONS_SPEC,
3590
+ PROCESSION_SPEC,
2172
3591
  RAIN_SPEC,
2173
3592
  SAKURA_SPEC,
2174
3593
  SCHEMA_EXAMPLES,
2175
3594
  SCHEMA_VERSION,
2176
3595
  SNOWFALL_SPEC,
3596
+ WARP_NEAR,
3597
+ WARP_TUNNEL_SPEC,
2177
3598
  adviseSpec,
2178
3599
  alphaAt,
2179
3600
  applyDeltasToSpec,
@@ -2181,11 +3602,20 @@ export {
2181
3602
  buildEntities,
2182
3603
  compileSaver,
2183
3604
  describeScene,
3605
+ diffScenes,
3606
+ dominanceRanking,
2184
3607
  easeSmooth,
3608
+ headingAt,
2185
3609
  lerpSpec,
3610
+ lifeAlphaAt,
3611
+ linkEdges,
2186
3612
  linkPairs,
3613
+ luminanceGrid,
2187
3614
  manifestFor,
3615
+ motionStats,
3616
+ perceiveScene,
2188
3617
  positionAt,
3618
+ renderBrailleMap,
2189
3619
  resolveSpecPath,
2190
3620
  rotationAt,
2191
3621
  sizeAt,