@idle-screens/schema 3.4.5 → 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
- var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret"]);
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") {
@@ -604,8 +766,11 @@ function validateSprite(sprite, path, err, warn) {
604
766
  if (rv.progress !== void 0 && (!isNum(rv.progress) || rv.progress < 0 || rv.progress > 1)) {
605
767
  err(`${path}.reveal.progress`, "must be a number between 0 and 1");
606
768
  }
607
- if (rv.mode !== void 0 && !["typewriter", "word", "line"].includes(rv.mode)) {
608
- err(`${path}.reveal.mode`, "must be 'typewriter' | 'word' | 'line'");
769
+ if (rv.mode !== void 0 && !["typewriter", "word", "line", "glyphFade"].includes(rv.mode)) {
770
+ err(`${path}.reveal.mode`, "must be 'typewriter' | 'word' | 'line' | 'glyphFade'");
771
+ }
772
+ if (rv.fade !== void 0 && (!isNum(rv.fade) || rv.fade <= 0 || rv.fade > 1)) {
773
+ err(`${path}.reveal.fade`, "must be between 0 (exclusive) and 1 (fraction of progress)");
609
774
  }
610
775
  if (rv.speed !== void 0 && (!isNum(rv.speed) || rv.speed < 0 || rv.speed > LIMITS.maxRevealSpeed)) {
611
776
  err(`${path}.reveal.speed`, `must be between 0 and ${LIMITS.maxRevealSpeed} (graphemes/sec)`);
@@ -627,7 +792,7 @@ function validateSprite(sprite, path, err, warn) {
627
792
  }
628
793
  }
629
794
  } else {
630
- 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");
631
796
  return;
632
797
  }
633
798
  for (const k of unknownKeys(sprite, knownSet)) {
@@ -680,6 +845,7 @@ function validateMotion(motion, path, err, warn, spec) {
680
845
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
681
846
  if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
682
847
  if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
848
+ validateEase(motion.ease, `${path}.ease`, err, warn);
683
849
  if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
684
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`);
685
851
  }
@@ -687,6 +853,7 @@ function validateMotion(motion, path, err, warn, spec) {
687
853
  knownSet = KNOWN_RISE;
688
854
  speedOk(motion.speed, `${path}.speed`);
689
855
  if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
856
+ validateEase(motion.ease, `${path}.ease`, err, warn);
690
857
  if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
691
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`);
692
859
  }
@@ -736,6 +903,7 @@ function validateMotion(motion, path, err, warn, spec) {
736
903
  }
737
904
  } else if (motion.type === "wander") {
738
905
  knownSet = KNOWN_WANDER;
906
+ validateEase(motion.ease, `${path}.ease`, err, warn);
739
907
  speedOk(motion.speed, `${path}.speed`);
740
908
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
741
909
  const meanderCap = isViewport ? LIMITS.maxMeander / refVp : LIMITS.maxMeander;
@@ -787,6 +955,28 @@ function validateMotion(motion, path, err, warn, spec) {
787
955
  warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
788
956
  }
789
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
+ }
790
980
  function assertValidSpec(spec) {
791
981
  const r = validateSpec(spec);
792
982
  if (!r.valid) {
@@ -904,6 +1094,110 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
904
1094
  return seq;
905
1095
  }
906
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
+
907
1201
  // src/simulate.ts
908
1202
  var WARP_NEAR = 0.08;
909
1203
  var WARP_MAX_SCALE = 8;
@@ -932,6 +1226,21 @@ function drawOsc(rng, amp) {
932
1226
  }
933
1227
  return osc;
934
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
+ }
935
1244
  function weightedIndex(u, weights) {
936
1245
  const total = weights.reduce((a, b) => a + b, 0);
937
1246
  let acc = 0;
@@ -942,15 +1251,15 @@ function weightedIndex(u, weights) {
942
1251
  return weights.length - 1;
943
1252
  }
944
1253
  function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
945
- 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(
946
1255
  Math.round(layer.count * countScale),
947
1256
  layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
948
1257
  ));
949
1258
  const [smin, smax] = layer.size ?? [20, 40];
950
1259
  const variants = spriteVariants(layer.sprite);
951
1260
  const sprite = layer.sprite;
952
- const spriteColors = sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect" ? sprite.colors : void 0;
953
- 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;
954
1263
  const colorsLen = spriteColors?.length ?? 0;
955
1264
  const cycle = sprite.kind === "emoji" || sprite.kind === "text" ? sprite.cycle : void 0;
956
1265
  const mSpec = layer.motion;
@@ -964,7 +1273,32 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
964
1273
  if (mSpec.type === "path") {
965
1274
  pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
966
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;
967
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
+ }
968
1302
  let gridCols = 0;
969
1303
  let cellW = 0;
970
1304
  let cellH = 0;
@@ -987,8 +1321,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
987
1321
  }
