@idle-screens/schema 3.5.0 → 3.6.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
@@ -55,7 +55,30 @@ 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
@@ -173,12 +196,14 @@ function structuralSignature(spec) {
173
196
  l.spin,
174
197
  l.grow,
175
198
  l.layout,
199
+ l.emit,
200
+ l.clock,
176
201
  l.sprite.kind,
177
202
  // Dimensional draws baked into entities: radius (circle/ring), length
178
203
  // (streak), width+aspect (rect), palette pick (all shaped sprites).
179
204
  s.radius,
180
205
  s.length,
181
- l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
206
+ l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
182
207
  Array.isArray(s.colors) ? s.colors.length : void 0,
183
208
  s.colorWeights,
184
209
  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 +218,7 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
193
218
  var isStr = (v) => typeof v === "string";
194
219
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
195
220
  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"]);
221
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting"]);
197
222
  var KNOWN_LAYER = /* @__PURE__ */ new Set([
198
223
  "count",
199
224
  "sprite",
@@ -212,28 +237,33 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
212
237
  "trail",
213
238
  "links",
214
239
  "layout",
215
- "life"
240
+ "life",
241
+ "emit",
242
+ "clock"
216
243
  ]);
217
244
  var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
218
245
  var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
219
246
  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"]);
247
+ var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "feather", "colors", "colorWeights"]);
248
+ var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness", "color", "max", "direction", "colors", "colorWeights"]);
249
+ var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
250
+ var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
221
251
  var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
222
252
  var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
223
253
  var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal"]);
224
254
  var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
225
255
  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"]);
256
+ var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
257
+ var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway", "ease"]);
228
258
  var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
229
259
  var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
230
260
  var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
231
- var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence"]);
261
+ var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence", "ease"]);
232
262
  var KNOWN_WARP = /* @__PURE__ */ new Set(["type", "speed", "center"]);
233
263
  var KNOWN_PATH = /* @__PURE__ */ new Set(["type", "points", "duration", "curve", "closed", "scatter"]);
234
264
  var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
235
265
  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"]);
266
+ var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout"]);
237
267
  function unknownKeys(obj, known) {
238
268
  return Object.keys(obj).filter((k) => !known.has(k));
239
269
  }
@@ -263,6 +293,9 @@ function validateSpec(spec) {
263
293
  if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
264
294
  err("motionIntensity", "must be calm | moderate | energetic");
265
295
  }
296
+ if (spec.density !== void 0 && !["sparse", "normal", "dense"].includes(spec.density)) {
297
+ err("density", "must be sparse | normal | dense");
298
+ }
266
299
  if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
267
300
  err("units", "must be 'px' | 'viewport'");
268
301
  }
@@ -379,7 +412,10 @@ function validateLayer(layer, path, err, warn, spec) {
379
412
  } else {
380
413
  if (layer.position.x < 0 || layer.position.x > 1) err(`${path}.position.x`, "must be 0..1");
381
414
  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");
415
+ const dataLayout = isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table");
416
+ if (isNum(layer.count) && layer.count !== 1 && !dataLayout) {
417
+ err(`${path}.position`, "position requires count: 1 \u2014 or a layout of type 'list' / 'table', where it anchors the whole block");
418
+ }
383
419
  }
384
420
  }
385
421
  if (layer.region !== void 0) {
@@ -468,8 +504,37 @@ function validateLayer(layer, path, err, warn, spec) {
468
504
  }
469
505
  }
470
506
  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'");
