@idle-screens/schema 2.1.0 → 2.3.0

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