@idle-screens/schema 3.5.0 → 3.6.1

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
@@ -55,17 +55,41 @@ var LIMITS = {
55
55
  minSegmentDuration: 1e3,
56
56
  // ms — flash-safety floor: segment cuts are luminance transitions
57
57
  maxTransitionDur: 5e3,
58
- minTransitionDur: 200
58
+ minTransitionDur: 200,
59
+ minEmitEvery: 1e3,
60
+ // ms — at most one event per second per entity (flash safety)
61
+ maxEmitEvery: 6e5,
62
+ // ms — ten minutes; longer silences than that are `life.enter`
63
+ minEmitLife: 500,
64
+ // ms — an event is a smooth envelope, never a cut
65
+ maxEmitGrow: 8,
66
+ // × base size across an event window
67
+ minClockedPeriod: 1e3,
68
+ // ms — a `clock`ed layer breathes in unison, so 1 Hz not 2
69
+ minClockRate: 0.1,
70
+ maxClockRate: 4,
71
+ defaultListGap: 0.06,
72
+ // viewport units between list/table cells (40 px in px specs)
73
+ defaultListGapPx: 40,
74
+ minPolygonSides: 3,
75
+ maxPolygonSides: 12,
76
+ maxShapePoints: 24,
77
+ // polygon.points / stroke.points
78
+ minEaseTau: 100,
79
+ // ms
80
+ maxEaseTau: 12e4
81
+ // ms
59
82
  };
60
83
 
61
84
  // src/steer.ts