507
+ if (isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table")) {
508
+ const lay = layer.layout;
509
+ if (lay.type === "table" && (!isNum(lay.columns) || !Number.isInteger(lay.columns) || lay.columns < 1 || lay.columns > LIMITS.maxGridColumns)) {
510
+ err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
511
+ }
512
+ const gapOk = (v) => isNum(v) && v > 0;
513
+ if (lay.gap !== void 0) {
514
+ if (isNum(lay.gap)) {
515
+ if (!gapOk(lay.gap)) err(`${path}.layout.gap`, "must be > 0 (viewport units of min(w,h), or px)");
516
+ } else if (lay.type === "table" && isObj(lay.gap)) {
517
+ if (lay.gap.x !== void 0 && !gapOk(lay.gap.x)) err(`${path}.layout.gap.x`, "must be > 0");
518
+ if (lay.gap.y !== void 0 && !gapOk(lay.gap.y)) err(`${path}.layout.gap.y`, "must be > 0");
519
+ for (const k of unknownKeys(lay.gap, /* @__PURE__ */ new Set(["x", "y"]))) {
520
+ warn(`${path}.layout.gap.${k}`, "unknown-property", `unknown gap property '${k}' \u2014 will be ignored`);
521
+ }
522
+ } else {
523
+ err(`${path}.layout.gap`, lay.type === "table" ? "must be a number > 0 or { x?, y? }" : "must be a number > 0");
524
+ }
525
+ }
526
+ for (const k of unknownKeys(lay, new Set(lay.type === "list" ? ["type", "gap"] : ["type", "columns", "gap"]))) {
527
+ warn(`${path}.layout.${k}`, "unknown-property", `unknown layout property '${k}' \u2014 will be ignored`);
528
+ }
529
+ const sp = layer.sprite;
530
+ if (isObj(sp) && isNum(layer.count)) {
531
+ 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;
532
+ if (variants !== null && variants !== layer.count) {
533
+ 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`);
534
+ }
535
+ }
536
+ } else if (!isObj(layer.layout) || layer.layout.type !== "grid") {
537
+ err(`${path}.layout`, "must be an object with type: 'grid' | 'list' | 'table'");
473
538
  } else {
474
539
  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
540
  err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
@@ -519,6 +584,64 @@ function validateLayer(layer, path, err, warn, spec) {
519
584
  }
520
585
  }
521
586
  }
587
+ if (layer.emit !== void 0) {
588
+ if (!isObj(layer.emit)) err(`${path}.emit`, "must be an object { every, life, jitter?, grow? }");
589
+ else {
590
+ const em = layer.emit;
591
+ if (!isNum(em.every) || em.every < LIMITS.minEmitEvery || em.every > LIMITS.maxEmitEvery) {
592
+ err(`${path}.emit.every`, `must be ${LIMITS.minEmitEvery}..${LIMITS.maxEmitEvery} ms (at most one event per second per entity)`);
593
+ }
594
+ if (!isNum(em.life) || em.life < LIMITS.minEmitLife) {
595
+ err(`${path}.emit.life`, `must be >= ${LIMITS.minEmitLife} ms (an event is a smooth envelope, never a cut)`);
596
+ } else if (isNum(em.every) && em.life > em.every) {
597
+ err(`${path}.emit.life`, "must be <= emit.every (the visible window cannot outlast its period)");
598
+ }
599
+ if (em.jitter !== void 0 && (!isNum(em.jitter) || em.jitter < 0 || em.jitter > 1)) {
600
+ err(`${path}.emit.jitter`, "must be 0..1 (0 = evenly staggered, 1 = scattered by a fixed sequence)");
601
+ }
602
+ if (em.grow !== void 0) {
603
+ if (!Array.isArray(em.grow) || em.grow.length !== 2 || !isNum(em.grow[0]) || !isNum(em.grow[1])) {
604
+ err(`${path}.emit.grow`, "must be [from, to] size multipliers");
605
+ } else if (em.grow[0] < 0 || em.grow[1] < 0 || em.grow[0] > LIMITS.maxEmitGrow || em.grow[1] > LIMITS.maxEmitGrow) {
606
+ err(`${path}.emit.grow`, `each multiplier must be 0..${LIMITS.maxEmitGrow}`);
607
+ }
608
+ }
609
+ if (em.jitter === 0 && isNum(em.every) && isNum(em.life) && isNum(layer.count) && layer.count > 1 && em.life > em.every / layer.count) {
610
+ 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`);
611
+ }
612
+ for (const k of unknownKeys(em, /* @__PURE__ */ new Set(["every", "life", "jitter", "grow"]))) {
613
+ warn(`${path}.emit.${k}`, "unknown-property", `unknown emit property '${k}' \u2014 will be ignored`);
614
+ }
615
+ }
616
+ }
617
+ if (layer.clock !== void 0) {
618
+ if (!isObj(layer.clock)) err(`${path}.clock`, "must be an object { phase?, rate? }");
619
+ else {
620
+ const ck = layer.clock;
621
+ if (ck.phase !== void 0 && (!isNum(ck.phase) || ck.phase < 0 || ck.phase > 1)) {
622
+ err(`${path}.clock.phase`, "must be 0..1 (turns)");
623
+ }
624
+ const rate = ck.rate === void 0 ? 1 : ck.rate;
625
+ if (!isNum(rate) || rate < LIMITS.minClockRate || rate > LIMITS.maxClockRate) {
626
+ err(`${path}.clock.rate`, `must be ${LIMITS.minClockRate}..${LIMITS.maxClockRate}`);
627
+ } else {
628
+ const floor = LIMITS.minClockedPeriod * rate;
629
+ if (isObj(layer.pulse) && isNum(layer.pulse.period) && layer.pulse.period < floor) {
630
+ 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)`);
631
+ }
632
+ if (isObj(layer.grow) && isNum(layer.grow.period) && layer.grow.period < floor) {
633
+ err(`${path}.grow.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod})`);
634
+ }
635
+ const sp = layer.sprite;
636
+ if (isObj(sp) && isObj(sp.cycle) && isNum(sp.cycle.period) && sp.cycle.period < floor) {
637
+ err(`${path}.sprite.cycle.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod})`);
638
+ }
639
+ }
640
+ for (const k of unknownKeys(ck, /* @__PURE__ */ new Set(["phase", "rate"]))) {
641
+ warn(`${path}.clock.${k}`, "unknown-property", `unknown clock property '${k}' \u2014 will be ignored`);
642
+ }
643
+ }
644
+ }
522
645
  validateSprite(layer.sprite, `${path}.sprite`, err, warn);
523
646
  validateMotion(layer.motion, `${path}.motion`, err, warn, spec);
524
647
  }
@@ -571,6 +694,45 @@ function validateSprite(sprite, path, err, warn) {
571
694
  if (sprite.aspect !== void 0 && (!isRange(sprite.aspect) || sprite.aspect[0] <= 0)) {
572
695
  err(`${path}.aspect`, "must be a [min,max] range of positive height/width ratios");
573
696
  }
697
+ if (sprite.feather !== void 0 && (!isNum(sprite.feather) || sprite.feather < 0 || sprite.feather > 1)) {
698
+ err(`${path}.feather`, "must be 0..1 (fraction of the half-size that fades out)");
699
+ }
700
+ color(sprite.color, `${path}.color`, err);
701
+ validatePalette(sprite, path, err);
702
+ } else if (sprite.kind === "bar") {
703
+ knownSet = KNOWN_BAR;
704
+ if (!Array.isArray(sprite.values) || sprite.values.length === 0 || sprite.values.length > LIMITS.maxPerLayer || !sprite.values.every((v) => isNum(v) && v >= 0)) {
705
+ err(`${path}.values`, `must be 1..${LIMITS.maxPerLayer} numbers >= 0`);
706
+ }
707
+ if (!isNum(sprite.length) || sprite.length <= 0) err(`${path}.length`, "must be > 0 (full-scale bar length)");
708
+ if (!isNum(sprite.thickness) || sprite.thickness <= 0) err(`${path}.thickness`, "must be > 0");
709
+ if (sprite.max !== void 0 && (!isNum(sprite.max) || sprite.max <= 0)) err(`${path}.max`, "must be > 0");
710
+ if (sprite.direction !== void 0 && !["right", "left", "up", "down"].includes(sprite.direction)) {
711
+ err(`${path}.direction`, "must be 'right' | 'left' | 'up' | 'down'");
712
+ }
713
+ color(sprite.color, `${path}.color`, err);
714
+ validatePalette(sprite, path, err);
715
+ } else if (sprite.kind === "polygon") {
716
+ knownSet = KNOWN_POLYGON;
717
+ if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px (circumradius)");
718
+ if (sprite.sides !== void 0 && sprite.points !== void 0) {
719
+ err(`${path}.sides`, "use sides (a regular polygon) or points (a custom one), not both");
720
+ }
721
+ if (sprite.sides !== void 0 && (!isNum(sprite.sides) || !Number.isInteger(sprite.sides) || sprite.sides < LIMITS.minPolygonSides || sprite.sides > LIMITS.maxPolygonSides)) {
722
+ err(`${path}.sides`, `must be an integer ${LIMITS.minPolygonSides}..${LIMITS.maxPolygonSides}`);
723
+ }
724
+ if (sprite.points !== void 0) validateShapePoints(sprite.points, `${path}.points`, 3, err);
725
+ if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
726
+ color(sprite.color, `${path}.color`, err);
727
+ validatePalette(sprite, path, err);
728
+ } else if (sprite.kind === "stroke") {
729
+ knownSet = KNOWN_STROKE;
730
+ 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)");
731
+ validateShapePoints(sprite.points, `${path}.points`, 2, err);
732
+ if (sprite.width !== void 0 && (!isNum(sprite.width) || sprite.width <= 0)) err(`${path}.width`, "must be > 0");
733
+ if (sprite.curve !== void 0 && sprite.curve !== "smooth" && sprite.curve !== "linear") err(`${path}.curve`, "must be 'smooth' | 'linear'");
734
+ if (sprite.taper !== void 0 && typeof sprite.taper !== "boolean") err(`${path}.taper`, "must be a boolean");
735
+ if (sprite.orient !== void 0 && typeof sprite.orient !== "boolean") err(`${path}.orient`, "must be a boolean");
574
736
  color(sprite.color, `${path}.color`, err);
575
737
  validatePalette(sprite, path, err);
576
738
  } else if (sprite.kind === "textBlock") {
@@ -630,7 +792,7 @@ function validateSprite(sprite, path, err, warn) {
630
792
  }
631
793
  }
632
794
  } else {
633
- err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
795
+ err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | bar | polygon | stroke | textBlock");
634
796
  return;
635
797
  }
636
798
  for (const k of unknownKeys(sprite, knownSet)) {
@@ -683,6 +845,7 @@ function validateMotion(motion, path, err, warn, spec) {
683
845
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
684
846
  if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
685
847
  if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
848
+ validateEase(motion.ease, `${path}.ease`, err, warn);
686
849
  if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
687
850
  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
851
  }
@@ -690,6 +853,7 @@ function validateMotion(motion, path, err, warn, spec) {
690
853
  knownSet = KNOWN_RISE;
691
854
  speedOk(motion.speed, `${path}.speed`);
692
855
  if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
856
+ validateEase(motion.ease, `${path}.ease`, err, warn);
693
857
  if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
694
858
  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
859
  }
@@ -739,6 +903,7 @@ function validateMotion(motion, path, err, warn, spec) {
739
903
  }
740
904
  } else if (motion.type === "wander") {
741
905
  knownSet = KNOWN_WANDER;
906
+ validateEase(motion.ease, `${path}.ease`, err, warn);
742
907
  speedOk(motion.speed, `${path}.speed`);
743
908
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
744
909
  const meanderCap = isViewport ? LIMITS.maxMeander / refVp : LIMITS.maxMeander;
@@ -790,6 +955,28 @@ function validateMotion(motion, path, err, warn, spec) {
790
955
  warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
791
956
  }
792
957
  }
958
+ function validateShapePoints(points, path, min, err) {
959
+ if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
960
+ return err(path, `must be ${min}..${LIMITS.maxShapePoints} [x, y] pairs in unit coordinates (-1..1)`);
961
+ }
962
+ for (let i = 0; i < points.length; i++) {
963
+ const pt = points[i];
964
+ if (!Array.isArray(pt) || pt.length !== 2 || !isNum(pt[0]) || !isNum(pt[1]) || Math.abs(pt[0]) > 1 || Math.abs(pt[1]) > 1) {
965
+ err(`${path}[${i}]`, "must be an [x, y] pair with each coordinate in -1..1");
966
+ }
967
+ }
968
+ }
969
+ function validateEase(ease, path, err, warn) {
970
+ if (ease === void 0) return;
971
+ if (!isObj(ease)) return err(path, "must be an object { type: 'settle' | 'buoyant', tau }");
972
+ if (ease.type !== "settle" && ease.type !== "buoyant") err(`${path}.type`, "must be 'settle' | 'buoyant'");
973
+ if (!isNum(ease.tau) || ease.tau < LIMITS.minEaseTau || ease.tau > LIMITS.maxEaseTau) {
974
+ err(`${path}.tau`, `must be ${LIMITS.minEaseTau}..${LIMITS.maxEaseTau} ms`);
975
+ }
976
+ for (const k of unknownKeys(ease, /* @__PURE__ */ new Set(["type", "tau"]))) {
977
+ warn(`${path}.${k}`, "unknown-property", `unknown ease property '${k}' \u2014 will be ignored`);
978
+ }
979
+ }
793
980
  function assertValidSpec(spec) {
794
981
  const r = validateSpec(spec);
795
982
  if (!r.valid) {
@@ -907,6 +1094,110 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
907
1094
  return seq;
908
1095
  }
909
1096
 
1097
+ // src/shapes.ts
1098
+ function isShapedSprite(s) {
1099
+ return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
1100
+ }
1101
+ function barFraction(s, index) {
1102
+ const n = s.values.length;
1103
+ if (n === 0) return 0;
1104
+ const v = s.values[(index % n + n) % n] ?? 0;
1105
+ const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
1106
+ if (!(max > 0)) return 0;
1107
+ return Math.max(0, Math.min(1, v / max));
1108
+ }
1109
+ function barBox(direction, len, thick) {
1110
+ switch (direction) {
1111
+ case "left":
1112
+ return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1113
+ case "up":
1114
+ return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
1115
+ case "down":
1116
+ return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
1117
+ default:
1118
+ return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
1119
+ }
1120
+ }
1121
+ var DEFAULT_POLYGON_SIDES = 6;
1122
+ var STROKE_SAMPLES = 24;
1123
+ function polygonPoints(s, radius) {
1124
+ if (s.points && s.points.length >= 3) {
1125
+ return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
1126
+ }
1127
+ const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
1128
+ const out = [];
1129
+ for (let k = 0; k < n; k++) {
1130
+ const a = -Math.PI / 2 + 2 * Math.PI * k / n;
1131
+ out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
1132
+ }
1133
+ return out;
1134
+ }
1135
+ function polygonArea(pts) {
1136
+ let a = 0;
1137
+ for (let i = 0; i < pts.length; i++) {
1138
+ const p = pts[i];
1139
+ const q = pts[(i + 1) % pts.length];
1140
+ a += p.x * q.y - q.x * p.y;
1141
+ }
1142
+ return Math.abs(a) / 2;
1143
+ }
1144
+ function polygonFill(s, radius) {
1145
+ if (radius <= 0) return 0;
1146
+ const disc = Math.PI * radius * radius;
1147
+ return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
1148
+ }
1149
+ function catmullRom(p0, p1, p2, p3, u) {
1150
+ const u2 = u * u;
1151
+ const u3 = u2 * u;
1152
+ return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
1153
+ }
1154
+ function strokeSamples(s, halfSize, n) {
1155
+ const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
1156
+ const m = pts.length;
1157
+ if (m < 2) return pts;
1158
+ const smooth = s.curve !== "linear" && m >= 3;
1159
+ const segs = m - 1;
1160
+ n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
1161
+ const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
1162
+ const out = [];
1163
+ for (let i = 0; i < n; i++) {
1164
+ const u = i / (n - 1) * segs;
1165
+ const seg = Math.min(segs - 1, Math.floor(u));
1166
+ const local = u - seg;
1167
+ const p1 = at(seg);
1168
+ const p2 = at(seg + 1);
1169
+ if (!smooth) {
1170
+ out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
1171
+ } else {
1172
+ const p0 = at(seg - 1);
1173
+ const p3 = at(seg + 2);
1174
+ out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
1175
+ }
1176
+ }
1177
+ return out;
1178
+ }
1179
+ function pathLength(pts) {
1180
+ let l = 0;
1181
+ for (let i = 1; i < pts.length; i++) {
1182
+ const dx = pts[i].x - pts[i - 1].x;
1183
+ const dy = pts[i].y - pts[i - 1].y;
1184
+ l += Math.sqrt(dx * dx + dy * dy);
1185
+ }
1186
+ return l;
1187
+ }
1188
+ function strokeTaper(u) {
1189
+ return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
1190
+ }
1191
+ function strokeWidthPx(s, scale) {
1192
+ return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
1193
+ }
1194
+ function featherAlphas(steps, additive) {
1195
+ const out = [];
1196
+ for (let k = 1; k <= steps; k++) out.push(additive ? 1 / steps : 1 / (steps - k + 1));
1197
+ return out;
1198
+ }
1199
+ var FEATHER_STEPS = 6;
1200
+
910
1201
  // src/simulate.ts
911
1202
  var WARP_NEAR = 0.08;
912
1203
  var WARP_MAX_SCALE = 8;
@@ -935,6 +1226,21 @@ function drawOsc(rng, amp) {
935
1226
  }
936
1227
  return osc;
937
1228
  }
1229
+ var GOLDEN = 0.6180339887498949;
1230
+ function emitParams(emit, i, n) {
1231
+ const every = emit.every;
1232
+ const jitter = Math.max(0, Math.min(1, emit.jitter ?? 1));
1233
+ const scattered = (i + 0.5) * GOLDEN % 1 * every;
1234
+ const even = i / Math.max(1, n) * every;
1235
+ const phase = ((even * (1 - jitter) + scattered * jitter) % every + every) % every;
1236
+ return {
1237
+ every,
1238
+ life: Math.min(emit.life, every),
1239
+ phase,
1240
+ growFrom: emit.grow?.[0] ?? 1,
1241
+ growTo: emit.grow?.[1] ?? 1
1242
+ };
1243
+ }
938
1244
  function weightedIndex(u, weights) {
939
1245
  const total = weights.reduce((a, b) => a + b, 0);
940
1246
  let acc = 0;
@@ -945,15 +1251,15 @@ function weightedIndex(u, weights) {
945
1251
  return weights.length - 1;
946
1252
  }
947
1253
  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(
1254
+ const effectiveCount = countScale === 1 || layer.layout !== void 0 ? layer.count : Math.max(1, Math.min(
949
1255
  Math.round(layer.count * countScale),
950
1256
  layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
951
1257
  ));
952
1258
  const [smin, smax] = layer.size ?? [20, 40];
953
1259
  const variants = spriteVariants(layer.sprite);
954
1260
  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;
1261
+ const spriteColors = isShapedSprite(sprite) ? sprite.colors : void 0;
1262
+ const colorWeights = isShapedSprite(sprite) ? sprite.colorWeights : void 0;
957
1263
  const colorsLen = spriteColors?.length ?? 0;
958
1264
  const cycle = sprite.kind === "emoji" || sprite.kind === "text" ? sprite.cycle : void 0;
959
1265
  const mSpec = layer.motion;
@@ -967,7 +1273,32 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
967
1273
  if (mSpec.type === "path") {
968
1274
  pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
969
1275
  }
1276
+ const ease = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
1277
+ const clockPhase = layer.clock ? (layer.clock.phase ?? 0) % 1 * Math.PI * 2 : 0;
970
1278
  const layout = layer.layout;
1279
+ const ordered = layout?.type === "list" || layout?.type === "table";
1280
+ let gapX = 0;
1281
+ let gapY = 0;
1282
+ let listX0 = 0;
1283
+ let listY0 = 0;
1284
+ let listCols = 1;
1285
+ if (layout?.type === "list" || layout?.type === "table") {
1286
+ const dflt = scale === 1 ? LIMITS.defaultListGapPx : LIMITS.defaultListGap;
1287
+ const g = layout.gap ?? dflt;
1288
+ gapX = (typeof g === "number" ? g : g.x ?? dflt) * scale;
1289
+ gapY = (typeof g === "number" ? g : g.y ?? dflt) * scale;
1290
+ listCols = layout.type === "list" ? 1 : Math.max(1, Math.round(layout.columns));
1291
+ const rows = Math.max(1, Math.ceil(effectiveCount / listCols));
1292
+ if (layer.position) {
1293
+ listX0 = layer.position.x * w;
1294
+ listY0 = layer.position.y * h;
1295
+ } else {
1296
+ const [rx0, rx1] = layer.region?.x ?? [0, 1];
1297
+ const [ry0, ry1] = layer.region?.y ?? [0, 1];
1298
+ listX0 = (rx0 + rx1) / 2 * w - (listCols - 1) * gapX / 2;
1299
+ listY0 = (ry0 + ry1) / 2 * h - (rows - 1) * gapY / 2;
1300
+ }
1301
+ }
971
1302
  let gridCols = 0;
972
1303
  let cellW = 0;
973
1304
  let cellH = 0;
@@ -990,8 +1321,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
990
1321
  }
991
1322
  const out = [];
992
1323
  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;
1324
+ 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;
1325
+ 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
1326
  let vx = 0;
996
1327
  let vy = 0;
997
1328
  let bob = 0;
@@ -1055,6 +1386,11 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1055
1386
  if (layer.position && layer.count === 1) {
1056
1387
  x0 = layer.position.x * w;
1057
1388
  y0 = layer.position.y * h;
1389
+ } else if (layout?.type === "list" || layout?.type === "table") {
1390
+ rng.next();
1391
+ rng.next();
1392
+ x0 = listX0 + i % listCols * gapX;
1393
+ y0 = listY0 + Math.floor(i / listCols) * gapY;
1058
1394
  } else if (layout?.type === "grid") {
1059
1395
  const col = i % gridCols;
1060
1396
  const row = Math.floor(i / gridCols);
@@ -1074,7 +1410,10 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1074
1410
  x0,
1075
1411
  y0,
1076
1412
  size,
1077
- spriteIndex: variants > 1 ? rng.int(0, variants - 1) : 0,
1413
+ // Data layouts read variants in order — strings[i] beside bar i. The
1414
+ // seeded draw is still consumed so a layout is placement only: toggling
1415
+ // list/table never disturbs the layer's alpha, pulse or spin stream.
1416
+ spriteIndex: variants > 1 ? ordered ? (rng.int(0, variants - 1), i % variants) : rng.int(0, variants - 1) : 0,
1078
1417
  phase: rng.range(0, Math.PI * 2),
1079
1418
  vx,
1080
1419
  vy,
@@ -1098,10 +1437,12 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1098
1437
  orbitCx: m.type === "orbit" && !orbitParent ? (m.center?.x ?? 0.5) * w : 0,
1099
1438
  orbitCy: m.type === "orbit" && !orbitParent ? (m.center?.y ?? 0.5) * h : 0,
1100
1439
  // 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,
1440
+ 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
1441
  cyclePeriod: cycle ? cycle.period : 0,
1103
1442
  // Optional feature fields last (guarded draws inside — wander draws 18 here).
1104
1443
  ...size2 !== void 0 ? { size2 } : {},
1444
+ ...sprite.kind === "bar" ? { barIndex: i } : {},
1445
+ ...ordered ? { ordered: true } : {},
1105
1446
  ...orbitParent ? { orbitParent } : {},
1106
1447
  ...m.type === "wander" ? {
1107
1448
  wander: {
@@ -1122,33 +1463,80 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1122
1463
  offX: pathOffX,
1123
1464
  offY: pathOffY
1124
1465
  }
1125
- } : {}
1466
+ } : {},
1467
+ // Time-structure fields (2026-09): ease draws nothing; emit's one draw
1468
+ // per entity happens in a pass AFTER this loop (see below).
1469
+ ...ease ? { ease: { type: ease.type, tau: ease.tau } } : {}
1126
1470
  });
1471
+ if (layer.clock) {
1472
+ const ent = out[out.length - 1];
1473
+ ent.pulsePhase = clockPhase;
1474
+ ent.growPhase = clockPhase;
1475
+ ent.cyclePhase = clockPhase;
1476
+ ent.clockRate = layer.clock.rate ?? 1;
1477
+ }
1127
1478
  if (layer.pulse?.wave) {
1128
1479
  const wl = Math.max(1e-6, layer.pulse.wave.wavelength * scale);
1129
1480
  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;
1481
+ out[out.length - 1].pulsePhase = clockPhase - (x0 * Math.cos(ang) + y0 * Math.sin(ang)) / wl * Math.PI * 2;
1131
1482
  }
1132
1483
  }
1484
+ if (layer.emit) {
1485
+ for (let i = 0; i < out.length; i++) out[i].emit = emitParams(layer.emit, i, out.length);
1486
+ }
1133
1487
  return out;
1134
1488
  }
1135
1489
  function warpZ(e, t) {
1136
1490
  const wp = e.warp;
1137
1491
  return wrap(wp.z0 - wp.vz * (t / 1e3), WARP_NEAR, 1);
1138
1492
  }
1493
+ function smoothstep(x) {
1494
+ const c = x < 0 ? 0 : x > 1 ? 1 : x;
1495
+ return c * c * (3 - 2 * c);
1496
+ }
1497
+ var EMIT_ATTACK = 0.25;
1498
+ function emitWindow(e, t) {
1499
+ const em = e.emit;
1500
+ if (!em) return null;
1501
+ const local = ((t - em.phase) % em.every + em.every) % em.every;
1502
+ return local < em.life ? local / em.life : null;
1503
+ }
1504
+ function emitEnvelope(u) {
1505
+ if (u < 0 || u >= 1) return 0;
1506
+ return u < EMIT_ATTACK ? smoothstep(u / EMIT_ATTACK) : 1 - smoothstep((u - EMIT_ATTACK) / (1 - EMIT_ATTACK));
1507
+ }
1508
+ function clockTime(e, t) {
1509
+ return e.clockRate ? t * e.clockRate : t;
1510
+ }
1511
+ function motionSeconds(e, t) {
1512
+ if (!e.ease) return t / 1e3;
1513
+ let tl = t;
1514
+ if (e.emit) tl = ((t - e.emit.phase) % e.emit.every + e.emit.every) % e.emit.every;
1515
+ const tau = e.ease.tau;
1516
+ const k = 1 - Math.exp(-tl / tau);
1517
+ return (e.ease.type === "settle" ? tau * k : tl - tau * k) / 1e3;
1518
+ }
1139
1519
  function alphaAt(e, t) {
1140
1520
  let a = e.alpha;
1141
- if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
1521
+ if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(clockTime(e, t) * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
1142
1522
  if (e.motion === "warp") {
1143
1523
  const z = warpZ(e, t);
1144
1524
  a *= Math.max(0, Math.min(1, (1 - z) / 0.2));
1145
1525
  }
1526
+ if (e.emit) {
1527
+ const u = emitWindow(e, t);
1528
+ a = u === null ? 0 : a * emitEnvelope(u);
1529
+ }
1146
1530
  return a < 0 ? 0 : a > 1 ? 1 : a;
1147
1531
  }
1148
1532
  function sizeAt(e, t) {
1149
1533
  let s = e.size;
1150
- if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
1534
+ if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(clockTime(e, t) * 2 * Math.PI / e.growPeriod + e.growPhase));
1151
1535
  if (e.motion === "warp") s *= Math.min(1 / warpZ(e, t), WARP_MAX_SCALE);
1536
+ if (e.emit && (e.emit.growFrom !== 1 || e.emit.growTo !== 1)) {
1537
+ const u = emitWindow(e, t);
1538
+ if (u !== null) s *= e.emit.growFrom + (e.emit.growTo - e.emit.growFrom) * u;
1539
+ }
1152
1540
  return s > 0 ? s : 0.1;
1153
1541
  }
1154
1542
  function rotationAt(e, t) {
@@ -1179,7 +1567,7 @@ function reflect(v, min, max) {
1179
1567
  const p = ((v - min) % period + period) % period;
1180
1568
  return min + (p < range ? p : period - p);
1181
1569
  }
1182
- function catmullRom(p0, p1, p2, p3, u) {
1570
+ function catmullRom2(p0, p1, p2, p3, u) {
1183
1571
  const u2 = u * u;
1184
1572
  const u3 = u2 * u;
1185
1573
  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 +1597,8 @@ function pathPosition(e, t) {
1209
1597
  const p0 = at(i - 1);
1210
1598
  const p3 = at(i + 2);
1211
1599
  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
1600
+ x: catmullRom2(p0.x, p1.x, p2.x, p3.x, local) + p.offX,
1601
+ y: catmullRom2(p0.y, p1.y, p2.y, p3.y, local) + p.offY
1214
1602
  };
1215
1603
  }
1216
1604
  function positionAt(e, t, w, h) {
@@ -1237,7 +1625,7 @@ function positionAt(e, t, w, h) {
1237
1625
  const pos = pathPosition(e, t);
1238
1626
  return { x: pos.x, y: pos.y, flip: false };
1239
1627
  }
1240
- const dt = t / 1e3;
1628
+ const dt = motionSeconds(e, t);
1241
1629
  const m = e.size;
1242
1630
  if (e.motion === "bounce") {
1243
1631
  return {
@@ -1294,7 +1682,8 @@ function headingAt(e, t, w, h) {
1294
1682
  }
1295
1683
  function spriteIndexAt(e, t, variants) {
1296
1684
  if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
1297
- return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
1685
+ if (e.ordered) return (e.spriteIndex + Math.floor(clockTime(e, t) / e.cyclePeriod)) % variants;
1686
+ return Math.floor(clockTime(e, t) / e.cyclePeriod + (e.cyclePhase ?? e.phase) / (2 * Math.PI)) % variants;
1298
1687
  }
1299
1688
  function wrapDist(pi, pj, doWrap, w, h) {
1300
1689
  let dx = pj.x - pi.x;
@@ -1756,7 +2145,7 @@ var SpecInstance = class {
1756
2145
  const headAlpha = alphaAt(e, t) * lifeA;
1757
2146
  const headSize = sizeAt(e, t);
1758
2147
  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";
2148
+ const resolvedColor = isShapedSprite(sprite) ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
1760
2149
  const isSoft = sprite.kind === "circle" && sprite.soft;
1761
2150
  const wrap2 = built.layer.wrap !== false;
1762
2151
  const head = this.entityPos(e, t, parentE);
@@ -1835,7 +2224,90 @@ var SpecInstance = class {
1835
2224
  ctx.translate(p.x, p.y);
1836
2225
  if (rot) ctx.rotate(rot);
1837
2226
  ctx.fillStyle = resolvedColor;
1838
- ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
2227
+ const feather = sprite.feather ?? 0;
2228
+ if (feather > 0) {
2229
+ const base = ctx.globalAlpha;
2230
+ const additive = built.layer.blend === "lighter";
2231
+ const alphas = featherAlphas(FEATHER_STEPS, additive);
2232
+ for (let j = 1; j <= FEATHER_STEPS; j++) {
2233
+ const f = 1 - feather * (j - 1) / FEATHER_STEPS;
2234
+ const fw = sz * f;
2235
+ const fh = rh * f;
2236
+ ctx.globalAlpha = base * alphas[j - 1];
2237
+ ctx.fillRect(-fw / 2, -fh / 2, fw, fh);
2238
+ }
2239
+ ctx.globalAlpha = base;
2240
+ } else {
2241
+ ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
2242
+ }
2243
+ ctx.restore();
2244
+ return;
2245
+ }
2246
+ if (sprite.kind === "bar") {
2247
+ const len = sz * barFraction(sprite, e.barIndex ?? 0);
2248
+ if (len <= 0) return;
2249
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
2250
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
2251
+ const box = barBox(sprite.direction ?? "right", len, th);
2252
+ ctx.save();
2253
+ ctx.translate(p.x, p.y);
2254
+ if (rot) ctx.rotate(rot);
2255
+ ctx.fillStyle = resolvedColor;
2256
+ ctx.fillRect(box.cx - box.halfX, box.cy - box.halfY, box.halfX * 2, box.halfY * 2);
2257
+ ctx.restore();
2258
+ return;
2259
+ }
2260
+ if (sprite.kind === "polygon") {
2261
+ const r = sz / 2;
2262
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
2263
+ const pts = polygonPoints(sprite, r);
2264
+ ctx.save();
2265
+ ctx.translate(p.x, p.y);
2266
+ if (rot) ctx.rotate(rot);
2267
+ if (sprite.soft) {
2268
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
2269
+ g.addColorStop(0, resolvedColor);
2270
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
2271
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
2272
+ ctx.fillStyle = g;
2273
+ } else {
2274
+ ctx.fillStyle = resolvedColor;
2275
+ }
2276
+ ctx.beginPath();
2277
+ ctx.moveTo(pts[0].x, pts[0].y);
2278
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
2279
+ ctx.closePath();
2280
+ ctx.fill();
2281
+ ctx.restore();
2282
+ return;
2283
+ }
2284
+ if (sprite.kind === "stroke") {
2285
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
2286
+ const pts = strokeSamples(sprite, sz / 2);
2287
+ const lw = strokeWidthPx(sprite, unitScale);
2288
+ ctx.save();
2289
+ ctx.translate(p.x, p.y);
2290
+ let angle = rot;
2291
+ if (sprite.orient) angle += headingAt(e, t, this.w, this.h) ?? 0;
2292
+ if (angle) ctx.rotate(angle);
2293
+ ctx.strokeStyle = resolvedColor;
2294
+ ctx.lineCap = "round";
2295
+ ctx.lineJoin = "round";
2296
+ if (sprite.taper) {
2297
+ for (let i = 1; i < pts.length; i++) {
2298
+ ctx.lineWidth = Math.max(0.5, lw * strokeTaper((i - 0.5) / (pts.length - 1)));
2299
+ ctx.beginPath();
2300
+ ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
2301
+ ctx.lineTo(pts[i].x, pts[i].y);
2302
+ ctx.stroke();
2303
+ }
2304
+ } else {
2305
+ ctx.lineWidth = lw;
2306
+ ctx.beginPath();
2307
+ ctx.moveTo(pts[0].x, pts[0].y);
2308
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
2309
+ ctx.stroke();
2310
+ }
1839
2311
  ctx.restore();
1840
2312
  return;
1841
2313
  }
@@ -2010,7 +2482,7 @@ var SpecInstance = class {
2010
2482
  let resolvedColor = links.color;
2011
2483
  if (!resolvedColor) {
2012
2484
  const sprite = built.layer.sprite;
2013
- if (sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect") {
2485
+ if (isShapedSprite(sprite)) {
2014
2486
  resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
2015
2487
  } else resolvedColor = "#e6e8ef";
2016
2488
  }
@@ -2373,7 +2845,7 @@ function hexLuma(hex) {
2373
2845
  }
2374
2846
  function spriteLuma(layer, e) {
2375
2847
  const s = layer.sprite;
2376
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
2848
+ if (isShapedSprite(s)) {
2377
2849
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
2378
2850
  }
2379
2851
  if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
@@ -2381,7 +2853,7 @@ function spriteLuma(layer, e) {
2381
2853
  }
2382
2854
  function spriteHex(layer, e) {
2383
2855
  const s = layer.sprite;
2384
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
2856
+ if (isShapedSprite(s)) {
2385
2857
  return s.colors?.[e.colorIndex] ?? s.color;
2386
2858
  }
2387
2859
  if (s.kind === "text" || s.kind === "textBlock") return s.color ?? "#e6e8ef";
@@ -2419,19 +2891,237 @@ function colourSeparation(layer, sprite, bg) {
2419
2891
  return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
2420
2892
  }
2421
2893
 
2894
+ // src/cohesion.ts
2895
+ var MERGE_FLOOR = 0.35;
2896
+ var WASH_ALPHA_MAX = 0.45;
2897
+ var COHESION_T = 5e3;
2898
+ var OUTLINE_SAMPLES = 24;
2899
+ var MAX_TRACED = 240;
2900
+ var EDGE_INSET = 1e-4;
2901
+ function circleShape(cx, cy, r) {
2902
+ return {
2903
+ cx,
2904
+ cy,
2905
+ br: r,
2906
+ area: Math.PI * r * r,
2907
+ contains: (x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r,
2908
+ outline: (n) => Array.from({ length: n }, (_, i) => {
2909
+ const a = i / n * Math.PI * 2;
2910
+ const inner = r * (1 - EDGE_INSET);
2911
+ return { x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) };
2912
+ })
2913
+ };
2914
+ }
2915
+ function polyShape(cx, cy, raw) {
2916
+ const abs = raw.filter((p, i) => {
2917
+ const q = raw[(i + 1) % raw.length];
2918
+ return Math.hypot(q.x - p.x, q.y - p.y) > 0;
2919
+ });
2920
+ if (abs.length < 3) return null;
2921
+ let br = 0;
2922
+ for (const p of abs) br = Math.max(br, Math.hypot(p.x - cx, p.y - cy));
2923
+ const inside = (x, y) => {
2924
+ let hit = false;
2925
+ for (let i = 0, j = abs.length - 1; i < abs.length; j = i++) {
2926
+ const a = abs[i];
2927
+ const b = abs[j];
2928
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) hit = !hit;
2929
+ }
2930
+ return hit;
2931
+ };
2932
+ const seg = abs.map((p, i) => {
2933
+ const q = abs[(i + 1) % abs.length];
2934
+ return Math.hypot(q.x - p.x, q.y - p.y);
2935
+ });
2936
+ const perimeter = seg.reduce((a, b) => a + b, 0);
2937
+ const shortest = Math.min(...seg);
2938
+ const step = Math.min(br * EDGE_INSET, shortest * 0.25);
2939
+ const area = polygonArea(abs);
2940
+ if (!(area > 0)) return null;
2941
+ return {
2942
+ cx,
2943
+ cy,
2944
+ br,
2945
+ area,
2946
+ contains: inside,
2947
+ /**
2948
+ * `n` points spread along the perimeter by ARC LENGTH, not one share per
2949
+ * edge — a long skinny rectangle has two edges carrying almost all of its
2950
+ * perimeter, and an equal-per-edge split would report a fully buried long
2951
+ * side as only a quarter covered. Each point is then stepped along its
2952
+ * edge's inward normal, whichever of the two candidates lands inside.
2953
+ */
2954
+ outline: (n) => {
2955
+ if (!(perimeter > 0)) return [];
2956
+ const out = [];
2957
+ for (let i = 0; i < n; i++) {
2958
+ let d = i / n * perimeter;
2959
+ let k = 0;
2960
+ while (k < seg.length - 1 && d > seg[k]) {
2961
+ d -= seg[k];
2962
+ k++;
2963
+ }
2964
+ const a = abs[k];
2965
+ const b = abs[(k + 1) % abs.length];
2966
+ const len = seg[k];
2967
+ const f = d / len;
2968
+ const px = a.x + (b.x - a.x) * f;
2969
+ const py = a.y + (b.y - a.y) * f;
2970
+ const ux = (b.x - a.x) / len;
2971
+ const uy = (b.y - a.y) / len;
2972
+ for (let t = step; t > 0; t /= 8) {
2973
+ const qx1 = px - uy * t;
2974
+ const qy1 = py + ux * t;
2975
+ if (inside(qx1, qy1)) {
2976
+ out.push({ x: qx1, y: qy1 });
2977
+ break;
2978
+ }
2979
+ const qx2 = px + uy * t;
2980
+ const qy2 = py - ux * t;
2981
+ if (inside(qx2, qy2)) {
2982
+ out.push({ x: qx2, y: qy2 });
2983
+ break;
2984
+ }
2985
+ if (t < step * 1e-3) break;
2986
+ }
2987
+ }
2988
+ return out;
2989
+ }
2990
+ };
2991
+ }
2992
+ function rectShape(cx, cy, halfW, halfH, rot) {
2993
+ const cos = Math.cos(rot);
2994
+ const sin = Math.sin(rot);
2995
+ const corner = (lx, ly) => ({ x: cx + lx * cos - ly * sin, y: cy + lx * sin + ly * cos });
2996
+ return polyShape(cx, cy, [
2997
+ corner(-halfW, -halfH),
2998
+ corner(halfW, -halfH),
2999
+ corner(halfW, halfH),
3000
+ corner(-halfW, halfH)
3001
+ ]);
3002
+ }
3003
+ function polygonShape(cx, cy, pts, rot) {
3004
+ const cos = Math.cos(rot);
3005
+ const sin = Math.sin(rot);
3006
+ return polyShape(cx, cy, pts.map((p) => ({ x: cx + p.x * cos - p.y * sin, y: cy + p.x * sin + p.y * cos })));
3007
+ }
3008
+ function shapeOf(layer, e, t, w, h) {
3009
+ const s = layer.sprite;
3010
+ if (s.kind !== "circle" && s.kind !== "rect" && s.kind !== "polygon") return null;
3011
+ const p = positionAt(e, t, w, h);
3012
+ const sz = sizeAt(e, t);
3013
+ if (!(sz > 0)) return null;
3014
+ if (s.kind === "circle") return circleShape(p.x, p.y, sz / 2);
3015
+ const rot = rotationAt(e, t);
3016
+ if (s.kind === "rect") {
3017
+ const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
3018
+ return rectShape(p.x, p.y, sz / 2, h2 / 2, rot);
3019
+ }
3020
+ return polygonShape(p.x, p.y, polygonPoints(s, sz / 2), rot);
3021
+ }
3022
+ function seamCauseOf(layer) {
3023
+ const s = layer.sprite;
3024
+ if ((s.kind === "circle" || s.kind === "polygon") && s.soft) {
3025
+ return "sprite.soft \u2014 radial falloff never forms a hard union";
3026
+ }
3027
+ if (s.kind === "rect" && s.feather) {
3028
+ return "rect.feather \u2014 a feathered edge never forms a hard union";
3029
+ }
3030
+ if (layer.blend) {
3031
+ return `blend: ${layer.blend} \u2014 overlaps composite instead of covering`;
3032
+ }
3033
+ const [lo, hi] = layer.alpha ?? [1, 1];
3034
+ if (lo < 1 || hi < 1) {
3035
+ return `alpha ${lo === hi ? lo : `${lo}..${hi}`} \u2014 every overlap paints a darker patch`;
3036
+ }
3037
+ if (layer.pulse && layer.pulse.amp > 0) {
3038
+ return "pulse \u2014 the layer is translucent for part of every cycle";
3039
+ }
3040
+ const palette = "colors" in s && Array.isArray(s.colors) ? s.colors : null;
3041
+ const weights = "colorWeights" in s && Array.isArray(s.colorWeights) ? s.colorWeights : null;
3042
+ if (palette) {
3043
+ const aligned = weights !== null && weights.length === palette.length;
3044
+ const reachable = new Set(palette.filter((_, i) => !aligned || (weights[i] ?? 0) > 0));
3045
+ if (reachable.size > 1) {
3046
+ return `sprite.colors has ${reachable.size} reachable colours \u2014 neighbours of unlike colour show their join`;
3047
+ }
3048
+ }
3049
+ return null;
3050
+ }
3051
+ function cohesionOf(layers, t, w, h) {
3052
+ return layers.map(({ layer, entities }, layerIndex) => {
3053
+ const seamCause = seamCauseOf(layer);
3054
+ const base = {
3055
+ layerIndex,
3056
+ key: layer.key,
3057
+ seamless: seamCause === null,
3058
+ seamCause
3059
+ };
3060
+ if (lifeAlphaAt(layer.life, t) <= 0) return { ...base, overlap: null, reads: null };
3061
+ const shapes = entities.map((e) => shapeOf(layer, e, t, w, h));
3062
+ const solid = shapes.filter((x) => x !== null && x.area > 0);
3063
+ if (solid.length < 2) return { ...base, overlap: null, reads: null };
3064
+ const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
3065
+ let covered = 0;
3066
+ let traced = 0;
3067
+ for (let i = 0; i < solid.length; i += stride) {
3068
+ const self = solid[i];
3069
+ const pts = self.outline(OUTLINE_SAMPLES);
3070
+ if (pts.length === 0) continue;
3071
+ let hit = 0;
3072
+ for (const p of pts) {
3073
+ for (let j = 0; j < solid.length; j++) {
3074
+ if (j === i) continue;
3075
+ const other = solid[j];
3076
+ const dx = p.x - other.cx;
3077
+ const dy = p.y - other.cy;
3078
+ if (dx * dx + dy * dy > other.br * other.br) continue;
3079
+ if (other.contains(p.x, p.y)) {
3080
+ hit++;
3081
+ break;
3082
+ }
3083
+ }
3084
+ }
3085
+ covered += hit / pts.length;
3086
+ traced++;
3087
+ }
3088
+ if (traced === 0) return { ...base, overlap: null, reads: null };
3089
+ const overlap = Number((covered / traced).toFixed(4));
3090
+ const reads = overlap < MERGE_FLOOR ? "marks" : seamCause === null ? "mass" : "seamed";
3091
+ return { ...base, overlap, reads };
3092
+ });
3093
+ }
3094
+ function seamsWorthWarning(layer, c, entityCount) {
3095
+ if (c.reads !== "seamed" || entityCount < 6) return false;
3096
+ const [lo, hi] = layer.alpha ?? [1, 1];
3097
+ if ((lo + hi) / 2 < WASH_ALPHA_MAX) return false;
3098
+ return layer.blend !== "lighter" && layer.blend !== "screen";
3099
+ }
3100
+
2422
3101
  // src/advise.ts
2423
3102
  var LOW_CONTRAST_FLOOR = 0.05;
2424
- function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
3103
+ var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
3104
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
2425
3105
  const warnings = [];
2426
3106
  const w = viewport.width;
2427
3107
  const h = viewport.height;
2428
3108
  const scale = spec.units === "px" ? 1 : Math.min(w, h);
2429
3109
  const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
2430
- const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
2431
- const rng = createRng2(spec.seed ?? 42);
3110
+ let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
3111
+ if (countScale > 1) {
3112
+ const rawTotal = spec.layers.reduce((sum, l) => sum + Math.round(l.count * countScale), 0);
3113
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
3114
+ }
3115
+ const rng = createRng2(opts.seed ?? spec.seed ?? 42);
2432
3116
  const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
2433
3117
  const bgLuma = backgroundLuma(spec);
2434
3118
  const bgRgb = backgroundRgb(spec);
3119
+ const cohesion = cohesionOf(
3120
+ spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
3121
+ opts.t ?? COHESION_T,
3122
+ w,
3123
+ h
3124
+ );
2435
3125
  let totalEntities = 0;
2436
3126
  let textLayerCount = 0;
2437
3127
  let motionLayerCount = 0;
@@ -2443,6 +3133,14 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2443
3133
  const isStaticText = isText && layer.motion.type === "static";
2444
3134
  if (isStaticText) textLayerCount++;
2445
3135
  if (layer.motion.type !== "static") motionLayerCount++;
3136
+ const coh = cohesion[li];
3137
+ if (seamsWorthWarning(layer, coh, entities.length)) {
3138
+ warnings.push({
3139
+ path: `layers[${li}]`,
3140
+ code: "overlap-seams",
3141
+ 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.`
3142
+ });
3143
+ }
2446
3144
  if (layer.trail && layer.motion.type === "static") {
2447
3145
  warnings.push({
2448
3146
  path: `layers[${li}].trail`,
@@ -2450,11 +3148,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2450
3148
  message: "trail has no effect on static entities \u2014 they have no past positions to draw"
2451
3149
  });
2452
3150
  }
