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