62
85
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
86
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
63
87
  function resolveSpecPath(spec, path) {
64
88
  if (!spec || typeof spec !== "object" || !path) return null;
65
89
  const parts = path.split(".");
66
90
  if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
67
91
  const s = spec;
68
- if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
92
+ if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
69
93
  const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
70
94
  if (idx === -1) return null;
71
95
  parts.splice(0, 1, "layers", String(idx));
@@ -127,7 +151,7 @@ function lerpSpec(from, to, k) {
127
151
  }
128
152
  function steerablePaths(spec) {
129
153
  if (!spec || typeof spec !== "object") return [];
130
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
154
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
131
155
  const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
132
156
  const out = [];
133
157
  const walk = (node, prefix, key) => {
@@ -158,6 +182,7 @@ function easeSmooth(k) {
158
182
  function structuralSignature(spec) {
159
183
  return JSON.stringify([
160
184
  spec.units,
185
+ spec.referenceViewport,
161
186
  spec.layers.map((l) => {
162
187
  const s = l.sprite;
163
188
  return [
@@ -173,12 +198,14 @@ function structuralSignature(spec) {
173
198
  l.spin,
174
199
  l.grow,
175
200
  l.layout,
201
+ l.emit,
202
+ l.clock,
176
203
  l.sprite.kind,
177
204
  // Dimensional draws baked into entities: radius (circle/ring), length
178
205
  // (streak), width+aspect (rect), palette pick (all shaped sprites).
179
206
  s.radius,
180
207
  s.length,
181
- l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
208
+ l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
182
209
  Array.isArray(s.colors) ? s.colors.length : void 0,
183
210
  s.colorWeights,
184
211
  l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? [l.sprite.fontSize] : void 0
@@ -193,7 +220,7 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
193
220
  var isStr = (v) => typeof v === "string";
194
221
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
195
222
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
196
- var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "referenceViewport", "background", "layers", "ghosting"]);
223
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting"]);
197
224
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
198
225
  "count",
199
226
  "sprite",
@@ -212,28 +239,33 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
212
239
  "trail",
213
240
  "links",
214
241
  "layout",
215
- "life"
242
+ "life",
243
+ "emit",
244
+ "clock"
216
245
  ]);
217
246
  var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
218
247
  var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
219
248
  var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
220
- var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
249
+ var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "feather", "colors", "colorWeights"]);
250
+ var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness", "color", "max", "direction", "colors", "colorWeights"]);
251
+ var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
252
+ var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
221
253
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
222
254
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
223
255
  var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal"]);
224
256
  var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
225
257
  var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
226
- var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
227
- var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
258
+ var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
259
+ var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway", "ease"]);
228
260
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
229
261
  var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
230
262
  var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
231
- var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence"]);
263
+ var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence", "ease"]);
232
264
  var KNOWN_WARP = /* @__PURE__ */ new Set(["type", "speed", "center"]);
233
265
  var KNOWN_PATH = /* @__PURE__ */ new Set(["type", "points", "duration", "curve", "closed", "scatter"]);
234
266
  var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
235
267
  var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
236
- var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key"]);
268
+ var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout"]);
237
269
  function unknownKeys(obj, known) {
238
270
  return Object.keys(obj).filter((k) => !known.has(k));
239
271
  }
@@ -263,6 +295,9 @@ function validateSpec(spec) {
263
295
  if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
264
296
  err("motionIntensity", "must be calm | moderate | energetic");
265
297
  }
298
+ if (spec.density !== void 0 && !["sparse", "normal", "dense"].includes(spec.density)) {
299
+ err("density", "must be sparse | normal | dense");
300
+ }
266
301
  if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
267
302
  err("units", "must be 'px' | 'viewport'");
268
303
  }
@@ -379,7 +414,10 @@ function validateLayer(layer, path, err, warn, spec) {
379
414
  } else {
380
415
  if (layer.position.x < 0 || layer.position.x > 1) err(`${path}.position.x`, "must be 0..1");
381
416
  if (layer.position.y < 0 || layer.position.y > 1) err(`${path}.position.y`, "must be 0..1");
382
- if (isNum(layer.count) && layer.count !== 1) err(`${path}.position`, "position requires count: 1");
417
+ const dataLayout = isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table");
418
+ if (isNum(layer.count) && layer.count !== 1 && !dataLayout) {
419
+ err(`${path}.position`, "position requires count: 1 \u2014 or a layout of type 'list' / 'table', where it anchors the whole block");
420
+ }
383
421
  }
384
422
  }
385
423
  if (layer.region !== void 0) {
@@ -468,8 +506,37 @@ function validateLayer(layer, path, err, warn, spec) {
468
506
  }
469
507
  }
470
508
  if (layer.layout !== void 0) {
471
- if (!isObj(layer.layout) || layer.layout.type !== "grid") {
472
- err(`${path}.layout`, "must be an object with type: 'grid'");
509
+ if (isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table")) {
510
+ const lay = layer.layout;
511
+ if (lay.type === "table" && (!isNum(lay.columns) || !Number.isInteger(lay.columns) || lay.columns < 1 || lay.columns > LIMITS.maxGridColumns)) {
512
+ err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
513
+ }
514
+ const gapOk = (v) => isNum(v) && v > 0;
515
+ if (lay.gap !== void 0) {
516
+ if (isNum(lay.gap)) {
517
+ if (!gapOk(lay.gap)) err(`${path}.layout.gap`, "must be > 0 (viewport units of min(w,h), or px)");
518
+ } else if (lay.type === "table" && isObj(lay.gap)) {
519
+ if (lay.gap.x !== void 0 && !gapOk(lay.gap.x)) err(`${path}.layout.gap.x`, "must be > 0");
520
+ if (lay.gap.y !== void 0 && !gapOk(lay.gap.y)) err(`${path}.layout.gap.y`, "must be > 0");
521
+ for (const k of unknownKeys(lay.gap, /* @__PURE__ */ new Set(["x", "y"]))) {
522
+ warn(`${path}.layout.gap.${k}`, "unknown-property", `unknown gap property '${k}' \u2014 will be ignored`);
523
+ }
524
+ } else {
525
+ err(`${path}.layout.gap`, lay.type === "table" ? "must be a number > 0 or { x?, y? }" : "must be a number > 0");
526
+ }
527
+ }
528
+ for (const k of unknownKeys(lay, new Set(lay.type === "list" ? ["type", "gap"] : ["type", "columns", "gap"]))) {
529
+ warn(`${path}.layout.${k}`, "unknown-property", `unknown layout property '${k}' \u2014 will be ignored`);
530
+ }
531
+ const sp = layer.sprite;
532
+ if (isObj(sp) && isNum(layer.count)) {
533
+ const variants = sp.kind === "text" && Array.isArray(sp.strings) ? sp.strings.length : sp.kind === "emoji" && Array.isArray(sp.glyphs) ? sp.glyphs.length : sp.kind === "bar" && Array.isArray(sp.values) ? sp.values.length : null;
534
+ if (variants !== null && variants !== layer.count) {
535
+ warn(`${path}.count`, "list-length-mismatch", `count is ${layer.count} but the sprite carries ${variants} ${sp.kind === "bar" ? "values" : "variants"} \u2014 a ${lay.type} layout reads them in order, so they cycle or go unused`);
536
+ }
537
+ }
538
+ } else if (!isObj(layer.layout) || layer.layout.type !== "grid") {
539
+ err(`${path}.layout`, "must be an object with type: 'grid' | 'list' | 'table'");
473
540
  } else {
474
541
  if (layer.layout.columns !== void 0 && (!isNum(layer.layout.columns) || !Number.isInteger(layer.layout.columns) || layer.layout.columns < 1 || layer.layout.columns > LIMITS.maxGridColumns)) {
475
542
  err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
@@ -519,6 +586,64 @@ function validateLayer(layer, path, err, warn, spec) {
519
586
  }
520
587
  }
521
588
  }
589
+ if (layer.emit !== void 0) {
590
+ if (!isObj(layer.emit)) err(`${path}.emit`, "must be an object { every, life, jitter?, grow? }");
591
+ else {
592
+ const em = layer.emit;
593
+ if (!isNum(em.every) || em.every < LIMITS.minEmitEvery || em.every > LIMITS.maxEmitEvery) {
594
+ err(`${path}.emit.every`, `must be ${LIMITS.minEmitEvery}..${LIMITS.maxEmitEvery} ms (at most one event per second per entity)`);
595
+ }
596
+ if (!isNum(em.life) || em.life < LIMITS.minEmitLife) {
597
+ err(`${path}.emit.life`, `must be >= ${LIMITS.minEmitLife} ms (an event is a smooth envelope, never a cut)`);
598
+ } else if (isNum(em.every) && em.life > em.every) {
599
+ err(`${path}.emit.life`, "must be <= emit.every (the visible window cannot outlast its period)");
600
+ }
601
+ if (em.jitter !== void 0 && (!isNum(em.jitter) || em.jitter < 0 || em.jitter > 1)) {
602
+ err(`${path}.emit.jitter`, "must be 0..1 (0 = evenly staggered, 1 = scattered by a fixed sequence)");
603
+ }
604
+ if (em.grow !== void 0) {
605
+ if (!Array.isArray(em.grow) || em.grow.length !== 2 || !isNum(em.grow[0]) || !isNum(em.grow[1])) {
606
+ err(`${path}.emit.grow`, "must be [from, to] size multipliers");
607
+ } else if (em.grow[0] < 0 || em.grow[1] < 0 || em.grow[0] > LIMITS.maxEmitGrow || em.grow[1] > LIMITS.maxEmitGrow) {
608
+ err(`${path}.emit.grow`, `each multiplier must be 0..${LIMITS.maxEmitGrow}`);
609
+ }
610
+ }
611
+ if (em.jitter === 0 && isNum(em.every) && isNum(em.life) && isNum(layer.count) && layer.count > 1 && em.life > em.every / layer.count) {
612
+ warn(`${path}.emit.life`, "emit-overlap", `jitter 0 staggers ${layer.count} entities ${(em.every / layer.count).toFixed(0)} ms apart but each stays lit ${em.life} ms \u2014 windows overlap, so more than one event is visible at a time. Shorten life or lengthen every`);
613
+ }
614
+ for (const k of unknownKeys(em, /* @__PURE__ */ new Set(["every", "life", "jitter", "grow"]))) {
615
+ warn(`${path}.emit.${k}`, "unknown-property", `unknown emit property '${k}' \u2014 will be ignored`);
616
+ }
617
+ }
618
+ }
619
+ if (layer.clock !== void 0) {
620
+ if (!isObj(layer.clock)) err(`${path}.clock`, "must be an object { phase?, rate? }");
621
+ else {
622
+ const ck = layer.clock;
623
+ if (ck.phase !== void 0 && (!isNum(ck.phase) || ck.phase < 0 || ck.phase > 1)) {
624
+ err(`${path}.clock.phase`, "must be 0..1 (turns)");
625
+ }
626
+ const rate = ck.rate === void 0 ? 1 : ck.rate;
627
+ if (!isNum(rate) || rate < LIMITS.minClockRate || rate > LIMITS.maxClockRate) {
628
+ err(`${path}.clock.rate`, `must be ${LIMITS.minClockRate}..${LIMITS.maxClockRate}`);
629
+ } else {
630
+ const floor = LIMITS.minClockedPeriod * rate;
631
+ if (isObj(layer.pulse) && isNum(layer.pulse.period) && layer.pulse.period < floor) {
632
+ err(`${path}.pulse.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod} \u2014 the whole layer breathes in unison)`);
633
+ }
634
+ if (isObj(layer.grow) && isNum(layer.grow.period) && layer.grow.period < floor) {
635
+ err(`${path}.grow.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod})`);
636
+ }
637
+ const sp = layer.sprite;
638
+ if (isObj(sp) && isObj(sp.cycle) && isNum(sp.cycle.period) && sp.cycle.period < floor) {
639
+ err(`${path}.sprite.cycle.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod})`);
640
+ }
641
+ }
642
+ for (const k of unknownKeys(ck, /* @__PURE__ */ new Set(["phase", "rate"]))) {
643
+ warn(`${path}.clock.${k}`, "unknown-property", `unknown clock property '${k}' \u2014 will be ignored`);
644
+ }
645
+ }
646
+ }
522
647
  validateSprite(layer.sprite, `${path}.sprite`, err, warn);
523
648
  validateMotion(layer.motion, `${path}.motion`, err, warn, spec);
524
649
  }
@@ -537,6 +662,9 @@ function validateSprite(sprite, path, err, warn) {
537
662
  err(`${path}.strings`, "must be a non-empty array of strings");
538
663
  }
539
664
  if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
665
+ if (sprite.font !== void 0 && !isStr(sprite.font)) {
666
+ err(`${path}.font`, "must be a string");
667
+ }
540
668
  if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
541
669
  err(`${path}.align`, "must be left | center | right");
542
670
  }
@@ -571,6 +699,45 @@ function validateSprite(sprite, path, err, warn) {
571
699
  if (sprite.aspect !== void 0 && (!isRange(sprite.aspect) || sprite.aspect[0] <= 0)) {
572
700
  err(`${path}.aspect`, "must be a [min,max] range of positive height/width ratios");
573
701
  }
702
+ if (sprite.feather !== void 0 && (!isNum(sprite.feather) || sprite.feather < 0 || sprite.feather > 1)) {
703
+ err(`${path}.feather`, "must be 0..1 (fraction of the half-size that fades out)");
704
+ }
705
+ color(sprite.color, `${path}.color`, err);
706
+ validatePalette(sprite, path, err);
707
+ } else if (sprite.kind === "bar") {
708
+ knownSet = KNOWN_BAR;
709
+ if (!Array.isArray(sprite.values) || sprite.values.length === 0 || sprite.values.length > LIMITS.maxPerLayer || !sprite.values.every((v) => isNum(v) && v >= 0)) {
710
+ err(`${path}.values`, `must be 1..${LIMITS.maxPerLayer} numbers >= 0`);
711
+ }
712
+ if (!isNum(sprite.length) || sprite.length <= 0) err(`${path}.length`, "must be > 0 (full-scale bar length)");
713
+ if (!isNum(sprite.thickness) || sprite.thickness <= 0) err(`${path}.thickness`, "must be > 0");
714
+ if (sprite.max !== void 0 && (!isNum(sprite.max) || sprite.max <= 0)) err(`${path}.max`, "must be > 0");
715
+ if (sprite.direction !== void 0 && !["right", "left", "up", "down"].includes(sprite.direction)) {
716
+ err(`${path}.direction`, "must be 'right' | 'left' | 'up' | 'down'");
717
+ }
718
+ color(sprite.color, `${path}.color`, err);
719
+ validatePalette(sprite, path, err);
720
+ } else if (sprite.kind === "polygon") {
721
+ knownSet = KNOWN_POLYGON;
722
+ if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px (circumradius)");
723
+ if (sprite.sides !== void 0 && sprite.points !== void 0) {
724
+ err(`${path}.sides`, "use sides (a regular polygon) or points (a custom one), not both");
725
+ }
726
+ if (sprite.sides !== void 0 && (!isNum(sprite.sides) || !Number.isInteger(sprite.sides) || sprite.sides < LIMITS.minPolygonSides || sprite.sides > LIMITS.maxPolygonSides)) {
727
+ err(`${path}.sides`, `must be an integer ${LIMITS.minPolygonSides}..${LIMITS.maxPolygonSides}`);
728
+ }
729
+ if (sprite.points !== void 0) validateShapePoints(sprite.points, `${path}.points`, 3, err);
730
+ if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
731
+ color(sprite.color, `${path}.color`, err);
732
+ validatePalette(sprite, path, err);
733
+ } else if (sprite.kind === "stroke") {
734
+ knownSet = KNOWN_STROKE;
735
+ if (!isRange(sprite.length) || sprite.length[0] <= 0) err(`${path}.length`, "must be a [min,max] range of positive px (the mark's bounding size)");
736
+ validateShapePoints(sprite.points, `${path}.points`, 2, err);
737
+ if (sprite.width !== void 0 && (!isNum(sprite.width) || sprite.width <= 0)) err(`${path}.width`, "must be > 0");
738
+ if (sprite.curve !== void 0 && sprite.curve !== "smooth" && sprite.curve !== "linear") err(`${path}.curve`, "must be 'smooth' | 'linear'");
739
+ if (sprite.taper !== void 0 && typeof sprite.taper !== "boolean") err(`${path}.taper`, "must be a boolean");
740
+ if (sprite.orient !== void 0 && typeof sprite.orient !== "boolean") err(`${path}.orient`, "must be a boolean");
574
741
  color(sprite.color, `${path}.color`, err);
575
742
  validatePalette(sprite, path, err);
576
743
  } else if (sprite.kind === "textBlock") {
@@ -630,7 +797,7 @@ function validateSprite(sprite, path, err, warn) {
630
797
  }
631
798
  }
632
799
  } else {
633
- err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
800
+ err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | bar | polygon | stroke | textBlock");
634
801
  return;
635
802
  }
636
803
  for (const k of unknownKeys(sprite, knownSet)) {
@@ -683,6 +850,7 @@ function validateMotion(motion, path, err, warn, spec) {
683
850
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
684
851
  if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
685
852
  if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
853
+ validateEase(motion.ease, `${path}.ease`, err, warn);
686
854
  if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
687
855
  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`);
688
856
  }
@@ -690,6 +858,7 @@ function validateMotion(motion, path, err, warn, spec) {
690
858
  knownSet = KNOWN_RISE;
691
859
  speedOk(motion.speed, `${path}.speed`);
692
860
  if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
861
+ validateEase(motion.ease, `${path}.ease`, err, warn);
693
862
  if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
694
863
  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`);
695
864
  }
@@ -739,6 +908,7 @@ function validateMotion(motion, path, err, warn, spec) {
739
908
  }
740
909
  } else if (motion.type === "wander") {
741
910
  knownSet = KNOWN_WANDER;
911
+ validateEase(motion.ease, `${path}.ease`, err, warn);
742
912
  speedOk(motion.speed, `${path}.speed`);
743
913
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
744
914
  const meanderCap = isViewport ? LIMITS.maxMeander / refVp : LIMITS.maxMeander;
@@ -790,6 +960,28 @@ function validateMotion(motion, path, err, warn, spec) {
790
960
  warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
791
961
  }
792
962
  }
963
+ function validateShapePoints(points, path, min, err) {
964
+ if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
965
+ return err(path, `must be ${min}..${LIMITS.maxShapePoints} [x, y] pairs in unit coordinates (-1..1)`);
966
+ }
967
+ for (let i = 0; i < points.length; i++) {
968
+ const pt = points[i];
969
+ if (!Array.isArray(pt) || pt.length !== 2 || !isNum(pt[0]) || !isNum(pt[1]) || Math.abs(pt[0]) > 1 || Math.abs(pt[1]) > 1) {
970
+ err(`${path}[${i}]`, "must be an [x, y] pair with each coordinate in -1..1");
971
+ }
972
+ }
973
+ }
974
+ function validateEase(ease, path, err, warn) {
975
+ if (ease === void 0) return;
976
+ if (!isObj(ease)) return err(path, "must be an object { type: 'settle' | 'buoyant', tau }");
977
+ if (ease.type !== "settle" && ease.type !== "buoyant") err(`${path}.type`, "must be 'settle' | 'buoyant'");
978
+ if (!isNum(ease.tau) || ease.tau < LIMITS.minEaseTau || ease.tau > LIMITS.maxEaseTau) {
979
+ err(`${path}.tau`, `must be ${LIMITS.minEaseTau}..${LIMITS.maxEaseTau} ms`);
980
+ }
981
+ for (const k of unknownKeys(ease, /* @__PURE__ */ new Set(["type", "tau"]))) {
982
+ warn(`${path}.${k}`, "unknown-property", `unknown ease property '${k}' \u2014 will be ignored`);
983
+ }
984
+ }
793
985
  function assertValidSpec(spec) {
794
986
  const r = validateSpec(spec);
795
987
  if (!r.valid) {
@@ -907,6 +1099,110 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
907
1099
  return seq;
908
1100
  }
909
1101
 
1102
+ // src/shapes.ts
1103
+ function isShapedSprite(s) {
1104
+ return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
1105
+ }
1106
+ function barFraction(s, index) {
1107
+ const n = s.values.length;
1108
+ if (n === 0) return 0;
1109
+ const v = s.values[(index % n + n) % n] ?? 0;
1110
+ const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
1111
+ if (!(max > 0)) return 0;
1112
+ return Math.max(0, Math.min(1, v / max));
1113
+ }
1114
+ function barBox(direction, len, thick) {
1115
+ switch (direction) {
1116
+ case "left":
1117
+ return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1118
+ case "up":
1119
+ return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
1120
+ case "down":
1121
+ return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
1122
+ default:
1123
+ return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1124
+ }
1125
+ }
1126
+ var DEFAULT_POLYGON_SIDES = 6;
1127
+ var STROKE_SAMPLES = 24;
1128
+ function polygonPoints(s, radius) {
1129
+ if (s.points && s.points.length >= 3) {
1130
+ return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
1131
+ }
1132
+ const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
1133
+ const out = [];
1134
+ for (let k = 0; k < n; k++) {
1135
+ const a = -Math.PI / 2 + 2 * Math.PI * k / n;
1136
+ out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
1137
+ }
1138
+ return out;
1139
+ }
1140
+ function polygonArea(pts) {
1141
+ let a = 0;
1142
+ for (let i = 0; i < pts.length; i++) {
1143
+ const p = pts[i];
1144
+ const q = pts[(i + 1) % pts.length];
1145
+ a += p.x * q.y - q.x * p.y;
1146
+ }
1147
+ return Math.abs(a) / 2;
1148
+ }
1149
+ function polygonFill(s, radius) {
1150
+ if (radius <= 0) return 0;
1151
+ const disc = Math.PI * radius * radius;
1152
+ return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
1153
+ }
1154
+ function catmullRom(p0, p1, p2, p3, u) {
1155
+ const u2 = u * u;
1156
+ const u3 = u2 * u;
1157
+ return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
1158
+ }
1159
+ function strokeSamples(s, halfSize, n) {
1160
+ const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
1161
+ const m = pts.length;
1162
+ if (m < 2) return pts;
1163
+ const smooth = s.curve !== "linear" && m >= 3;
1164
+ const segs = m - 1;
1165
+ n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
1166
+ const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
1167
+ const out = [];
1168
+ for (let i = 0; i < n; i++) {
1169
+ const u = i / (n - 1) * segs;
1170
+ const seg = Math.min(segs - 1, Math.floor(u));
1171
+ const local = u - seg;
1172
+ const p1 = at(seg);
1173
+ const p2 = at(seg + 1);
1174
+ if (!smooth) {
1175
+ out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
1176
+ } else {
1177
+ const p0 = at(seg - 1);
1178
+ const p3 = at(seg + 2);
1179
+ out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
1180
+ }
1181
+ }
1182
+ return out;
1183
+ }
1184
+ function pathLength(pts) {
1185
+ let l = 0;
1186
+ for (let i = 1; i < pts.length; i++) {
1187
+ const dx = pts[i].x - pts[i - 1].x;
1188
+ const dy = pts[i].y - pts[i - 1].y;
1189
+ l += Math.sqrt(dx * dx + dy * dy);
1190
+ }
1191
+ return l;
1192
+ }
1193
+ function strokeTaper(u) {
1194
+ return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
1195
+ }
1196
+ function strokeWidthPx(s, scale) {
1197
+ return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
1198
+ }
1199
+ function featherAlphas(steps, additive) {
1200
+ const out = [];
1201
+ for (let k = 1; k <= steps; k++) out.push(additive ? 1 / steps : 1 / (steps - k + 1));
1202
+ return out;
1203
+ }
1204
+ var FEATHER_STEPS = 6;
1205
+
910
1206
  // src/simulate.ts
911
1207
  var WARP_NEAR = 0.08;
912
1208
  var WARP_MAX_SCALE = 8;
@@ -935,6 +1231,21 @@ function drawOsc(rng, amp) {
935
1231
  }
936
1232
  return osc;
937
1233
  }