2453
- if (layer.sprite.kind === "streak" && layer.motion.type === "static") {
3151
+ if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static") {
2454
3152
  warnings.push({
2455
3153
  path: `layers[${li}].sprite`,
2456
3154
  code: "streak-on-static",
2457
- message: "streak sprites orient along the motion heading \u2014 static entities have none and will render at angle 0"
3155
+ 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
3156
  });
2459
3157
  }
2460
3158
  if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
@@ -2501,6 +3199,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2501
3199
  let pixArea;
2502
3200
  if (layer.sprite.kind === "circle") {
2503
3201
  pixArea = Math.PI * r * r;
3202
+ } else if (layer.sprite.kind === "bar") {
3203
+ pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
2504
3204
  } else if (layer.sprite.kind === "textBlock") {
2505
3205
  const fsPx = layer.sprite.fontSize * scale;
2506
3206
  const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
@@ -2510,7 +3210,13 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2510
3210
  } else {
2511
3211
  pixArea = e.size * e.size;
2512
3212
  }
2513
- totalCoverage += pixArea * e.alpha / (w * h);
3213
+ let duty = 1;
3214
+ if (layer.emit) {
3215
+ const [ga, gb] = layer.emit.grow ?? [1, 1];
3216
+ const g2 = (ga * ga + ga * gb + gb * gb) / 3;
3217
+ duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
3218
+ }
3219
+ totalCoverage += pixArea * e.alpha * duty / (w * h);
2514
3220
  }