988
1322
  const out = [];
989
1323
  for (let i = 0; i < effectiveCount; i++) {
990
- 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;
991
- 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;
992
1326
  let vx = 0;
993
1327
  let vy = 0;
994
1328
  let bob = 0;
@@ -1052,6 +1386,11 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1052
1386
  if (layer.position && layer.count === 1) {
1053
1387
  x0 = layer.position.x * w;
1054
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;
1055
1394
  } else if (layout?.type === "grid") {
1056
1395
  const col = i % gridCols;
1057
1396
  const row = Math.floor(i / gridCols);
@@ -1071,7 +1410,10 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1071
1410
  x0,
1072
1411
  y0,
1073
1412
  size,
1074
- 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,
1075
1417
  phase: rng.range(0, Math.PI * 2),
1076
1418
  vx,
1077
1419
  vy,
@@ -1095,10 +1437,12 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1095
1437
  orbitCx: m.type === "orbit" && !orbitParent ? (m.center?.x ?? 0.5) * w : 0,
1096
1438
  orbitCy: m.type === "orbit" && !orbitParent ? (m.center?.y ?? 0.5) * h : 0,
1097
1439
  // New draws AFTER orbit — guarded so existing specs keep identical streams.
1098
- 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,
1099
1441
  cyclePeriod: cycle ? cycle.period : 0,
1100
1442
  // Optional feature fields last (guarded draws inside — wander draws 18 here).
1101
1443
  ...size2 !== void 0 ? { size2 } : {},
1444
+ ...sprite.kind === "bar" ? { barIndex: i } : {},
1445
+ ...ordered ? { ordered: true } : {},
1102
1446
  ...orbitParent ? { orbitParent } : {},
1103
1447
  ...m.type === "wander" ? {
1104
1448
  wander: {
@@ -1119,33 +1463,80 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1119
1463
  offX: pathOffX,
1120
1464
  offY: pathOffY
1121
1465
  }
1122
- } : {}
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 } } : {}
1123
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
+ }
1124
1478
  if (layer.pulse?.wave) {
1125
1479
  const wl = Math.max(1e-6, layer.pulse.wave.wavelength * scale);
1126
1480
  const ang = (layer.pulse.wave.angle ?? 0) * Math.PI / 180;
1127
- 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;
1128
1482
  }
1129
1483
  }
1484
+ if (layer.emit) {
1485
+ for (let i = 0; i < out.length; i++) out[i].emit = emitParams(layer.emit, i, out.length);
1486
+ }
1130
1487
  return out;
1131
1488
  }
1132
1489
  function warpZ(e, t) {
1133
1490
  const wp = e.warp;
1134
1491
  return wrap(wp.z0 - wp.vz * (t / 1e3), WARP_NEAR, 1);
1135
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
+ }
1136
1519
  function alphaAt(e, t) {
1137
1520
  let a = e.alpha;
1138
- 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);
1139
1522
  if (e.motion === "warp") {
1140
1523
  const z = warpZ(e, t);
1141
1524
  a *= Math.max(0, Math.min(1, (1 - z) / 0.2));
1142
1525
  }
1526
+ if (e.emit) {
1527
+ const u = emitWindow(e, t);
1528
+ a = u === null ? 0 : a * emitEnvelope(u);
1529
+ }
1143
1530
  return a < 0 ? 0 : a > 1 ? 1 : a;
1144
1531
  }
1145
1532
  function sizeAt(e, t) {
1146
1533
  let s = e.size;
1147
- 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));
1148
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
+ }
1149
1540
  return s > 0 ? s : 0.1;
1150
1541
  }