1234
+ var GOLDEN = 0.6180339887498949;
1235
+ function emitParams(emit, i, n) {
1236
+ const every = emit.every;
1237
+ const jitter = Math.max(0, Math.min(1, emit.jitter ?? 1));
1238
+ const scattered = (i + 0.5) * GOLDEN % 1 * every;
1239
+ const even = i / Math.max(1, n) * every;
1240
+ const phase = ((even * (1 - jitter) + scattered * jitter) % every + every) % every;
1241
+ return {
1242
+ every,
1243
+ life: Math.min(emit.life, every),
1244
+ phase,
1245
+ growFrom: emit.grow?.[0] ?? 1,
1246
+ growTo: emit.grow?.[1] ?? 1
1247
+ };
1248
+ }
938
1249
  function weightedIndex(u, weights) {
939
1250
  const total = weights.reduce((a, b) => a + b, 0);
940
1251
  let acc = 0;
@@ -945,15 +1256,16 @@ function weightedIndex(u, weights) {
945
1256
  return weights.length - 1;
946
1257
  }
947
1258
  function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
948
- const effectiveCount = countScale === 1 || layer.layout?.type === "grid" ? layer.count : Math.max(1, Math.min(
1259
+ const effectiveCount = countScale === 1 || layer.layout !== void 0 ? layer.count : Math.max(1, Math.min(
949
1260
  Math.round(layer.count * countScale),
950
1261
  layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
951
1262
  ));
952
- const [smin, smax] = layer.size ?? [20, 40];
1263
+ const defaultSize = scale === 1 ? [20, 40] : [20 / LIMITS.referenceViewport, 40 / LIMITS.referenceViewport];
1264
+ const [smin, smax] = layer.size ?? defaultSize;
953
1265
  const variants = spriteVariants(layer.sprite);
954
1266
  const sprite = layer.sprite;
955
- const spriteColors = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors : void 0;
956
- const colorWeights = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colorWeights : void 0;
1267
+ const spriteColors = isShapedSprite(sprite) ? sprite.colors : void 0;
1268
+ const colorWeights = isShapedSprite(sprite) ? sprite.colorWeights : void 0;
957
1269
  const colorsLen = spriteColors?.length ?? 0;
958
1270
  const cycle = sprite.kind === "emoji" || sprite.kind === "text" ? sprite.cycle : void 0;
959
1271
  const mSpec = layer.motion;
@@ -967,7 +1279,32 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
967
1279
  if (mSpec.type === "path") {
968
1280
  pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
969
1281
  }
1282
+ const ease = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
1283
+ const clockPhase = layer.clock ? (layer.clock.phase ?? 0) % 1 * Math.PI * 2 : 0;
970
1284
  const layout = layer.layout;
1285
+ const ordered = layout?.type === "list" || layout?.type === "table";
1286
+ let gapX = 0;
1287
+ let gapY = 0;
1288
+ let listX0 = 0;
1289
+ let listY0 = 0;
1290
+ let listCols = 1;
1291
+ if (layout?.type === "list" || layout?.type === "table") {
1292
+ const dflt = scale === 1 ? LIMITS.defaultListGapPx : LIMITS.defaultListGap;
1293
+ const g = layout.gap ?? dflt;
1294
+ gapX = (typeof g === "number" ? g : g.x ?? dflt) * scale;
1295
+ gapY = (typeof g === "number" ? g : g.y ?? dflt) * scale;
1296
+ listCols = layout.type === "list" ? 1 : Math.max(1, Math.round(layout.columns));
1297
+ const rows = Math.max(1, Math.ceil(effectiveCount / listCols));
1298
+ if (layer.position) {
1299
+ listX0 = layer.position.x * w;
1300
+ listY0 = layer.position.y * h;
1301
+ } else {
1302
+ const [rx0, rx1] = layer.region?.x ?? [0, 1];
1303
+ const [ry0, ry1] = layer.region?.y ?? [0, 1];
1304
+ listX0 = (rx0 + rx1) / 2 * w - (listCols - 1) * gapX / 2;
1305
+ listY0 = (ry0 + ry1) / 2 * h - (rows - 1) * gapY / 2;
1306
+ }
1307
+ }
971
1308
  let gridCols = 0;
972
1309
  let cellW = 0;
973
1310
  let cellH = 0;
@@ -990,8 +1327,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
990
1327
  }
991
1328
  const out = [];
992
1329
  for (let i = 0; i < effectiveCount; i++) {
993
- const size = sprite.kind === "textBlock" ? sprite.fontSize * scale : 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;
994
- const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : void 0;
1330
+ const size = sprite.kind === "textBlock" ? sprite.fontSize * scale : sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "polygon" ? rng.range(sprite.radius[0], sprite.radius[1]) * 2 * scale : sprite.kind === "streak" || sprite.kind === "stroke" ? rng.range(sprite.length[0], sprite.length[1]) * scale : sprite.kind === "rect" ? rng.range(sprite.width[0], sprite.width[1]) * scale : sprite.kind === "bar" ? sprite.length * scale : rng.range(smin, smax) * scale;
1331
+ const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : sprite.kind === "bar" ? sprite.thickness * scale : void 0;
995
1332
  let vx = 0;
996
1333
  let vy = 0;
997
1334
  let bob = 0;
@@ -1055,6 +1392,11 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1055
1392
  if (layer.position && layer.count === 1) {
1056
1393
  x0 = layer.position.x * w;
1057
1394
  y0 = layer.position.y * h;
1395
+ } else if (layout?.type === "list" || layout?.type === "table") {
1396
+ rng.next();
1397
+ rng.next();
1398
+ x0 = listX0 + i % listCols * gapX;
1399
+ y0 = listY0 + Math.floor(i / listCols) * gapY;
1058
1400
  } else if (layout?.type === "grid") {
1059
1401
  const col = i % gridCols;
1060
1402
  const row = Math.floor(i / gridCols);
@@ -1074,7 +1416,10 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1074
1416
  x0,
1075
1417
  y0,
1076
1418
  size,
1077
- spriteIndex: variants > 1 ? rng.int(0, variants - 1) : 0,
1419
+ // Data layouts read variants in order — strings[i] beside bar i. The
1420
+ // seeded draw is still consumed so a layout is placement only: toggling
1421
+ // list/table never disturbs the layer's alpha, pulse or spin stream.
1422
+ spriteIndex: variants > 1 ? ordered ? (rng.int(0, variants - 1), i % variants) : rng.int(0, variants - 1) : 0,
1078
1423
  phase: rng.range(0, Math.PI * 2),
1079
1424
  vx,
1080
1425
  vy,
@@ -1098,11 +1443,14 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1098
1443
  orbitCx: m.type === "orbit" && !orbitParent ? (m.center?.x ?? 0.5) * w : 0,
1099
1444
  orbitCy: m.type === "orbit" && !orbitParent ? (m.center?.y ?? 0.5) * h : 0,
1100
1445
  // New draws AFTER orbit — guarded so existing specs keep identical streams.
1101
- colorIndex: colorsLen > 0 ? colorWeights && colorWeights.length === colorsLen ? weightedIndex(rng.next(), colorWeights) : rng.int(0, colorsLen - 1) : -1,
1446
+ colorIndex: colorsLen > 0 ? colorWeights && colorWeights.length === colorsLen ? weightedIndex(rng.next(), colorWeights) : ordered ? (rng.int(0, colorsLen - 1), i % colorsLen) : rng.int(0, colorsLen - 1) : -1,
1102
1447
  cyclePeriod: cycle ? cycle.period : 0,
1103
1448
  // Optional feature fields last (guarded draws inside — wander draws 18 here).
1104
1449
  ...size2 !== void 0 ? { size2 } : {},
1450
+ ...sprite.kind === "bar" ? { barIndex: i } : {},
1451
+ ...ordered ? { ordered: true } : {},
1105
1452
  ...orbitParent ? { orbitParent } : {},
1453
+ ...layer.wrap === false ? { wrapDisabled: true } : {},
1106
1454
  ...m.type === "wander" ? {
1107
1455
  wander: {
1108
1456
  own: drawOsc(rng, meanderPx),
@@ -1122,33 +1470,80 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1122
1470
  offX: pathOffX,
1123
1471
  offY: pathOffY
1124
1472
  }
1125
- } : {}
1473
+ } : {},
1474
+ // Time-structure fields (2026-09): ease draws nothing; emit's one draw
1475
+ // per entity happens in a pass AFTER this loop (see below).
1476
+ ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {}
1126
1477
  });
1478
+ if (layer.clock) {
1479
+ const ent = out[out.length - 1];
1480
+ ent.pulsePhase = clockPhase;
1481
+ ent.growPhase = clockPhase;
1482
+ ent.cyclePhase = clockPhase;
1483
+ ent.clockRate = layer.clock.rate ?? 1;
1484
+ }
1127
1485
  if (layer.pulse?.wave) {
1128
1486
  const wl = Math.max(1e-6, layer.pulse.wave.wavelength * scale);
1129
1487
  const ang = (layer.pulse.wave.angle ?? 0) * Math.PI / 180;
1130
- out[out.length - 1].pulsePhase = -((x0 * Math.cos(ang) + y0 * Math.sin(ang)) / wl) * Math.PI * 2;
1488
+ out[out.length - 1].pulsePhase = clockPhase - (x0 * Math.cos(ang) + y0 * Math.sin(ang)) / wl * Math.PI * 2;
1131
1489
  }
1132
1490
  }