2515
3221
  if (layer.links) {
2516
3222
  const positions = entities.map((e) => positionAt(e, 0, w, h));
@@ -2521,20 +3227,35 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2521
3227
  for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
2522
3228
  }
2523
3229
  }
2524
- if (totalCoverage < 5e-4 && spec.layers.length > 0) {
3230
+ const density = spec.density ?? "normal";
3231
+ const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
3232
+ if (wouldBeSparse && density !== "sparse") {
2525
3233
  warnings.push({
2526
3234
  path: "layers",
2527
3235
  code: "sparse-scene",
2528
3236
  message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
2529
3237
  });
2530
3238
  }
2531
- if (totalEntities > 500) {
3239
+ if (totalEntities > 500 && density !== "dense") {
2532
3240
  warnings.push({
2533
3241
  path: "layers",
2534
3242
  code: "dense-scene",
2535
3243
  message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
2536
3244
  });
2537
3245
  }
3246
+ if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
3247
+ warnings.push({
3248
+ path: "density",
3249
+ code: "density-mismatch",
3250
+ message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
3251
+ });
3252
+ } else if (density === "dense" && wouldBeSparse) {
3253
+ warnings.push({
3254
+ path: "density",
3255
+ code: "density-mismatch",
3256
+ message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
3257
+ });
3258
+ }
2538
3259
  if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
2539
3260
  const textRatio = textLayerCount / spec.layers.length;
2540
3261
  if (textRatio >= 0.8) {
@@ -2982,6 +3703,36 @@ function luminanceGrid(spec, opts = {}) {
2982
3703
  const p = posOf(scene, e, tPass);
2983
3704
  const sz = sizeAt(e, tPass);
2984
3705
  const s = layer.sprite;
3706
+ if (s.kind === "stroke") {
3707
+ const pts = strokeSamples(s, sz / 2);
3708
+ let angle = rotationAt(e, tPass);
3709
+ if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
3710
+ const ca = Math.cos(angle);
3711
+ const sa = Math.sin(angle);
3712
+ const lw = strokeWidthPx(s, scale);
3713
+ const stampAt = (q, u) => {
3714
+ const x = p.x + q.x * ca - q.y * sa;
3715
+ const y = p.y + q.x * sa + q.y * ca;
3716
+ const c = Math.floor(x / cellW);
3717
+ const r = Math.floor(y / cellH);
3718
+ if (c < 0 || c >= cols || r < 0 || r >= rows) return;
3719
+ const taper = s.taper ? strokeTaper(u) : 1;
3720
+ compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
3721
+ };
3722
+ const step = Math.min(cellW, cellH) / 2;
3723
+ stampAt(pts[0], 0);
3724
+ for (let i = 1; i < pts.length; i++) {
3725
+ const q0 = pts[i - 1];
3726
+ const q1 = pts[i];
3727
+ const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
3728
+ const sub = Math.max(1, Math.ceil(segLen / step));
3729
+ for (let k = 1; k <= sub; k++) {
3730
+ const f = k / sub;
3731
+ stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
3732
+ }
3733
+ }
3734
+ continue;
3735
+ }
2985
3736
  if (s.kind === "streak") {
2986
3737
  const heading = headingAt(e, tPass, w, h) ?? 0;
2987
3738
  const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
@@ -3013,9 +3764,22 @@ function luminanceGrid(spec, opts = {}) {
3013
3764
  centerY = box.cy;
3014
3765
  halfX = box.halfX;
3015
3766
  halfY = box.halfY;
3767
+ } else if (s.kind === "bar") {
3768
+ const len = sz * barFraction(s, e.barIndex ?? 0);
3769
+ if (len <= 0) continue;
3770
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
3771
+ const box = barBox(s.direction ?? "right", len, th);
3772
+ const rot = rotationAt(e, tPass);
3773
+ const cr = Math.cos(rot);
3774
+ const sr = Math.sin(rot);
3775
+ centerX = p.x + box.cx * cr - box.cy * sr;
3776
+ centerY = p.y + box.cx * sr + box.cy * cr;
3777
+ halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
3778
+ halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
3016
3779
  }
3017
- const circular = s.kind === "circle" || s.kind === "ring";
3018
- const soft = s.kind === "circle" && !!s.soft;
3780
+ const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
3781
+ const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
3782
+ const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
3019
3783
  const additive = layer.blend === "lighter" || layer.blend === "screen";
3020
3784
  const glow = soft && additive;
3021
3785
  const reachX = glow ? halfX * GLOW_SPREAD : halfX;
@@ -3048,9 +3812,9 @@ function luminanceGrid(spec, opts = {}) {
3048
3812
  } else {
3049
3813
  wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
3050
3814
  }
3051
- compose(r * cols + c, lum, a * wgt, layer.blend);
3815
+ compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
3052
3816
  } else {
3053
- compose(r * cols + c, lum, a * inkWeight, layer.blend);
3817
+ compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
3054
3818
  }
3055
3819
  }
3056
3820
  }
@@ -3238,7 +4002,15 @@ function dominanceRanking(spec, opts = {}) {
3238
4002
  else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
3239
4003
  else if (s.kind === "rect") {
3240
4004
  const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
3241
- entArea = sz * h2;
4005
+ const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
4006
+ entArea = sz * h2 * f;
4007
+ } else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
4008
+ else if (s.kind === "bar") {
4009
+ const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
4010
+ entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
4011
+ } else if (s.kind === "stroke") {
4012
+ const meanTaper = s.taper ? 0.64 : 1;
4013
+ entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
3242
4014
  } else if (s.kind === "text") {
3243
4015
  const box = textBox(s, e, { x: 0, y: 0 });
3244
4016
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
@@ -3283,6 +4055,10 @@ function dominanceRanking(spec, opts = {}) {
3283
4055
  factors: r.factors
3284
4056
  }));
3285
4057
  }