1151
1542
  function rotationAt(e, t) {
@@ -1176,7 +1567,7 @@ function reflect(v, min, max) {
1176
1567
  const p = ((v - min) % period + period) % period;
1177
1568
  return min + (p < range ? p : period - p);
1178
1569
  }
1179
- function catmullRom(p0, p1, p2, p3, u) {
1570
+ function catmullRom2(p0, p1, p2, p3, u) {
1180
1571
  const u2 = u * u;
1181
1572
  const u3 = u2 * u;
1182
1573
  return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
@@ -1206,8 +1597,8 @@ function pathPosition(e, t) {
1206
1597
  const p0 = at(i - 1);
1207
1598
  const p3 = at(i + 2);
1208
1599
  return {
1209
- x: catmullRom(p0.x, p1.x, p2.x, p3.x, local) + p.offX,
1210
- 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
1211
1602
  };
1212
1603
  }
1213
1604
  function positionAt(e, t, w, h) {
@@ -1234,7 +1625,7 @@ function positionAt(e, t, w, h) {
1234
1625
  const pos = pathPosition(e, t);
1235
1626
  return { x: pos.x, y: pos.y, flip: false };
1236
1627
  }
1237
- const dt = t / 1e3;
1628
+ const dt = motionSeconds(e, t);
1238
1629
  const m = e.size;
1239
1630
  if (e.motion === "bounce") {
1240
1631
  return {
@@ -1291,7 +1682,8 @@ function headingAt(e, t, w, h) {
1291
1682
  }
1292
1683
  function spriteIndexAt(e, t, variants) {
1293
1684
  if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
1294
- 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;
1295
1687
  }
1296
1688
  function wrapDist(pi, pj, doWrap, w, h) {
1297
1689
  let dx = pj.x - pi.x;
@@ -1462,6 +1854,17 @@ function revealState(lines, reveal, tMs) {
1462
1854
  const mode = reveal.mode ?? "typewriter";
1463
1855
  let fullLines = 0;
1464
1856
  let partialText = "";
1857
+ let glyphAlphas;
1858
+ if (mode === "glyphFade") {
1859
+ const f = Math.min(1, Math.max(0.01, reveal.fade ?? 0.15));
1860
+ let g = 0;
1861
+ glyphAlphas = lineClusters.map(
1862
+ (clusters) => clusters.map(() => {
1863
+ const start = totalGraphemes > 0 ? g++ / totalGraphemes * (1 - f) : 0;
1864
+ return Math.max(0, Math.min(1, (progress - start) / f));
1865
+ })
1866
+ );
1867
+ }
1465
1868
  if (mode === "line") {
1466
1869
  fullLines = Math.round(progress * lines.length);
1467
1870
  } else if (mode === "word") {
@@ -1501,7 +1904,7 @@ function revealState(lines, reveal, tMs) {
1501
1904
  caretLine = 0;
1502
1905
  caretPrefix = "";
1503
1906
  }
1504
- return { progress, fullLines, partialText, caretLine, caretPrefix };
1907
+ return { progress, fullLines, partialText, caretLine, caretPrefix, glyphAlphas };
1505
1908
  }
1506
1909
 
1507
1910
  // src/compile.ts
@@ -1510,7 +1913,13 @@ import {
1510
1913
  } from "@idle-screens/core";
1511
1914
 
1512
1915
  // src/sequence.ts
1513
- function resolveSegment(seq, T) {
1916
+ function segmentStart(seq, index) {
1917
+ let t = 0;
1918
+ const n = Math.max(0, Math.min(index, seq.segments.length - 1));
1919
+ for (let i = 0; i < n; i++) t += seq.segments[i].duration ?? 0;
1920
+ return t;
1921
+ }
1922
+ function resolveSegment(seq, T, opts = {}) {
1514
1923
  const { segments, loop } = seq;
1515
1924
  if (segments.length === 0) return { index: 0, localT: 0, startT: 0 };
1516
1925
  let totalTimed = 0;
@@ -1518,24 +1927,33 @@ function resolveSegment(seq, T) {
1518
1927
  if (seg.duration != null) totalTimed += seg.duration;
1519
1928
  else break;
1520
1929
  }
1521
- let t = Math.max(0, T);
1522
- if (loop && totalTimed > 0) {
1523
- t = t % totalTimed;
1524
- }
1525
- let cumulative = 0;
1526
- for (let i = 0; i < segments.length; i++) {
1527
- const seg = segments[i];
1528
- if (seg.duration == null) {
1529
- return { index: i, localT: t - cumulative, startT: cumulative };
1530
- }
1531
- if (t < cumulative + seg.duration) {
1532
- return { index: i, localT: t - cumulative, startT: cumulative };
1930
+ const t0 = Math.max(0, T);
1931
+ const walk = (t, releasedBelow) => {
1932
+ let cumulative = 0;
1933
+ for (let i = 0; i < segments.length; i++) {
1934
+ const seg = segments[i];
1935
+ if (seg.duration == null) {
1936
+ return loop ? null : { index: i, localT: t - cumulative, startT: cumulative };
1937
+ }
1938
+ if (t < cumulative + seg.duration) {
1939
+ return { index: i, localT: t - cumulative, startT: cumulative };
1940
+ }
1941
+ if (seg.advance === "input" && i >= releasedBelow) {
1942
+ return { index: i, localT: t - cumulative, startT: cumulative, held: true };
1943
+ }
1944
+ cumulative += seg.duration;
1533
1945
  }
1534
- cumulative += seg.duration;
1946
+ return null;
1947
+ };
1948
+ const first = walk(t0, opts.releasedBelow ?? 0);
1949
+ if (first) return first;
1950
+ if (loop && totalTimed > 0) {
1951
+ const wrapped = walk(t0 % totalTimed, 0);
1952
+ if (wrapped) return wrapped;
1535
1953
  }
1536
1954
  const last = segments.length - 1;
1537
- const lastStart = cumulative - (segments[last].duration ?? 0);
1538
- return { index: last, localT: t - lastStart, startT: lastStart };
1955
+ const lastStart = totalTimed - (segments[last].duration ?? 0);
1956
+ return { index: last, localT: t0 - lastStart, startT: lastStart };
1539
1957
  }
1540
1958
 
1541
1959
  // src/compile.ts
@@ -1727,7 +2145,7 @@ var SpecInstance = class {
1727
2145
  const headAlpha = alphaAt(e, t) * lifeA;
1728
2146
  const headSize = sizeAt(e, t);
1729
2147
  const sprite = built.layer.sprite;
1730
- 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";
1731
2149
  const isSoft = sprite.kind === "circle" && sprite.soft;
1732
2150
  const wrap2 = built.layer.wrap !== false;
1733
2151
  const head = this.entityPos(e, t, parentE);
@@ -1806,7 +2224,90 @@ var SpecInstance = class {
1806
2224
  ctx.translate(p.x, p.y);
1807
2225
  if (rot) ctx.rotate(rot);
1808
2226
  ctx.fillStyle = resolvedColor;
1809
- 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
+ }
1810
2311
  ctx.restore();
1811
2312
  return;
1812
2313
  }
@@ -1853,11 +2354,39 @@ var SpecInstance = class {
1853
2354
  ctx.textBaseline = "top";
1854
2355
  ctx.textAlign = align;
1855
2356
  const xOff = align === "center" ? maxWPx / 2 : align === "right" ? maxWPx : 0;
1856
- for (let li = 0; li < fullLines; li++) {
1857
- ctx.fillText(lines[li].text, xOff, li * lh);
1858
- }
1859
- if (rs && rs.partialText.length > 0) {
1860
- ctx.fillText(rs.partialText, xOff, fullLines * lh);
2357
+ if (rs?.glyphAlphas) {
2358
+ ctx.textAlign = "left";
2359
+ const baseAlpha = ctx.globalAlpha;
2360
+ for (let li = 0; li < lines.length; li++) {
2361
+ const alphas = rs.glyphAlphas[li] ?? [];
2362
+ if (alphas.length > 0 && alphas[0] <= 0) break;
2363
+ const clusters = graphemeClusters(lines[li].text);
2364
+ const lw = ctx.measureText(lines[li].text).width;
2365
+ const x0 = align === "center" ? (maxWPx - lw) / 2 : align === "right" ? maxWPx - lw : 0;
2366
+ let opaque = 0;
2367
+ while (opaque < clusters.length && (alphas[opaque] ?? 0) >= 1) opaque++;
2368
+ let prefix = "";
2369
+ if (opaque > 0) {
2370
+ prefix = clusters.slice(0, opaque).join("");
2371
+ ctx.globalAlpha = baseAlpha;
2372
+ ctx.fillText(prefix, x0, li * lh);
2373
+ }
2374
+ for (let gi = opaque; gi < clusters.length; gi++) {
2375
+ const a = alphas[gi] ?? 0;
2376
+ if (a <= 0) break;
2377
+ ctx.globalAlpha = baseAlpha * a;
2378
+ ctx.fillText(clusters[gi], x0 + ctx.measureText(prefix).width, li * lh);
2379
+ prefix += clusters[gi];
2380
+ }
2381
+ }
2382
+ ctx.globalAlpha = baseAlpha;
2383
+ } else {
2384
+ for (let li = 0; li < fullLines; li++) {
2385
+ ctx.fillText(lines[li].text, xOff, li * lh);
2386
+ }
2387
+ if (rs && rs.partialText.length > 0) {
2388
+ ctx.fillText(rs.partialText, xOff, fullLines * lh);
2389
+ }
1861
2390
  }
1862
2391
  if (rs && reveal.caret) {
1863
2392
  const cfg = reveal.caret === true ? {} : reveal.caret;
@@ -1953,7 +2482,7 @@ var SpecInstance = class {
1953
2482
  let resolvedColor = links.color;
1954
2483
  if (!resolvedColor) {
1955
2484
  const sprite = built.layer.sprite;
1956
- if (sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "streak" || sprite.kind === "rect") {
2485
+ if (isShapedSprite(sprite)) {
1957
2486
  resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
1958
2487
  } else resolvedColor = "#e6e8ef";
1959
2488
  }
@@ -2091,6 +2620,18 @@ var SequenceInstance = class {
2091
2620
  startT = 0;
2092
2621
  baseT = 0;
2093
2622
  lastT = 0;
2623
+ /** The T most recently passed to renderFrame — the clock a steer displaces. */
2624
+ renderedT = 0;
2625
+ /**
2626
+ * The clicker's two pieces of state. `clockOffset` is added to every T so
2627
+ * a `sequence.segment` steer lands the timeline at the target segment's
2628
+ * start and STAYS there as the wall clock keeps ticking — the steer moves
2629
+ * the clock instead of fighting it. `releasedBelow` records that the steer
2630
+ * counts as the presenter clicking past every `advance: 'input'` hold
2631
+ * before the target (see `resolveSegment`).
2632
+ */
2633
+ clockOffset = 0;
2634
+ releasedBelow = 0;
2094
2635
  constructor(seq, ctx) {
2095
2636
  this.seq = seq;
2096
2637
  let surface = ctx.surface ?? null;
@@ -2173,7 +2714,8 @@ var SequenceInstance = class {
2173
2714
  this.renderFrame(this.lastT, this.seed);
2174
2715
  }
2175
2716
  renderFrame(T, seed) {
2176
- const resolved = resolveSegment(this.seq, T);
2717
+ this.renderedT = T;
2718
+ const resolved = resolveSegment(this.seq, T + this.clockOffset, { releasedBelow: this.releasedBelow });
2177
2719
  const { index, localT } = resolved;
2178
2720
  const prevIdx = index > 0 ? index - 1 : -1;
2179
2721
  const morphActive = prevIdx >= 0 && this.canMorph(prevIdx) && localT < this.morphDur(prevIdx);
@@ -2242,14 +2784,9 @@ var SequenceInstance = class {
2242
2784
  const segDelta = deltas.find((d) => d.path === "sequence.segment");
2243
2785
  if (segDelta !== void 0 && typeof segDelta.value === "number") {
2244
2786
  const idx = Math.max(0, Math.min(this.seq.segments.length - 1, Math.round(segDelta.value)));
2245
- if (idx !== this.activeIndex) {
2246
- for (let i = 0; i < this.children.length; i++) {
2247
- if (i !== idx) this.releaseChild(i);
2248
- }
2249
- this.activeIndex = idx;
2250
- const child = this.ensureChild(idx);
2251
- child.renderFrame(0, this.seq.segments[idx].scene.seed ?? this.seq.seed ?? 0);
2252
- }
2787
+ this.clockOffset = segmentStart(this.seq, idx) - this.renderedT;
2788
+ this.releasedBelow = idx;
2789
+ this.renderFrame(this.renderedT, this.seed);
2253
2790
  }
2254
2791
  const childDeltas = deltas.filter((d) => d.path !== "sequence.segment");
2255
2792
  if (childDeltas.length > 0 && this.activeIndex >= 0) {
@@ -2308,7 +2845,7 @@ function hexLuma(hex) {
2308
2845
  }
2309
2846
  function spriteLuma(layer, e) {
2310
2847
  const s = layer.sprite;
2311
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
2848
+ if (isShapedSprite(s)) {
2312
2849
  return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
2313
2850
  }
2314
2851
  if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
@@ -2316,7 +2853,7 @@ function spriteLuma(layer, e) {
2316
2853
  }
2317
2854
  function spriteHex(layer, e) {
2318
2855
  const s = layer.sprite;
2319
- if (s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect") {
2856
+ if (isShapedSprite(s)) {
2320
2857
  return s.colors?.[e.colorIndex] ?? s.color;
2321
2858
  }
2322
2859
  if (s.kind === "text" || s.kind === "textBlock") return s.color ?? "#e6e8ef";
@@ -2354,19 +2891,237 @@ function colourSeparation(layer, sprite, bg) {
2354
2891
  return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
2355
2892
  }
2356
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
+
2357
3101
  // src/advise.ts
2358
3102
  var LOW_CONTRAST_FLOOR = 0.05;
2359
- 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 = {}) {
2360
3105
  const warnings = [];
2361
3106
  const w = viewport.width;
2362
3107
  const h = viewport.height;
2363
3108
  const scale = spec.units === "px" ? 1 : Math.min(w, h);
2364
3109
  const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
2365
- const countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
2366
- 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);
2367
3116
  const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
2368
3117
  const bgLuma = backgroundLuma(spec);
2369
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
+ );
2370
3125
  let totalEntities = 0;
2371
3126
  let textLayerCount = 0;
2372
3127
  let motionLayerCount = 0;
@@ -2378,6 +3133,14 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2378
3133
  const isStaticText = isText && layer.motion.type === "static";
2379
3134
  if (isStaticText) textLayerCount++;
2380
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
+ }
2381
3144
  if (layer.trail && layer.motion.type === "static") {
2382
3145
  warnings.push({
2383
3146
  path: `layers[${li}].trail`,
@@ -2385,11 +3148,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2385
3148
  message: "trail has no effect on static entities \u2014 they have no past positions to draw"
2386
3149
  });
2387
3150
  }
2388
- 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") {
2389
3152
  warnings.push({
2390
3153
  path: `layers[${li}].sprite`,
2391
3154
  code: "streak-on-static",
2392
- 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"}`
2393
3156
  });
2394
3157
  }
2395
3158
  if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
@@ -2436,6 +3199,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2436
3199
  let pixArea;
2437
3200
  if (layer.sprite.kind === "circle") {
2438
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);
2439
3204
  } else if (layer.sprite.kind === "textBlock") {
2440
3205
  const fsPx = layer.sprite.fontSize * scale;
2441
3206
  const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
@@ -2445,7 +3210,13 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2445
3210
  } else {
2446
3211
  pixArea = e.size * e.size;
2447
3212
  }
2448
- 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);
2449
3220
  }
2450
3221
  if (layer.links) {
2451
3222
  const positions = entities.map((e) => positionAt(e, 0, w, h));
@@ -2456,20 +3227,35 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2456
3227
  for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
2457
3228
  }
2458
3229
  }
2459
- 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") {
2460
3233
  warnings.push({
2461
3234
  path: "layers",
2462
3235
  code: "sparse-scene",
2463
3236
  message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
2464
3237
  });
2465
3238
  }
2466
- if (totalEntities > 500) {
3239
+ if (totalEntities > 500 && density !== "dense") {
2467
3240
  warnings.push({
2468
3241
  path: "layers",
2469
3242
  code: "dense-scene",
2470
3243
  message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
2471
3244
  });
2472
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
+ }
2473
3259
  if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
2474
3260
  const textRatio = textLayerCount / spec.layers.length;
2475
3261
  if (textRatio >= 0.8) {
@@ -2529,8 +3315,88 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
2529
3315
  });
2530
3316
  }
2531
3317
  }
3318
+ const textBoxes = [];
3319
+ for (let li = 0; li < spec.layers.length; li++) {
3320
+ const layer = spec.layers[li];
3321
+ const s = layer.sprite;
3322
+ if (layer.motion.type !== "static") continue;
3323
+ if (s.kind !== "text" && s.kind !== "textBlock") continue;
3324
+ const label = layer.key ? `\`${layer.key}\`` : `layers[${li}]`;
3325
+ for (const e of allEntities[li]) {
3326
+ const p = positionAt(e, 0, w, h);
3327
+ const box = s.kind === "textBlock" ? textBlockBoxAt(s, p, w, h) : textBoxAt(s, e, p, spec, w, h);
3328
+ textBoxes.push({ li, label, ...box });
3329
+ }
3330
+ }
3331
+ const tolX = w * 0.01;
3332
+ const tolY = h * 0.01;
3333
+ const offFlagged = /* @__PURE__ */ new Set();
3334
+ for (const b of textBoxes) {
3335
+ if (offFlagged.has(b.li)) continue;
3336
+ const edges = [];
3337
+ if (b.x0 < -tolX) edges.push(`left edge by ~${Math.round(-b.x0)}px`);
3338
+ if (b.x1 > w + tolX) edges.push(`right edge by ~${Math.round(b.x1 - w)}px`);
3339
+ if (b.y0 < -tolY) edges.push(`top edge by ~${Math.round(-b.y0)}px`);
3340
+ if (b.y1 > h + tolY) edges.push(`bottom edge by ~${Math.round(b.y1 - h)}px`);
3341
+ if (edges.length === 0) continue;
3342
+ offFlagged.add(b.li);
3343
+ warnings.push({
3344
+ path: `layers[${b.li}]`,
3345
+ code: "text-off-screen",
3346
+ message: `${b.label} text runs off the ${edges.join(" and the ")} at ${w}\xD7${h} \u2014 move position, or shrink fontSize / maxWidth`
3347
+ });
3348
+ }
3349
+ const pairFlagged = /* @__PURE__ */ new Set();
3350
+ for (let i = 0; i < textBoxes.length; i++) {
3351
+ for (let j = i + 1; j < textBoxes.length; j++) {
3352
+ const a = textBoxes[i];
3353
+ const b = textBoxes[j];
3354
+ if (a.li === b.li) continue;
3355
+ const pair = `${Math.min(a.li, b.li)}:${Math.max(a.li, b.li)}`;
3356
+ if (pairFlagged.has(pair)) continue;
3357
+ const ix = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
3358
+ const iy = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
3359
+ if (ix <= 0 || iy <= 0) continue;
3360
+ const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0));
3361
+ if (smaller <= 0) continue;
3362
+ const share = ix * iy / smaller;
3363
+ if (share <= 0.1) continue;
3364
+ pairFlagged.add(pair);
3365
+ const [first, second] = a.li < b.li ? [a, b] : [b, a];
3366
+ warnings.push({
3367
+ path: `layers[${first.li}]`,
3368
+ code: "text-overlap",
3369
+ message: `${first.label} and ${second.label} text boxes overlap (~${Math.round(share * 100)}% of the smaller one) \u2014 they will paint over each other; move one or shrink it`
3370
+ });
3371
+ }
3372
+ }
2532
3373
  return warnings;
2533
3374
  }
3375
+ function textBoxAt(s, e, p, spec, w, h) {
3376
+ const m = s.font ? /(\d{1,5}(?:\.\d{1,4})?|\.\d{1,4})px/.exec(s.font) : null;
3377
+ const fontScale = spec.units === "px" ? 1 : Math.min(w, h) / (spec.referenceViewport ?? LIMITS.referenceViewport);
3378
+ const fh = m ? Number(m[1]) * fontScale : e.size;
3379
+ const str = s.strings[e.spriteIndex] ?? s.strings[0] ?? "";
3380
+ let fw = textWidthEm(str) * fh;
3381
+ if (s.maxWidth) fw = Math.min(fw, s.maxWidth * (spec.units === "px" ? 1 : Math.min(w, h)));
3382
+ const align = s.align ?? "center";
3383
+ const baseline = s.baseline ?? "middle";
3384
+ const x0 = align === "left" ? p.x : align === "right" ? p.x - fw : p.x - fw / 2;
3385
+ const y0 = baseline === "top" ? p.y : baseline === "bottom" ? p.y - fh : p.y - fh / 2;
3386
+ return { x0, y0, x1: x0 + fw, y1: y0 + fh };
3387
+ }
3388
+ function textBlockBoxAt(s, p, w, h) {
3389
+ const unit = Math.min(w, h);
3390
+ const fsPx = s.fontSize * unit;
3391
+ const lh = (s.lineHeight ?? 1.4) * fsPx;
3392
+ const maxWPx = s.maxWidth * unit;
3393
+ const lines = breakTextBlock(s.text, maxWPx / fsPx);
3394
+ const maxLineW = lines.reduce((mx, l) => Math.max(mx, l.widthEm), 0) * fsPx;
3395
+ const totalH = lines.length * lh;
3396
+ const align = s.align ?? "left";
3397
+ const x0 = align === "center" ? p.x + (maxWPx - maxLineW) / 2 : align === "right" ? p.x + maxWPx - maxLineW : p.x;
3398
+ return { x0, y0: p.y, x1: x0 + maxLineW, y1: p.y + totalH };
3399
+ }
2534
3400
  function adviseSequence(seq, viewport = { width: 1920, height: 1080 }) {
2535
3401
  const warnings = [];
2536
3402
  for (let i = 0; i < seq.segments.length; i++) {
@@ -2729,7 +3595,17 @@ function textBlockRevealFraction(s, w, h, t) {
2729
3595
  const unitScale = Math.min(w, h);
2730
3596
  const maxWEm = s.maxWidth * unitScale / (s.fontSize * unitScale);
2731
3597
  const lines = breakTextBlock(s.text, maxWEm);
2732
- return revealState(lines, s.reveal, t).progress;
3598
+ const rs = revealState(lines, s.reveal, t);
3599
+ if (rs.glyphAlphas) {
3600
+ let sum = 0;
3601
+ let n = 0;
3602
+ for (const line of rs.glyphAlphas) for (const a of line) {
3603
+ sum += a;
3604
+ n++;
3605
+ }
3606
+ return n > 0 ? sum / n : 1;
3607
+ }
3608
+ return rs.progress;
2733
3609
  }
2734
3610
  function luminanceGrid(spec, opts = {}) {
2735
3611
  const scene = buildScene(spec, opts);
@@ -2827,6 +3703,36 @@ function luminanceGrid(spec, opts = {}) {
2827
3703
  const p = posOf(scene, e, tPass);
2828
3704
  const sz = sizeAt(e, tPass);
2829
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
+ }
2830
3736
  if (s.kind === "streak") {
2831
3737
  const heading = headingAt(e, tPass, w, h) ?? 0;
2832
3738
  const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
@@ -2858,9 +3764,22 @@ function luminanceGrid(spec, opts = {}) {
2858
3764
  centerY = box.cy;
2859
3765
  halfX = box.halfX;
2860
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);
2861
3779
  }
2862
- const circular = s.kind === "circle" || s.kind === "ring";
2863
- 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;
2864
3783
  const additive = layer.blend === "lighter" || layer.blend === "screen";
2865
3784
  const glow = soft && additive;
2866
3785
  const reachX = glow ? halfX * GLOW_SPREAD : halfX;
@@ -2893,9 +3812,9 @@ function luminanceGrid(spec, opts = {}) {
2893
3812
  } else {
2894
3813
  wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
2895
3814
  }
2896
- compose(r * cols + c, lum, a * wgt, layer.blend);
3815
+ compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
2897
3816
  } else {
2898
- compose(r * cols + c, lum, a * inkWeight, layer.blend);
3817
+ compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
2899
3818
  }
2900
3819
  }
2901
3820
  }
@@ -3083,7 +4002,15 @@ function dominanceRanking(spec, opts = {}) {
3083
4002
  else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
3084
4003
  else if (s.kind === "rect") {
3085
4004
  const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
3086
- 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;
3087
4014
  } else if (s.kind === "text") {
3088
4015
  const box = textBox(s, e, { x: 0, y: 0 });
3089
4016
  entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
@@ -3128,6 +4055,10 @@ function dominanceRanking(spec, opts = {}) {
3128
4055
  factors: r.factors
3129
4056
  }));
3130
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
+ }
3131
4062
  function motionStats(spec, opts = {}) {
3132
4063
  const scene = buildScene(spec, opts);
3133
4064
  const t = opts.t ?? 5e3;
@@ -3224,8 +4155,9 @@ function perceiveScene(spec, opts = {}) {
3224
4155
  colProfile: grid.colProfile,
3225
4156
  dominance: dominanceRanking(spec, opts),
3226
4157
  motion: motionStats(spec, opts),
4158
+ form: layerCohesion(spec, opts),
3227
4159
  text: textSprites(spec, opts),
3228
- 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 })
3229
4161
  };
3230
4162
  }
3231
4163
 
@@ -3941,6 +4873,77 @@ var DASHBOARD_SPEC = {
3941
4873
  ]
3942
4874
  };
3943
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
+
3944
4947
  // src/examples/haiku.ts
3945
4948
  var HAIKU_SPEC = {
3946
4949
  schemaVersion: 1,
@@ -4333,6 +5336,40 @@ var ORRERY_SPEC = {
4333
5336
  ]
4334
5337
  };
4335
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
+
4336
5373
  // src/examples/procession.ts
4337
5374
  var PROCESSION_SPEC = {
4338
5375
  schemaVersion: 1,
@@ -4429,6 +5466,74 @@ var RAIN_SPEC = {
4429
5466
  ]
4430
5467
  };
4431
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
+
4432
5537
  // src/examples/sakura.ts
4433
5538
  var SAKURA_SPEC = {
4434
5539
  schemaVersion: 1,
@@ -4665,7 +5770,10 @@ var SCHEMA_EXAMPLES = [
4665
5770
  { id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
4666
5771
  { id: "procession", label: "Night Procession", spec: PROCESSION_SPEC },
4667
5772
  { id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC },
4668
- { 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 }
4669
5777
  ];
4670
5778
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
4671
5779
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -4674,20 +5782,24 @@ var EXAMPLE_BY_ID = Object.fromEntries(
4674
5782
  export {
4675
5783
  AQUARIUM_SPEC,
4676
5784
  AURORA_SPEC,
5785
+ COHESION_T,
4677
5786
  COMETS_SPEC,
4678
5787
  CONSTELLATION_SPEC,
4679
5788
  DASHBOARD_SPEC,
4680
5789
  EXAMPLE_BY_ID,
4681
5790
  EXAMPLE_SPECS,
5791
+ FACETS_SPEC,
4682
5792
  HAIKU_SPEC,
4683
5793
  LANTERNS_SPEC,
4684
5794
  LIMITS,
4685
5795
  MATRIX_RAIN_SPEC,
4686
5796
  NOSTALGHIA_CANDLE_SPEC,
4687
5797
  ORRERY_SPEC,
5798
+ PINGS_SPEC,
4688
5799
  POLYGONS_SPEC,
4689
5800
  PROCESSION_SPEC,
4690
5801
  RAIN_SPEC,
5802
+ RELAY_BOARD_SPEC,
4691
5803
  SAKURA_SPEC,
4692
5804
  SCHEMA_EXAMPLES,
4693
5805
  SCHEMA_VERSION,
@@ -4702,14 +5814,18 @@ export {
4702
5814
  assertValidSpec,
4703
5815
  breakTextBlock,
4704
5816
  buildEntities,
5817
+ cohesionOf,
4705
5818
  compileSaver,
4706
5819
  compileSequence,
4707
5820
  describeScene,
4708
5821
  diffScenes,
4709
5822
  dominanceRanking,
4710
5823
  easeSmooth,
5824
+ emitEnvelope,
5825
+ emitWindow,
4711
5826
  graphemeClusters,
4712
5827
  headingAt,
5828
+ layerCohesion,
4713
5829
  lerpSpec,
4714
5830
  lifeAlphaAt,
4715
5831
  linkEdges,
@@ -4725,12 +5841,16 @@ export {
4725
5841
  resolveSpecPath,
4726
5842
  revealState,
4727
5843
  rotationAt,
5844
+ seamCauseOf,
5845
+ seamsWorthWarning,
5846
+ segmentStart,
4728
5847
  sizeAt,
4729
5848
  spriteIndexAt,
4730
5849
  spriteVariants,
4731
5850
  steerablePaths,
4732
5851
  structuralSignature,
4733
5852
  textSprites,
5853
+ textWidthEm,
4734
5854
  validateSequence,
4735
5855
  validateSpec
4736
5856
  };