1491
+ if (layer.emit) {
1492
+ for (let i = 0; i < out.length; i++) out[i].emit = emitParams(layer.emit, i, out.length);
1493
+ }
1133
1494
  return out;
1134
1495
  }
1135
1496
  function warpZ(e, t) {
1136
1497
  const wp = e.warp;
1137
1498
  return wrap(wp.z0 - wp.vz * (t / 1e3), WARP_NEAR, 1);
1138
1499
  }
1500
+ function smoothstep(x) {
1501
+ const c = x < 0 ? 0 : x > 1 ? 1 : x;
1502
+ return c * c * (3 - 2 * c);
1503
+ }
1504
+ var EMIT_ATTACK = 0.25;
1505
+ function emitWindow(e, t) {
1506
+ const em = e.emit;
1507
+ if (!em) return null;
1508
+ const local = ((t - em.phase) % em.every + em.every) % em.every;
1509
+ return local < em.life ? local / em.life : null;
1510
+ }
1511
+ function emitEnvelope(u) {
1512
+ if (u < 0 || u >= 1) return 0;
1513
+ return u < EMIT_ATTACK ? smoothstep(u / EMIT_ATTACK) : 1 - smoothstep((u - EMIT_ATTACK) / (1 - EMIT_ATTACK));
1514
+ }
1515
+ function clockTime(e, t) {
1516
+ return e.clockRate ? t * e.clockRate : t;
1517
+ }
1518
+ function motionSeconds(e, t) {
1519
+ if (!e.ease) return t / 1e3;
1520
+ let tl = t;
1521
+ if (e.emit) tl = ((t - e.emit.phase) % e.emit.every + e.emit.every) % e.emit.every;
1522
+ const tau = e.ease.tau;
1523
+ const k = 1 - Math.exp(-tl / tau);
1524
+ return (e.ease.type === "settle" ? tau * k : tl - tau * k) / 1e3;
1525
+ }
1139
1526
  function alphaAt(e, t) {
1140
1527
  let a = e.alpha;
1141
- if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
1528
+ if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(clockTime(e, t) * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
1142
1529
  if (e.motion === "warp") {
1143
1530
  const z = warpZ(e, t);
1144
1531
  a *= Math.max(0, Math.min(1, (1 - z) / 0.2));
1145
1532
  }
1533
+ if (e.emit) {
1534
+ const u = emitWindow(e, t);
1535
+ a = u === null ? 0 : a * emitEnvelope(u);
1536
+ }
1146
1537
  return a < 0 ? 0 : a > 1 ? 1 : a;
1147
1538
  }
1148
1539
  function sizeAt(e, t) {
1149
1540
  let s = e.size;
1150
- if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
1541
+ if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(clockTime(e, t) * 2 * Math.PI / e.growPeriod + e.growPhase));
1151
1542
  if (e.motion === "warp") s *= Math.min(1 / warpZ(e, t), WARP_MAX_SCALE);
1543
+ if (e.emit && (e.emit.growFrom !== 1 || e.emit.growTo !== 1)) {
1544
+ const u = emitWindow(e, t);
1545
+ if (u !== null) s *= e.emit.growFrom + (e.emit.growTo - e.emit.growFrom) * u;
1546
+ }
1152
1547
  return s > 0 ? s : 0.1;
1153
1548
  }