4058
+ function layerCohesion(spec, opts = {}) {
4059
+ const scene = buildScene(spec, opts);
4060
+ return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
4061
+ }
3286
4062
  function motionStats(spec, opts = {}) {
3287
4063
  const scene = buildScene(spec, opts);
3288
4064
  const t = opts.t ?? 5e3;
@@ -3379,8 +4155,9 @@ function perceiveScene(spec, opts = {}) {
3379
4155
  colProfile: grid.colProfile,
3380
4156
  dominance: dominanceRanking(spec, opts),
3381
4157
  motion: motionStats(spec, opts),
4158
+ form: layerCohesion(spec, opts),
3382
4159
  text: textSprites(spec, opts),
3383
- advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 })
4160
+ advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
3384
4161
  };
3385
4162
  }
3386
4163
 
@@ -4096,6 +4873,77 @@ var DASHBOARD_SPEC = {
4096
4873
  ]
4097
4874
  };
4098
4875
 
4876
+ // src/examples/facets.ts
4877
+ var FACETS_SPEC = {
4878
+ schemaVersion: 1,
4879
+ id: "facets",
4880
+ label: "Facets",
4881
+ seed: 1911,
4882
+ motionIntensity: "calm",
4883
+ background: {
4884
+ type: "gradient",
4885
+ stops: [
4886
+ { at: 0, color: "#101626" },
4887
+ { at: 1, color: "#1c1a2e" }
4888
+ ]
4889
+ },
4890
+ layers: [
4891
+ {
4892
+ key: "planes",
4893
+ count: 3,
4894
+ sprite: {
4895
+ kind: "rect",
4896
+ width: [0.28, 0.42],
4897
+ aspect: [0.6, 1.4],
4898
+ color: "#b3402f",
4899
+ colors: ["#b3402f", "#c9862b", "#2d4f8a"],
4900
+ feather: 0.6
4901
+ },
4902
+ alpha: [0.35, 0.5],
4903
+ region: { x: [0.2, 0.8], y: [0.2, 0.8] },
4904
+ motion: { type: "wander", speed: [1e-3, 3e-3], meander: 0.02 },
4905
+ spin: [-2, 2]
4906
+ },
4907
+ {
4908
+ key: "facets",
4909
+ count: 14,
4910
+ sprite: {
4911
+ kind: "polygon",
4912
+ radius: [0.02, 0.06],
4913
+ points: [[-1, 0.9], [0.2, -1], [1, 0.4]],
4914
+ color: "#f2e8c9",
4915
+ colors: ["#f2e8c9", "#e0b53a", "#6fa8dc", "#d94f3d"]
4916
+ },
4917
+ alpha: [0.6, 0.9],
4918
+ motion: { type: "wander", speed: [3e-3, 8e-3], meander: 0.03 },
4919
+ spin: [-8, 8]
4920
+ },
4921
+ {
4922
+ key: "arcs",
4923
+ count: 12,
4924
+ sprite: {
4925
+ kind: "stroke",
4926
+ length: [0.08, 0.18],
4927
+ points: [[-1, 0.3], [-0.5, -0.6], [0.3, -0.7], [1, 0.1]],
4928
+ width: 4e-3,
4929
+ taper: true,
4930
+ color: "#f2e8c9"
4931
+ },
4932
+ alpha: [0.5, 0.8],
4933
+ motion: { type: "drift", speed: [4e-3, 0.01], angle: 20, bidirectional: true },
4934
+ spin: [-4, 4]
4935
+ },
4936
+ {
4937
+ key: "hexes",
4938
+ count: 8,
4939
+ sprite: { kind: "polygon", radius: [8e-3, 0.016], sides: 6, color: "#e0b53a", soft: true },
4940
+ alpha: [0.5, 0.8],
4941
+ blend: "lighter",
4942
+ motion: { type: "wander", speed: [2e-3, 5e-3] }
4943
+ }
4944
+ ]
4945
+ };
4946
+
4099
4947
  // src/examples/haiku.ts
4100
4948
  var HAIKU_SPEC = {
4101
4949
  schemaVersion: 1,
@@ -4488,6 +5336,40 @@ var ORRERY_SPEC = {
4488
5336
  ]
4489
5337
  };
4490
5338
 
5339
+ // src/examples/pings.ts
5340
+ var PINGS_SPEC = {
5341
+ schemaVersion: 1,
5342
+ id: "pings",
5343
+ label: "Pings",
5344
+ seed: 7,
5345
+ motionIntensity: "calm",
5346
+ ghosting: 0.9,
5347
+ background: { type: "solid", color: "#03060a" },
5348
+ layers: [
5349
+ {
5350
+ key: "ping",
5351
+ count: 3,
5352
+ sprite: { kind: "ring", radius: [0.05, 0.05], color: "#4fb3a8", width: 15e-4 },
5353
+ alpha: [0.35, 0.35],
5354
+ blend: "lighter",
5355
+ region: { x: [0.2, 0.8], y: [0.25, 0.75] },
5356
+ motion: { type: "static" },
5357
+ // One ring at a time: three entities evenly staggered across 12 s.
5358
+ emit: { every: 12e3, life: 4e3, jitter: 0, grow: [0.15, 2.4] }
5359
+ },
5360
+ {
5361
+ key: "motes",
5362
+ count: 14,
5363
+ sprite: { kind: "circle", radius: [12e-4, 2e-3], color: "#4fb3a8", soft: true },
5364
+ alpha: [0.1, 0.22],
5365
+ blend: "lighter",
5366
+ // Each mote is flung upward and comes to rest within ~2.5 s of its event.
5367
+ motion: { type: "rise", speed: [0.01, 0.02], sway: 4e-3, ease: { type: "settle", tau: 2500 } },
5368
+ emit: { every: 16e3, life: 7e3, grow: [0.6, 1.4] }
5369
+ }
5370
+ ]
5371
+ };
5372
+
4491
5373
  // src/examples/procession.ts