1154
1549
  function rotationAt(e, t) {
@@ -1179,7 +1574,7 @@ function reflect(v, min, max) {
1179
1574
  const p = ((v - min) % period + period) % period;
1180
1575
  return min + (p < range ? p : period - p);
1181
1576
  }
1182
- function catmullRom(p0, p1, p2, p3, u) {
1577
+ function catmullRom2(p0, p1, p2, p3, u) {
1183
1578
  const u2 = u * u;
1184
1579
  const u3 = u2 * u;
1185
1580
  return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
@@ -1209,8 +1604,8 @@ function pathPosition(e, t) {
1209
1604
  const p0 = at(i - 1);
1210
1605
  const p3 = at(i + 2);
1211
1606
  return {
1212
- x: catmullRom(p0.x, p1.x, p2.x, p3.x, local) + p.offX,
1213
- y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) + p.offY
1607
+ x: catmullRom2(p0.x, p1.x, p2.x, p3.x, local) + p.offX,
1608
+ y: catmullRom2(p0.y, p1.y, p2.y, p3.y, local) + p.offY
1214
1609
  };
1215
1610
  }
1216
1611
  function positionAt(e, t, w, h) {
@@ -1237,7 +1632,7 @@ function positionAt(e, t, w, h) {
1237
1632
  const pos = pathPosition(e, t);
1238
1633
  return { x: pos.x, y: pos.y, flip: false };
1239
1634
  }
1240
- const dt = t / 1e3;
1635
+ const dt = motionSeconds(e, t);
1241
1636
  const m = e.size;
1242
1637
  if (e.motion === "bounce") {
1243
1638
  return {
@@ -1247,9 +1642,10 @@ function positionAt(e, t, w, h) {
1247
1642
  };
1248
1643
  }
1249
1644
  if (e.motion === "rise") {
1645
+ const y2 = e.y0 + e.vy * dt;
1250
1646
  return {
1251
1647
  x: e.x0 + (e.bob ? e.bob * Math.sin(t / 700 + e.phase) : 0),
1252
- y: wrap(e.y0 + e.vy * dt, -m, h + m),
1648
+ y: e.wrapDisabled ? y2 : wrap(y2, -m, h + m),
1253
1649
  flip: false
1254
1650
  };
1255
1651
  }
@@ -1269,14 +1665,18 @@ function positionAt(e, t, w, h) {
1269
1665
  }
1270
1666
  }
1271
1667
  const margin = m + wp.margin;
1668
+ const x2 = e.x0 + e.vx * dt + hx;
1669
+ const y2 = e.y0 + e.vy * dt + hy;
1272
1670
  return {
1273
- x: wrap(e.x0 + e.vx * dt + hx, -margin, w + margin),
1274
- y: wrap(e.y0 + e.vy * dt + hy, -margin, h + margin),
1671
+ x: e.wrapDisabled ? x2 : wrap(x2, -margin, w + margin),
1672
+ y: e.wrapDisabled ? y2 : wrap(y2, -margin, h + margin),
1275
1673
  flip: e.headingLeft
1276
1674
  };
1277
1675
  }
1278
- const x = wrap(e.x0 + e.vx * dt, -m, w + m);
1279
- let y = e.vy !== 0 ? wrap(e.y0 + e.vy * dt, -m, h + m) : e.y0;
1676
+ const rawX = e.x0 + e.vx * dt;
1677
+ const x = e.wrapDisabled ? rawX : wrap(rawX, -m, w + m);
1678
+ const rawY = e.y0 + e.vy * dt;
1679
+ let y = e.vy !== 0 && !e.wrapDisabled ? wrap(rawY, -m, h + m) : rawY;
1280
1680
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
1281
1681
  return { x, y, flip: e.headingLeft };
1282
1682
  }
@@ -1294,7 +1694,8 @@ function headingAt(e, t, w, h) {
1294
1694
  }
1295
1695
  function spriteIndexAt(e, t, variants) {
1296
1696
  if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
1297
- return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
1697
+ if (e.ordered) return (e.spriteIndex + Math.floor(clockTime(e, t) / e.cyclePeriod)) % variants;
1698
+ return Math.floor(clockTime(e, t) / e.cyclePeriod + (e.cyclePhase ?? e.phase) / (2 * Math.PI)) % variants;
1298
1699
  }
1299
1700
  function wrapDist(pi, pj, doWrap, w, h) {
1300
1701
  let dx = pj.x - pi.x;
@@ -1756,7 +2157,7 @@ var SpecInstance = class {
1756
2157
  const headAlpha = alphaAt(e, t) * lifeA;
1757
2158
  const headSize = sizeAt(e, t);
1758
2159
  const sprite = built.layer.sprite;
1759
- const resolvedColor = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
2160
+ const resolvedColor = isShapedSprite(sprite) ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
1760
2161
  const isSoft = sprite.kind === "circle" && sprite.soft;
1761
2162
  const wrap2 = built.layer.wrap !== false;
1762
2163
  const head = this.entityPos(e, t, parentE);
@@ -1835,7 +2236,90 @@ var SpecInstance = class {
1835
2236
  ctx.translate(p.x, p.y);
1836
2237
  if (rot) ctx.rotate(rot);
1837
2238
  ctx.fillStyle = resolvedColor;
1838
- ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
2239
+ const feather = sprite.feather ?? 0;
2240
+ if (feather > 0) {
2241
+ const base = ctx.globalAlpha;
2242
+ const additive = built.layer.blend === "lighter";
2243
+ const alphas = featherAlphas(FEATHER_STEPS, additive);
2244
+ for (let j = 1; j <= FEATHER_STEPS; j++) {
2245
+ const f = 1 - feather * (j - 1) / FEATHER_STEPS;
2246
+ const fw = sz * f;
2247
+ const fh = rh * f;
2248
+ ctx.globalAlpha = base * alphas[j - 1];
2249
+ ctx.fillRect(-fw / 2, -fh / 2, fw, fh);
2250
+ }
2251
+ ctx.globalAlpha = base;
2252
+ } else {
2253
+ ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
2254
+ }
2255
+ ctx.restore();
2256
+ return;
2257
+ }
2258
+ if (sprite.kind === "bar") {
2259
+ const len = sz * barFraction(sprite, e.barIndex ?? 0);
2260
+ if (len <= 0) return;
2261
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
2262
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
2263
+ const box = barBox(sprite.direction ?? "right", len, th);
2264
+ ctx.save();
2265
+ ctx.translate(p.x, p.y);
2266
+ if (rot) ctx.rotate(rot);
2267
+ ctx.fillStyle = resolvedColor;
2268
+ ctx.fillRect(box.cx - box.halfX, box.cy - box.halfY, box.halfX * 2, box.halfY * 2);
2269
+ ctx.restore();
2270
+ return;
2271
+ }
2272
+ if (sprite.kind === "polygon") {
2273
+ const r = sz / 2;
2274
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
2275
+ const pts = polygonPoints(sprite, r);
2276
+ ctx.save();
2277
+ ctx.translate(p.x, p.y);
2278
+ if (rot) ctx.rotate(rot);
2279
+ if (sprite.soft) {
2280
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
2281
+ g.addColorStop(0, resolvedColor);
2282
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
2283
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
2284
+ ctx.fillStyle = g;
2285
+ } else {
2286
+ ctx.fillStyle = resolvedColor;
2287
+ }
2288
+ ctx.beginPath();
2289
+ ctx.moveTo(pts[0].x, pts[0].y);
2290
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
2291
+ ctx.closePath();
2292
+ ctx.fill();
2293
+ ctx.restore();
2294
+ return;
2295
+ }
2296
+ if (sprite.kind === "stroke") {
2297
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
2298
+ const pts = strokeSamples(sprite, sz / 2);
2299
+ const lw = strokeWidthPx(sprite, unitScale);
2300
+ ctx.save();
2301
+ ctx.translate(p.x, p.y);
2302
+ let angle = rot;
2303
+ if (sprite.orient) angle += headingAt(e, t, this.w, this.h) ?? 0;
2304
+ if (angle) ctx.rotate(angle);
2305
+ ctx.strokeStyle = resolvedColor;
2306
+ ctx.lineCap = "round";
2307
+ ctx.lineJoin = "round";
2308
+ if (sprite.taper) {
2309
+ for (let i = 1; i < pts.length; i++) {
2310
+ ctx.lineWidth = Math.max(0.5, lw * strokeTaper((i - 0.5) / (pts.length - 1)));
2311
+ ctx.beginPath();
2312
+ ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
2313
+ ctx.lineTo(pts[i].x, pts[i].y);
2314
+ ctx.stroke();
2315
+ }
2316
+ } else {
2317
+ ctx.lineWidth = lw;
2318
+ ctx.beginPath();
2319
+ ctx.moveTo(pts[0].x, pts[0].y);
2320
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
2321
+ ctx.stroke();
2322
+ }
1839
2323
  ctx.restore();
1840
2324
  return;
1841
2325
  }
@@ -2000,7 +2484,9 @@ var SpecInstance = class {
2000
2484
  const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
2001
2485
  const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
2002
2486
  const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
2003
- const lw = (links.width ?? 1) * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
2487
+ const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
2488
+ const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
2489
+ const lw = (links.width ?? defaultWidth) * unitScale;
2004
2490
  ctx.lineWidth = lw;
2005
2491
  ctx.lineCap = "butt";
2006
2492
  for (const { i, j, dist } of edges) {
@@ -2010,7 +2496,7 @@ var SpecInstance = class {
2010
2496
  let resolvedColor = links.color;
2011
2497
  if (!resolvedColor) {
2012
2498
  const sprite = built.layer.sprite;
2013
- if (sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect") {
2499
+ if (isShapedSprite(sprite)) {
2014
2500
  resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
2015
2501
  } else resolvedColor = "#e6e8ef";
2016
2502
  }
@@ -2286,7 +2772,6 @@ var SequenceInstance = class {
2286
2772
  child.hotSwapSpec(this.childScene(index));
2287
2773
  } else {
2288
2774
  child = this.ensureChild(index);
2289
- if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
2290
2775
  }
2291
2776
  child.renderFrame(localT, effSeed);
2292
2777
  }
@@ -2373,7 +2858,7 @@ function hexLuma(hex) {
2373
2858
  }
2374
2859
  function spriteLuma(layer, e) {
2375
2860
  const s = layer.sprite;
2376
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
2861
+ if (isShapedSprite(s)) {
2377
2862
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
2378
2863
  }
2379
2864
  if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
@@ -2381,7 +2866,7 @@ function spriteLuma(layer, e) {
2381
2866
  }
2382
2867
  function spriteHex(layer, e) {
2383
2868
  const s = layer.sprite;
2384
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
2869
+ if (isShapedSprite(s)) {
2385
2870
  return s.colors?.[e.colorIndex] ?? s.color;
2386
2871
  }
2387
2872
  if (s.kind === "text" || s.kind === "textBlock") return s.color ?? "#e6e8ef";
@@ -2419,19 +2904,237 @@ function colourSeparation(layer, sprite, bg) {
2419
2904
  return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
2420
2905
  }
2421
2906
 
2907
+ // src/cohesion.ts
2908
+ var MERGE_FLOOR = 0.35;
2909
+ var WASH_ALPHA_MAX = 0.45;
2910
+ var COHESION_T = 5e3;
2911
+ var OUTLINE_SAMPLES = 24;
2912
+ var MAX_TRACED = 240;
2913
+ var EDGE_INSET = 1e-4;
2914
+ function circleShape(cx, cy, r) {
2915
+ return {
2916
+ cx,
2917
+ cy,
2918
+ br: r,
2919
+ area: Math.PI * r * r,
2920
+ contains: (x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r,
2921
+ outline: (n) => Array.from({ length: n }, (_, i) => {
2922
+ const a = i / n * Math.PI * 2;
2923
+ const inner = r * (1 - EDGE_INSET);
2924
+ return { x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) };
2925
+ })
2926
+ };
2927
+ }
2928
+ function polyShape(cx, cy, raw) {
2929
+ const abs = raw.filter((p, i) => {
2930
+ const q = raw[(i + 1) % raw.length];
2931
+ return Math.hypot(q.x - p.x, q.y - p.y) > 0;
2932
+ });
2933
+ if (abs.length < 3) return null;
2934
+ let br = 0;
2935
+ for (const p of abs) br = Math.max(br, Math.hypot(p.x - cx, p.y - cy));
2936
+ const inside = (x, y) => {
2937
+ let hit = false;
2938
+ for (let i = 0, j = abs.length - 1; i < abs.length; j = i++) {
2939
+ const a = abs[i];
2940
+ const b = abs[j];
2941
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) hit = !hit;
2942
+ }
2943
+ return hit;
2944
+ };
2945
+ const seg = abs.map((p, i) => {
2946
+ const q = abs[(i + 1) % abs.length];
2947
+ return Math.hypot(q.x - p.x, q.y - p.y);
2948
+ });
2949
+ const perimeter = seg.reduce((a, b) => a + b, 0);
2950
+ const shortest = Math.min(...seg);
2951
+ const step = Math.min(br * EDGE_INSET, shortest * 0.25);
2952
+ const area = polygonArea(abs);
2953
+ if (!(area > 0)) return null;
2954
+ return {
2955
+ cx,
2956
+ cy,
2957
+ br,
2958
+ area,
2959
+ contains: inside,
2960
+ /**
2961
+ * `n` points spread along the perimeter by ARC LENGTH, not one share per
2962
+ * edge — a long skinny rectangle has two edges carrying almost all of its
2963
+ * perimeter, and an equal-per-edge split would report a fully buried long
2964
+ * side as only a quarter covered. Each point is then stepped along its
2965
+ * edge's inward normal, whichever of the two candidates lands inside.
2966
+ */
2967
+ outline: (n) => {
2968
+ if (!(perimeter > 0)) return [];
2969
+ const out = [];
2970
+ for (let i = 0; i < n; i++) {
2971
+ let d = i / n * perimeter;
2972
+ let k = 0;
2973
+ while (k < seg.length - 1 && d > seg[k]) {
2974
+ d -= seg[k];
2975
+ k++;
2976
+ }
2977
+ const a = abs[k];
2978
+ const b = abs[(k + 1) % abs.length];
2979
+ const len = seg[k];
2980
+ const f = d / len;
2981
+ const px = a.x + (b.x - a.x) * f;
2982
+ const py = a.y + (b.y - a.y) * f;
2983
+ const ux = (b.x - a.x) / len;
2984
+ const uy = (b.y - a.y) / len;
2985
+ for (let t = step; t > 0; t /= 8) {
2986
+ const qx1 = px - uy * t;
2987
+ const qy1 = py + ux * t;
2988
+ if (inside(qx1, qy1)) {
2989
+ out.push({ x: qx1, y: qy1 });
2990
+ break;
2991
+ }
2992
+ const qx2 = px + uy * t;
2993
+ const qy2 = py - ux * t;
2994
+ if (inside(qx2, qy2)) {
2995
+ out.push({ x: qx2, y: qy2 });
2996
+ break;
2997
+ }
2998
+ if (t < step * 1e-3) break;
2999
+ }
3000
+ }
3001
+ return out;
3002
+ }
3003
+ };
3004
+ }
3005
+ function rectShape(cx, cy, halfW, halfH, rot) {
3006
+ const cos = Math.cos(rot);
3007
+ const sin = Math.sin(rot);
3008
+ const corner = (lx, ly) => ({ x: cx + lx * cos - ly * sin, y: cy + lx * sin + ly * cos });
3009
+ return polyShape(cx, cy, [
3010
+ corner(-halfW, -halfH),
3011
+ corner(halfW, -halfH),
3012
+ corner(halfW, halfH),
3013
+ corner(-halfW, halfH)
3014
+ ]);
3015
+ }
3016
+ function polygonShape(cx, cy, pts, rot) {
3017
+ const cos = Math.cos(rot);
3018
+ const sin = Math.sin(rot);
3019
+ return polyShape(cx, cy, pts.map((p) => ({ x: cx + p.x * cos - p.y * sin, y: cy + p.x * sin + p.y * cos })));
3020
+ }
3021
+ function shapeOf(layer, e, t, w, h) {
3022
+ const s = layer.sprite;
3023
+ if (s.kind !== "circle" && s.kind !== "rect" && s.kind !== "polygon") return null;
3024
+ const p = positionAt(e, t, w, h);
3025
+ const sz = sizeAt(e, t);
3026
+ if (!(sz > 0)) return null;
3027
+ if (s.kind === "circle") return circleShape(p.x, p.y, sz / 2);
3028
+ const rot = rotationAt(e, t);
3029
+ if (s.kind === "rect") {
3030
+ const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
3031
+ return rectShape(p.x, p.y, sz / 2, h2 / 2, rot);
3032
+ }
3033
+ return polygonShape(p.x, p.y, polygonPoints(s, sz / 2), rot);
3034
+ }
3035
+ function seamCauseOf(layer) {
3036
+ const s = layer.sprite;
3037
+ if ((s.kind === "circle" || s.kind === "polygon") && s.soft) {
3038
+ return "sprite.soft \u2014 radial falloff never forms a hard union";
3039
+ }
3040
+ if (s.kind === "rect" && s.feather) {
3041
+ return "rect.feather \u2014 a feathered edge never forms a hard union";
3042
+ }
3043
+ if (layer.blend) {
3044
+ return `blend: ${layer.blend} \u2014 overlaps composite instead of covering`;
3045
+ }
3046
+ const [lo, hi] = layer.alpha ?? [1, 1];
3047
+ if (lo < 1 || hi < 1) {
3048
+ return `alpha ${lo === hi ? lo : `${lo}..${hi}`} \u2014 every overlap paints a darker patch`;
3049
+ }
3050
+ if (layer.pulse && layer.pulse.amp > 0) {
3051
+ return "pulse \u2014 the layer is translucent for part of every cycle";
3052
+ }
3053
+ const palette = "colors" in s && Array.isArray(s.colors) ? s.colors : null;
3054
+ const weights = "colorWeights" in s && Array.isArray(s.colorWeights) ? s.colorWeights : null;
3055
+ if (palette) {
3056
+ const aligned = weights !== null && weights.length === palette.length;
3057
+ const reachable = new Set(palette.filter((_, i) => !aligned || (weights[i] ?? 0) > 0));
3058
+ if (reachable.size > 1) {
3059
+ return `sprite.colors has ${reachable.size} reachable colours \u2014 neighbours of unlike colour show their join`;
3060
+ }
3061
+ }
3062
+ return null;
3063
+ }
3064
+ function cohesionOf(layers, t, w, h) {
3065
+ return layers.map(({ layer, entities }, layerIndex) => {
3066
+ const seamCause = seamCauseOf(layer);
3067
+ const base = {
3068
+ layerIndex,
3069
+ key: layer.key,
3070
+ seamless: seamCause === null,
3071
+ seamCause
3072
+ };
3073
+ if (lifeAlphaAt(layer.life, t) <= 0) return { ...base, overlap: null, reads: null };
3074
+ const shapes = entities.map((e) => shapeOf(layer, e, t, w, h));
3075
+ const solid = shapes.filter((x) => x !== null && x.area > 0);
3076
+ if (solid.length < 2) return { ...base, overlap: null, reads: null };
3077
+ const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
3078
+ let covered = 0;
3079
+ let traced = 0;
3080
+ for (let i = 0; i < solid.length; i += stride) {
3081
+ const self = solid[i];
3082
+ const pts = self.outline(OUTLINE_SAMPLES);
3083
+ if (pts.length === 0) continue;
3084
+ let hit = 0;
3085
+ for (const p of pts) {
3086
+ for (let j = 0; j < solid.length; j++) {
3087
+ if (j === i) continue;
3088
+ const other = solid[j];
3089
+ const dx = p.x - other.cx;
3090
+ const dy = p.y - other.cy;
3091
+ if (dx * dx + dy * dy > other.br * other.br) continue;
3092
+ if (other.contains(p.x, p.y)) {
3093
+ hit++;
3094
+ break;
3095
+ }
3096
+ }
3097
+ }
3098
+ covered += hit / pts.length;
3099
+ traced++;
3100
+ }
3101
+ if (traced === 0) return { ...base, overlap: null, reads: null };
3102
+ const overlap = Number((covered / traced).toFixed(4));
3103
+ const reads = overlap < MERGE_FLOOR ? "marks" : seamCause === null ? "mass" : "seamed";
3104
+ return { ...base, overlap, reads };
3105
+ });
3106
+ }
3107
+ function seamsWorthWarning(layer, c, entityCount) {
3108
+ if (c.reads !== "seamed" || entityCount < 6) return false;
3109
+ const [lo, hi] = layer.alpha ?? [1, 1];
3110
+ if ((lo + hi) / 2 < WASH_ALPHA_MAX) return false;
3111
+ return layer.blend !== "lighter" && layer.blend !== "screen";
3112
+ }
3113
+
2422
3114
  // src/advise.ts
2423
3115
  var LOW_CONTRAST_FLOOR = 0.05;
2424
- function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
3116
+ var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
3117
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
2425
3118
  const warnings = [];
2426
3119
  const w = viewport.width;
2427
3120
  const h = viewport.height;
2428
3121
  const scale = spec.units === "px" ? 1 : Math.min(w, h);
2429
3122
  const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
2430
- const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
2431
- const rng = createRng2(spec.seed ?? 42);
3123
+ let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
3124
+ if (countScale > 1) {
3125
+ const rawTotal = spec.layers.reduce((sum, l) => sum + Math.round(l.count * countScale), 0);
3126
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
3127
+ }
3128
+ const rng = createRng2(opts.seed ?? spec.seed ?? 42);
2432
3129
  const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
2433
3130
  const bgLuma = backgroundLuma(spec);
2434
3131
  const bgRgb = backgroundRgb(spec);
3132
+ const cohesion = cohesionOf(
3133
+ spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
3134
+ opts.t ?? COHESION_T,
3135
+ w,
3136
+ h
3137
+ );
2435
3138
  let totalEntities = 0;
2436
3139
  let textLayerCount = 0;
2437
3140
  let motionLayerCount = 0;
@@ -2443,6 +3146,14 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2443
3146
  const isStaticText = isText && layer.motion.type === "static";
2444
3147
  if (isStaticText) textLayerCount++;
2445
3148
  if (layer.motion.type !== "static") motionLayerCount++;
3149
+ const coh = cohesion[li];
3150
+ if (seamsWorthWarning(layer, coh, entities.length)) {
3151
+ warnings.push({
3152
+ path: `layers[${li}]`,
3153
+ code: "overlap-seams",
3154
+ message: `entities bury ${Math.round((coh.overlap ?? 0) * 100)}% of each other's outlines, so this layer is drawn as one shape \u2014 but ${coh.seamCause}, so every overlap paints a visible edge and it will read as a pile of sprites instead. A layer merges into a single silhouette only when it is one flat colour at alpha 1, hard-edged, with no blend and no pulse.`
3155
+ });
3156
+ }
2446
3157
  if (layer.trail && layer.motion.type === "static") {
2447
3158
  warnings.push({
2448
3159
  path: `layers[${li}].trail`,
@@ -2450,11 +3161,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2450
3161
  message: "trail has no effect on static entities \u2014 they have no past positions to draw"
2451
3162
  });
2452
3163
  }
2453
- if (layer.sprite.kind === "streak" && layer.motion.type === "static") {
3164
+ if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static") {
2454
3165
  warnings.push({
2455
3166
  path: `layers[${li}].sprite`,
2456
3167
  code: "streak-on-static",
2457
- message: "streak sprites orient along the motion heading \u2014 static entities have none and will render at angle 0"
3168
+ message: `${layer.sprite.kind === "streak" ? "streak sprites orient" : "an oriented stroke turns"} along the motion heading \u2014 static entities have none, so the heading contributes nothing${layer.spin ? " (spin still rotates the mark)" : " and it renders at angle 0"}`
2458
3169
  });
2459
3170
  }
2460
3171
  if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
@@ -2501,6 +3212,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2501
3212
  let pixArea;
2502
3213
  if (layer.sprite.kind === "circle") {
2503
3214
  pixArea = Math.PI * r * r;
3215
+ } else if (layer.sprite.kind === "bar") {
3216
+ pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
2504
3217
  } else if (layer.sprite.kind === "textBlock") {
2505
3218
  const fsPx = layer.sprite.fontSize * scale;
2506
3219
  const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
@@ -2510,31 +3223,53 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2510
3223
  } else {
2511
3224
  pixArea = e.size * e.size;
2512
3225
  }
2513
- totalCoverage += pixArea * e.alpha / (w * h);
3226
+ let duty = 1;
3227
+ if (layer.emit) {
3228
+ const [ga, gb] = layer.emit.grow ?? [1, 1];
3229
+ const g2 = (ga * ga + ga * gb + gb * gb) / 3;
3230
+ duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
3231
+ }
3232
+ totalCoverage += pixArea * e.alpha * duty / (w * h);
2514
3233
  }
2515
3234
  if (layer.links) {
2516
3235
  const positions = entities.map((e) => positionAt(e, 0, w, h));
2517
3236
  const maxDistPx = layer.links.maxDist * scale;
2518
3237
  const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
2519
- const lwPx = (layer.links.width ?? 1) * scale;
3238
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
3239
+ const lwPx = (layer.links.width ?? defaultWidth) * scale;
2520
3240
  const la = layer.links.alpha ?? 1;
2521
3241
  for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
2522
3242
  }
2523
3243
  }
2524
- if (totalCoverage < 5e-4 && spec.layers.length > 0) {
3244
+ const density = spec.density ?? "normal";
3245
+ const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
3246
+ if (wouldBeSparse && density !== "sparse") {
2525
3247
  warnings.push({
2526
3248
  path: "layers",
2527
3249
  code: "sparse-scene",
2528
3250
  message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
2529
3251
  });
2530
3252
  }
2531
- if (totalEntities > 500) {
3253
+ if (totalEntities > 500 && density !== "dense") {
2532
3254
  warnings.push({
2533
3255
  path: "layers",
2534
3256
  code: "dense-scene",
2535
3257
  message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
2536
3258
  });
2537
3259
  }
3260
+ if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
3261
+ warnings.push({
3262
+ path: "density",
3263
+ code: "density-mismatch",
3264
+ message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
3265
+ });
3266
+ } else if (density === "dense" && wouldBeSparse) {
3267
+ warnings.push({
3268
+ path: "density",
3269
+ code: "density-mismatch",
3270
+ message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
3271
+ });
3272
+ }
2538
3273
  if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
2539
3274
  const textRatio = textLayerCount / spec.layers.length;
2540
3275
  if (textRatio >= 0.8) {
@@ -2982,6 +3717,36 @@ function luminanceGrid(spec, opts = {}) {
2982
3717
  const p = posOf(scene, e, tPass);
2983
3718
  const sz = sizeAt(e, tPass);
2984
3719
  const s = layer.sprite;
3720
+ if (s.kind === "stroke") {
3721
+ const pts = strokeSamples(s, sz / 2);
3722
+ let angle = rotationAt(e, tPass);
3723
+ if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
3724
+ const ca = Math.cos(angle);
3725
+ const sa = Math.sin(angle);
3726
+ const lw = strokeWidthPx(s, scale);
3727
+ const stampAt = (q, u) => {
3728
+ const x = p.x + q.x * ca - q.y * sa;
3729
+ const y = p.y + q.x * sa + q.y * ca;
3730
+ const c = Math.floor(x / cellW);
3731
+ const r = Math.floor(y / cellH);
3732
+ if (c < 0 || c >= cols || r < 0 || r >= rows) return;
3733
+ const taper = s.taper ? strokeTaper(u) : 1;
3734
+ compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
3735
+ };
3736
+ const step = Math.min(cellW, cellH) / 2;
3737
+ stampAt(pts[0], 0);
3738
+ for (let i = 1; i < pts.length; i++) {
3739
+ const q0 = pts[i - 1];
3740
+ const q1 = pts[i];
3741
+ const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
3742
+ const sub = Math.max(1, Math.ceil(segLen / step));
3743
+ for (let k = 1; k <= sub; k++) {
3744
+ const f = k / sub;
3745
+ stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
3746
+ }
3747
+ }
3748
+ continue;
3749
+ }
2985
3750
  if (s.kind === "streak") {
2986
3751
  const heading = headingAt(e, tPass, w, h) ?? 0;
2987
3752
  const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
@@ -3013,9 +3778,22 @@ function luminanceGrid(spec, opts = {}) {
3013
3778
  centerY = box.cy;
3014
3779
  halfX = box.halfX;
3015
3780
  halfY = box.halfY;
3781
+ } else if (s.kind === "bar") {
3782
+ const len = sz * barFraction(s, e.barIndex ?? 0);
3783
+ if (len <= 0) continue;
3784
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
3785
+ const box = barBox(s.direction ?? "right", len, th);
3786
+ const rot = rotationAt(e, tPass);
3787
+ const cr = Math.cos(rot);
3788
+ const sr = Math.sin(rot);
3789
+ centerX = p.x + box.cx * cr - box.cy * sr;
3790
+ centerY = p.y + box.cx * sr + box.cy * cr;
3791
+ halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
3792
+ halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
3016
3793
  }
3017
- const circular = s.kind === "circle" || s.kind === "ring";
3018
- const soft = s.kind === "circle" && !!s.soft;
3794
+ const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
3795
+ const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
3796
+ const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
3019
3797
  const additive = layer.blend === "lighter" || layer.blend === "screen";
3020
3798
  const glow = soft && additive;
3021
3799
  const reachX = glow ? halfX * GLOW_SPREAD : halfX;
@@ -3048,9 +3826,9 @@ function luminanceGrid(spec, opts = {}) {
3048
3826
  } else {
3049
3827
  wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
3050
3828
  }
3051
- compose(r * cols + c, lum, a * wgt, layer.blend);
3829
+ compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
3052
3830
  } else {
3053
- compose(r * cols + c, lum, a * inkWeight, layer.blend);
3831
+ compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
3054
3832
  }
3055
3833
  }
3056
3834
  }
@@ -3062,7 +3840,8 @@ function luminanceGrid(spec, opts = {}) {
3062
3840
  const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
3063
3841
  const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
3064
3842
  const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
3065
- const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la * alphaScale;
3843
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
3844
+ const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
3066
3845
  for (const edge of edges) {
3067
3846
  const pi = positions[edge.i];
3068
3847
  const pj = positions[edge.j];
@@ -3238,7 +4017,15 @@ function dominanceRanking(spec, opts = {}) {
3238
4017
  else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
3239
4018
  else if (s.kind === "rect") {
3240
4019
  const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
3241
- entArea = sz * h2;
4020
+ const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
4021
+ entArea = sz * h2 * f;
4022
+ } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
4023
+ else if (s.kind === "bar") {
4024
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
4025
+ entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
4026
+ } else if (s.kind === "stroke") {
4027
+ const meanTaper = s.taper ? 0.64 : 1;
4028
+ entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
3242
4029
  } else if (s.kind === "text") {
3243
4030
  const box = textBox(s, e, { x: 0, y: 0 });
3244
4031
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
@@ -3262,7 +4049,8 @@ function dominanceRanking(spec, opts = {}) {
3262
4049
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
3263
4050
  const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
3264
4051
  const la = (layer.links.alpha ?? 0.6) * lifeA;
3265
- const lw = (layer.links.width ?? 1) * scale;
4052
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4053
+ const lw = (layer.links.width ?? defaultWidth) * scale;
3266
4054
  for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
3267
4055
  }
3268
4056
  const meanLuma = entities.length ? lumAcc / entities.length : 0;
@@ -3283,6 +4071,10 @@ function dominanceRanking(spec, opts = {}) {
3283
4071
  factors: r.factors
3284
4072
  }));
3285
4073
  }
4074
+ function layerCohesion(spec, opts = {}) {
4075
+ const scene = buildScene(spec, opts);
4076
+ return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
4077
+ }
3286
4078
  function motionStats(spec, opts = {}) {
3287
4079
  const scene = buildScene(spec, opts);
3288
4080
  const t = opts.t ?? 5e3;
@@ -3379,8 +4171,9 @@ function perceiveScene(spec, opts = {}) {
3379
4171
  colProfile: grid.colProfile,
3380
4172
  dominance: dominanceRanking(spec, opts),
3381
4173
  motion: motionStats(spec, opts),
4174
+ form: layerCohesion(spec, opts),
3382
4175
  text: textSprites(spec, opts),
3383
- advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 })
4176
+ advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
3384
4177
  };
3385
4178
  }
3386
4179
 
@@ -4096,6 +4889,77 @@ var DASHBOARD_SPEC = {
4096
4889
  ]
4097
4890
  };
4098
4891
 
4892
+ // src/examples/facets.ts
4893
+ var FACETS_SPEC = {
4894
+ schemaVersion: 1,
4895
+ id: "facets",
4896
+ label: "Facets",
4897
+ seed: 1911,
4898
+ motionIntensity: "calm",
4899
+ background: {
4900
+ type: "gradient",
4901
+ stops: [
4902
+ { at: 0, color: "#101626" },
4903
+ { at: 1, color: "#1c1a2e" }
4904
+ ]
4905
+ },
4906
+ layers: [
4907
+ {
4908
+ key: "planes",
4909
+ count: 3,
4910
+ sprite: {
4911
+ kind: "rect",
4912
+ width: [0.28, 0.42],
4913
+ aspect: [0.6, 1.4],
4914
+ color: "#b3402f",
4915
+ colors: ["#b3402f", "#c9862b", "#2d4f8a"],
4916
+ feather: 0.6
4917
+ },
4918
+ alpha: [0.35, 0.5],
4919
+ region: { x: [0.2, 0.8], y: [0.2, 0.8] },
4920
+ motion: { type: "wander", speed: [1e-3, 3e-3], meander: 0.02 },
4921
+ spin: [-2, 2]
4922
+ },
4923
+ {
4924
+ key: "facets",
4925
+ count: 14,
4926
+ sprite: {
4927
+ kind: "polygon",
4928
+ radius: [0.02, 0.06],
4929
+ points: [[-1, 0.9], [0.2, -1], [1, 0.4]],
4930
+ color: "#f2e8c9",
4931
+ colors: ["#f2e8c9", "#e0b53a", "#6fa8dc", "#d94f3d"]
4932
+ },
4933
+ alpha: [0.6, 0.9],
4934
+ motion: { type: "wander", speed: [3e-3, 8e-3], meander: 0.03 },
4935
+ spin: [-8, 8]
4936
+ },
4937
+ {
4938
+ key: "arcs",
4939
+ count: 12,
4940
+ sprite: {
4941
+ kind: "stroke",
4942
+ length: [0.08, 0.18],
4943
+ points: [[-1, 0.3], [-0.5, -0.6], [0.3, -0.7], [1, 0.1]],
4944
+ width: 4e-3,
4945
+ taper: true,
4946
+ color: "#f2e8c9"
4947
+ },
4948
+ alpha: [0.5, 0.8],
4949
+ motion: { type: "drift", speed: [4e-3, 0.01], angle: 20, bidirectional: true },
4950
+ spin: [-4, 4]
4951
+ },
4952
+ {
4953
+ key: "hexes",
4954
+ count: 8,
4955
+ sprite: { kind: "polygon", radius: [8e-3, 0.016], sides: 6, color: "#e0b53a", soft: true },
4956
+ alpha: [0.5, 0.8],
4957
+ blend: "lighter",
4958
+ motion: { type: "wander", speed: [2e-3, 5e-3] }
4959
+ }
4960
+ ]
4961
+ };
4962
+
4099
4963
  // src/examples/haiku.ts
4100
4964
  var HAIKU_SPEC = {
4101
4965
  schemaVersion: 1,
@@ -4488,6 +5352,40 @@ var ORRERY_SPEC = {
4488
5352
  ]
4489
5353
  };
4490
5354
 
5355
+ // src/examples/pings.ts
5356
+ var PINGS_SPEC = {
5357
+ schemaVersion: 1,
5358
+ id: "pings",
5359
+ label: "Pings",
5360
+ seed: 7,
5361
+ motionIntensity: "calm",
5362
+ ghosting: 0.9,
5363
+ background: { type: "solid", color: "#03060a" },
5364
+ layers: [
5365
+ {
5366
+ key: "ping",
5367
+ count: 3,
5368
+ sprite: { kind: "ring", radius: [0.05, 0.05], color: "#4fb3a8", width: 15e-4 },
5369
+ alpha: [0.35, 0.35],
5370
+ blend: "lighter",
5371
+ region: { x: [0.2, 0.8], y: [0.25, 0.75] },
5372
+ motion: { type: "static" },
5373
+ // One ring at a time: three entities evenly staggered across 12 s.
5374
+ emit: { every: 12e3, life: 4e3, jitter: 0, grow: [0.15, 2.4] }
5375
+ },
5376
+ {
5377
+ key: "motes",
5378
+ count: 14,
5379
+ sprite: { kind: "circle", radius: [12e-4, 2e-3], color: "#4fb3a8", soft: true },
5380
+ alpha: [0.1, 0.22],
5381
+ blend: "lighter",
5382
+ // Each mote is flung upward and comes to rest within ~2.5 s of its event.
5383
+ motion: { type: "rise", speed: [0.01, 0.02], sway: 4e-3, ease: { type: "settle", tau: 2500 } },
5384
+ emit: { every: 16e3, life: 7e3, grow: [0.6, 1.4] }
5385
+ }
5386
+ ]
5387
+ };
5388
+
4491
5389
  // src/examples/procession.ts
4492
5390
  var PROCESSION_SPEC = {
4493
5391
  schemaVersion: 1,
@@ -4584,6 +5482,74 @@ var RAIN_SPEC = {
4584
5482
  ]
4585
5483
  };
4586
5484
 
5485
+ // src/examples/relay-board.ts
5486
+ var RELAY_BOARD_SPEC = {
5487
+ schemaVersion: 1,
5488
+ id: "relay-board",
5489
+ label: "Relay Board",
5490
+ seed: 9,
5491
+ motionIntensity: "calm",
5492
+ background: {
5493
+ type: "gradient",
5494
+ stops: [
5495
+ { at: 0, color: "#05070d" },
5496
+ { at: 1, color: "#0a0d18" }
5497
+ ]
5498
+ },
5499
+ layers: [
5500
+ {
5501
+ key: "dust",
5502
+ count: 40,
5503
+ sprite: { kind: "circle", radius: [8e-4, 16e-4], color: "#22344a", soft: true },
5504
+ alpha: [0.2, 0.45],
5505
+ blend: "lighter",
5506
+ motion: { type: "wander", speed: [1e-3, 3e-3] }
5507
+ },
5508
+ {
5509
+ key: "title",
5510
+ count: 1,
5511
+ sprite: { kind: "textBlock", text: "RELAY LINKS \u2014 UPLINK MARGIN", maxWidth: 0.6, fontSize: 0.028, color: "#9fb4c9" },
5512
+ motion: { type: "static" },
5513
+ position: { x: 0.12, y: 0.16 }
5514
+ },
5515
+ {
5516
+ key: "labels",
5517
+ count: 6,
5518
+ sprite: { kind: "text", strings: ["VIREO-9", "TIDE", "ORRERY", "COMET", "LANTERN", "SAKURA"], font: "bold monospace", align: "left", color: "#e6e8ef" },
5519
+ size: [0.024, 0.024],
5520
+ motion: { type: "static" },
5521
+ position: { x: 0.12, y: 0.3 },
5522
+ layout: { type: "list", gap: 0.075 }
5523
+ },
5524
+ {
5525
+ key: "bars",
5526
+ count: 6,
5527
+ sprite: {
5528
+ kind: "bar",
5529
+ values: [82, 64, 91, 37, 58, 73],
5530
+ max: 100,
5531
+ length: 0.5,
5532
+ thickness: 0.02,
5533
+ color: "#17e8c8",
5534
+ colors: ["#17e8c8", "#35d0ff", "#ffd93b", "#ff6ad5", "#ad8cff", "#17e8c8"]
5535
+ },
5536
+ alpha: [0.85, 0.85],
5537
+ motion: { type: "static" },
5538
+ position: { x: 0.3, y: 0.3 },
5539
+ layout: { type: "list", gap: 0.075 }
5540
+ },
5541
+ {
5542
+ key: "readouts",
5543
+ count: 6,
5544
+ sprite: { kind: "text", strings: ["82%", "64%", "91%", "37%", "58%", "73%"], font: "monospace", align: "left", color: "#9fb4c9" },
5545
+ size: [0.02, 0.02],
5546
+ motion: { type: "static" },
5547
+ position: { x: 0.86, y: 0.3 },
5548
+ layout: { type: "list", gap: 0.075 }
5549
+ }
5550
+ ]
5551
+ };
5552
+
4587
5553
  // src/examples/sakura.ts
4588
5554
  var SAKURA_SPEC = {
4589
5555
  schemaVersion: 1,
@@ -4820,7 +5786,10 @@ var SCHEMA_EXAMPLES = [
4820
5786
  { id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
4821
5787
  { id: "procession", label: "Night Procession", spec: PROCESSION_SPEC },
4822
5788
  { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC },
4823
- { id: "haiku", label: "Haiku", spec: HAIKU_SPEC }
5789
+ { id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
5790
+ { id: "pings", label: "Pings", spec: PINGS_SPEC },
5791
+ { id: "facets", label: "Facets", spec: FACETS_SPEC },
5792
+ { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC }
4824
5793
  ];
4825
5794
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
4826
5795
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -4829,20 +5798,24 @@ var EXAMPLE_BY_ID = Object.fromEntries(
4829
5798
  export {
4830
5799
  AQUARIUM_SPEC,
4831
5800
  AURORA_SPEC,
5801
+ COHESION_T,
4832
5802
  COMETS_SPEC,
4833
5803
  CONSTELLATION_SPEC,
4834
5804
  DASHBOARD_SPEC,
4835
5805
  EXAMPLE_BY_ID,
4836
5806
  EXAMPLE_SPECS,
5807
+ FACETS_SPEC,
4837
5808
  HAIKU_SPEC,
4838
5809
  LANTERNS_SPEC,
4839
5810
  LIMITS,
4840
5811
  MATRIX_RAIN_SPEC,
4841
5812
  NOSTALGHIA_CANDLE_SPEC,
4842
5813
  ORRERY_SPEC,
5814
+ PINGS_SPEC,
4843
5815
  POLYGONS_SPEC,
4844
5816
  PROCESSION_SPEC,
4845
5817
  RAIN_SPEC,
5818
+ RELAY_BOARD_SPEC,
4846
5819
  SAKURA_SPEC,
4847
5820
  SCHEMA_EXAMPLES,
4848
5821
  SCHEMA_VERSION,
@@ -4857,14 +5830,18 @@ export {
4857
5830
  assertValidSpec,
4858
5831
  breakTextBlock,
4859
5832
  buildEntities,
5833
+ cohesionOf,
4860
5834
  compileSaver,
4861
5835
  compileSequence,
4862
5836
  describeScene,
4863
5837
  diffScenes,
4864
5838
  dominanceRanking,
4865
5839
  easeSmooth,
5840
+ emitEnvelope,
5841
+ emitWindow,
4866
5842
  graphemeClusters,
4867
5843
  headingAt,
5844
+ layerCohesion,
4868
5845
  lerpSpec,
4869
5846
  lifeAlphaAt,
4870
5847
  linkEdges,
@@ -4880,6 +5857,8 @@ export {
4880
5857
  resolveSpecPath,
4881
5858
  revealState,
4882
5859
  rotationAt,
5860
+ seamCauseOf,
5861
+ seamsWorthWarning,
4883
5862
  segmentStart,
4884
5863
  sizeAt,
4885
5864
  spriteIndexAt,