4492
5374
  var PROCESSION_SPEC = {
4493
5375
  schemaVersion: 1,
@@ -4584,6 +5466,74 @@ var RAIN_SPEC = {
4584
5466
  ]
4585
5467
  };
4586
5468
 
5469
+ // src/examples/relay-board.ts
5470
+ var RELAY_BOARD_SPEC = {
5471
+ schemaVersion: 1,
5472
+ id: "relay-board",
5473
+ label: "Relay Board",
5474
+ seed: 9,
5475
+ motionIntensity: "calm",
5476
+ background: {
5477
+ type: "gradient",
5478
+ stops: [
5479
+ { at: 0, color: "#05070d" },
5480
+ { at: 1, color: "#0a0d18" }
5481
+ ]
5482
+ },
5483
+ layers: [
5484
+ {
5485
+ key: "dust",
5486
+ count: 40,
5487
+ sprite: { kind: "circle", radius: [8e-4, 16e-4], color: "#22344a", soft: true },
5488
+ alpha: [0.2, 0.45],
5489
+ blend: "lighter",
5490
+ motion: { type: "wander", speed: [1e-3, 3e-3] }
5491
+ },
5492
+ {
5493
+ key: "title",
5494
+ count: 1,
5495
+ sprite: { kind: "textBlock", text: "RELAY LINKS \u2014 UPLINK MARGIN", maxWidth: 0.6, fontSize: 0.028, color: "#9fb4c9" },
5496
+ motion: { type: "static" },
5497
+ position: { x: 0.12, y: 0.16 }
5498
+ },
5499
+ {
5500
+ key: "labels",
5501
+ count: 6,
5502
+ sprite: { kind: "text", strings: ["VIREO-9", "TIDE", "ORRERY", "COMET", "LANTERN", "SAKURA"], font: "bold monospace", align: "left", color: "#e6e8ef" },
5503
+ size: [0.024, 0.024],
5504
+ motion: { type: "static" },
5505
+ position: { x: 0.12, y: 0.3 },
5506
+ layout: { type: "list", gap: 0.075 }
5507
+ },
5508
+ {
5509
+ key: "bars",
5510
+ count: 6,
5511
+ sprite: {
5512
+ kind: "bar",
5513
+ values: [82, 64, 91, 37, 58, 73],
5514
+ max: 100,
5515
+ length: 0.5,
5516
+ thickness: 0.02,
5517
+ color: "#17e8c8",
5518
+ colors: ["#17e8c8", "#35d0ff", "#ffd93b", "#ff6ad5", "#ad8cff", "#17e8c8"]
5519
+ },
5520
+ alpha: [0.85, 0.85],
5521
+ motion: { type: "static" },
5522
+ position: { x: 0.3, y: 0.3 },
5523
+ layout: { type: "list", gap: 0.075 }
5524
+ },
5525
+ {
5526
+ key: "readouts",
5527
+ count: 6,
5528
+ sprite: { kind: "text", strings: ["82%", "64%", "91%", "37%", "58%", "73%"], font: "monospace", align: "left", color: "#9fb4c9" },
5529
+ size: [0.02, 0.02],
5530
+ motion: { type: "static" },
5531
+ position: { x: 0.86, y: 0.3 },
5532
+ layout: { type: "list", gap: 0.075 }
5533
+ }
5534
+ ]
5535
+ };
5536
+
4587
5537
  // src/examples/sakura.ts
4588
5538
  var SAKURA_SPEC = {
4589
5539
  schemaVersion: 1,
@@ -4820,7 +5770,10 @@ var SCHEMA_EXAMPLES = [
4820
5770
  { id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
4821
5771
  { id: "procession", label: "Night Procession", spec: PROCESSION_SPEC },
4822
5772
  { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC },
4823
- { id: "haiku", label: "Haiku", spec: HAIKU_SPEC }
5773
+ { id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
5774
+ { id: "pings", label: "Pings", spec: PINGS_SPEC },
5775
+ { id: "facets", label: "Facets", spec: FACETS_SPEC },
5776
+ { id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC }
4824
5777
  ];
4825
5778
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
4826
5779
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -4829,20 +5782,24 @@ var EXAMPLE_BY_ID = Object.fromEntries(
4829
5782
  export {
4830
5783
  AQUARIUM_SPEC,
4831
5784
  AURORA_SPEC,
5785
+ COHESION_T,
4832
5786
  COMETS_SPEC,
4833
5787
  CONSTELLATION_SPEC,
4834
5788
  DASHBOARD_SPEC,
4835
5789
  EXAMPLE_BY_ID,
4836
5790
  EXAMPLE_SPECS,
5791
+ FACETS_SPEC,
4837
5792
  HAIKU_SPEC,
4838
5793
  LANTERNS_SPEC,
4839
5794
  LIMITS,
4840
5795
  MATRIX_RAIN_SPEC,
4841
5796
  NOSTALGHIA_CANDLE_SPEC,
4842
5797
  ORRERY_SPEC,
5798
+ PINGS_SPEC,
4843
5799
  POLYGONS_SPEC,
4844
5800
  PROCESSION_SPEC,
4845
5801
  RAIN_SPEC,
5802
+ RELAY_BOARD_SPEC,
4846
5803
  SAKURA_SPEC,
4847
5804
  SCHEMA_EXAMPLES,
4848
5805
  SCHEMA_VERSION,
@@ -4857,14 +5814,18 @@ export {
4857
5814
  assertValidSpec,
4858
5815
  breakTextBlock,
4859
5816
  buildEntities,
5817
+ cohesionOf,
4860
5818
  compileSaver,
4861
5819
  compileSequence,
4862
5820
  describeScene,
4863
5821
  diffScenes,
4864
5822
  dominanceRanking,
4865
5823
  easeSmooth,
5824
+ emitEnvelope,
5825
+ emitWindow,
4866
5826
  graphemeClusters,
4867
5827
  headingAt,
5828
+ layerCohesion,
4868
5829
  lerpSpec,
4869
5830
  lifeAlphaAt,
4870
5831
  linkEdges,
@@ -4880,6 +5841,8 @@ export {
4880
5841
  resolveSpecPath,
4881
5842
  revealState,
4882
5843
  rotationAt,
5844
+ seamCauseOf,
5845
+ seamsWorthWarning,
4883
5846
  segmentStart,
4884
5847
  sizeAt,
4885
5848
  spriteIndexAt,