coily 0.13.1 → 0.14.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.
@@ -1,4 +1,10 @@
1
1
  //#region src/flush-queue.ts
2
+ /**
3
+ * Defers callbacks while inside a batch or tick: `request` runs the
4
+ * callback immediately at depth 0 and queues it — deduplicated — until
5
+ * the outermost exit otherwise. Composite springs use it to coalesce
6
+ * per-channel events into composite ones.
7
+ */
2
8
  var FlushQueue = class {
3
9
  #callbacks = /* @__PURE__ */ new Set();
4
10
  #depth = 0;
@@ -27,6 +33,11 @@ var FlushQueue = class {
27
33
  };
28
34
  //#endregion
29
35
  //#region src/motion-set.ts
36
+ /**
37
+ * The set of motions currently moving. Motions leave the set as they
38
+ * rest and re-enter on any write that disturbs them; `onWake` tells the
39
+ * ticker the set became non-empty so it can stop sleeping.
40
+ */
30
41
  var MotionSet = class {
31
42
  reduced = false;
32
43
  onWake = null;
@@ -52,6 +63,7 @@ var MotionSet = class {
52
63
  finishAll() {
53
64
  this.flushes.batch(() => {
54
65
  for (const motion of this.#motions) {
66
+ if (!motion.respectsReducedMotion) continue;
55
67
  motion.finish();
56
68
  this.#motions.delete(motion);
57
69
  }
@@ -83,6 +95,11 @@ function invariant(condition, message) {
83
95
  const resolved = typeof message === "function" ? message() : message;
84
96
  throw new Error(resolved ?? "Invariant Failed");
85
97
  }
98
+ /**
99
+ * True for plain objects only. Class instances are excluded so shape
100
+ * walks reject values like a `Spring` instead of descending into their
101
+ * fields as if they were nested shapes.
102
+ */
86
103
  function isRecord(value) {
87
104
  if (typeof value !== "object" || value === null) return false;
88
105
  const proto = Object.getPrototypeOf(value);
@@ -97,23 +114,39 @@ function isArray(value) {
97
114
  function isNumber(value) {
98
115
  return typeof value === "number";
99
116
  }
117
+ const RESOLVED = Promise.resolve();
100
118
  //#endregion
101
119
  //#region src/config.ts
102
120
  /**
103
- * Immutable spring configuration. Instances are frozen — to change a spring's
104
- * behavior, assign a new config via `spring.config`.
121
+ * Immutable, resolved spring parameters. Whatever `defineSpring` input
122
+ * shape produced it, a config carries concrete `mass`, `tension`,
123
+ * `damping`, and `precision`, plus the quantities derived from them.
124
+ * Instances are frozen and safe to share between springs; to change a
125
+ * spring's behavior, assign a new config via `spring.config`.
105
126
  */
106
- var SpringConfig = class SpringConfig {
107
- static default = new SpringConfig({
127
+ var SpringDefinition = class SpringDefinition {
128
+ /** The config for springs created without one: critically damped, settling in about 500ms. */
129
+ static default = new SpringDefinition({
108
130
  dampingRatio: 1,
109
131
  duration: 500
110
132
  });
133
+ /** Mass of the moving value, as provided or derived. */
111
134
  mass;
135
+ /** Spring stiffness, as provided or derived. */
112
136
  tension;
137
+ /** Friction strength, as provided or derived. */
113
138
  damping;
139
+ /** Decimal places of the resting threshold — see `restingMagnitude`. */
114
140
  precision;
141
+ /** Oscillation rate with no damping: `sqrt(tension / mass)`, in radians per second. */
115
142
  naturalFrequency;
143
+ /** The damping at which bounce disappears: `2 * mass * naturalFrequency`. */
116
144
  criticalDamping;
145
+ /**
146
+ * `damping / criticalDamping`. Below 1 the spring overshoots and
147
+ * bounces, at 1 it settles fastest without overshooting, above 1 it
148
+ * settles slower.
149
+ */
117
150
  dampingRatio;
118
151
  /** Resting threshold in value units: half a unit in the last `precision` place. */
119
152
  restingMagnitude;
@@ -126,6 +159,10 @@ var SpringConfig = class SpringConfig {
126
159
  const duration = raw.duration !== void 0 ? raw.duration / 1e3 : void 0;
127
160
  const displacement = raw.displacement ?? 1;
128
161
  const precision = raw.precision ?? 2;
162
+ for (const key of Object.keys(raw)) {
163
+ const value = raw[key];
164
+ if (value !== void 0) invariant(Number.isFinite(value), () => `Invalid ${key}: expected a finite number`);
165
+ }
129
166
  if (mass !== void 0) invariant(mass > 0, "Mass must be greater than 0");
130
167
  if (tension !== void 0) invariant(tension > 0, "Tension must be greater than 0");
131
168
  if (damping !== void 0) invariant(damping >= 0, "Damping must be greater than or equal to 0");
@@ -194,23 +231,13 @@ var SpringConfig = class SpringConfig {
194
231
  Object.freeze(this);
195
232
  }
196
233
  /**
197
- * Analytically estimates the time remaining for a spring to come to rest,
198
- * given the current displacement and velocity.
199
- *
200
- * Uses the exponential decay envelope common to all three damping regimes.
201
- * The decay rate depends on the regime:
202
- * - Underdamped (ζ < 1): σ = ζωₙ
203
- * - Critically damped (ζ = 1): σ = ωₙ
204
- * - Overdamped (ζ > 1): σ = (ζ - √(ζ²-1))ωₙ (the slower eigenvalue)
234
+ * Estimates how long a spring with this config takes to come to rest
235
+ * from `state`, in milliseconds.
205
236
  *
206
- * The effective initial amplitude accounts for both displacement and velocity,
207
- * since kinetic energy can convert to additional displacement:
208
- * A₀ = |x₀| + |v₀| / ωₙ
209
- *
210
- * Settling time: t = ln(A₀ / threshold) / σ, with a 2× safety factor
211
- * to account for polynomial terms in the critically damped solution.
212
- *
213
- * Returns the estimated time in milliseconds.
237
+ * The estimate follows the motion's decay envelope with a 2x safety
238
+ * margin, so actual rest usually lands earlier. Returns 0 when `state`
239
+ * is already inside the resting threshold, and Infinity when the config
240
+ * never settles (a `dampingRatio` of 0).
214
241
  */
215
242
  computeTimeRemaining(state) {
216
243
  const { position, velocity } = state;
@@ -225,10 +252,11 @@ var SpringConfig = class SpringConfig {
225
252
  }
226
253
  };
227
254
  function defineSpring(input) {
228
- return new SpringConfig(input);
255
+ return new SpringDefinition(input);
229
256
  }
230
257
  //#endregion
231
258
  //#region src/emitter.ts
259
+ /** Minimal fixed-event emitter behind motions and composite springs. */
232
260
  var Emitter = class {
233
261
  #update = [];
234
262
  #start = [];
@@ -267,6 +295,10 @@ var Emitter = class {
267
295
  };
268
296
  //#endregion
269
297
  //#region src/state.ts
298
+ /**
299
+ * The mutable position/velocity pair a motion and its solvers share, in
300
+ * displacement space: position 0 means at the target.
301
+ */
270
302
  var State = class {
271
303
  #config;
272
304
  #position;
@@ -291,12 +323,18 @@ var State = class {
291
323
  set velocity(value) {
292
324
  this.#velocity = value;
293
325
  }
326
+ /**
327
+ * The rest test: displacement, plus the displacement the current
328
+ * velocity could still convert into (|x| + |v| / wn), is inside the
329
+ * config's resting threshold.
330
+ */
294
331
  get isResting() {
295
332
  return Math.abs(this.#position) + Math.abs(this.#velocity) / this.#config.naturalFrequency <= this.#config.restingMagnitude;
296
333
  }
297
334
  };
298
335
  //#endregion
299
336
  //#region src/solver.ts
337
+ /** dampingRatio < 1: decaying oscillation, x(t) = exp(-sigma*t) * (c1*cos(wd*t) + c2*sin(wd*t)). */
300
338
  var UnderdampedSolver = class {
301
339
  #state;
302
340
  #dampedFrequency = 0;
@@ -328,6 +366,7 @@ var UnderdampedSolver = class {
328
366
  this.#state.velocity = decay * oscillationVelocity + decayVelocity * oscillation;
329
367
  }
330
368
  };
369
+ /** dampingRatio = 1: fastest non-oscillating decay, x(t) = (c1 + c2*t) * exp(-wn*t). */
331
370
  var CriticallyDampedSolver = class {
332
371
  #state;
333
372
  #naturalFrequency = 0;
@@ -353,6 +392,7 @@ var CriticallyDampedSolver = class {
353
392
  this.#state.velocity = scaleVelocity * decay + decayVelocity * scale;
354
393
  }
355
394
  };
395
+ /** dampingRatio > 1: slow non-oscillating decay, x(t) = exp(-sigma*t) * (c1*sinh(wd*t) + c2*cosh(wd*t)) / wd. */
356
396
  var OverdampedSolver = class {
357
397
  #state;
358
398
  #dampedFrequency = 0;
@@ -387,8 +427,16 @@ var OverdampedSolver = class {
387
427
  };
388
428
  //#endregion
389
429
  //#region src/motion.ts
430
+ /**
431
+ * One spring simulation in displacement space: position is measured from
432
+ * the target (0 means settled), so retargeting is a rebase performed by
433
+ * the owning `Spring`. Picks the closed-form solver for the config's
434
+ * damping regime and emits update/start/stop/dispose events.
435
+ */
390
436
  var Motion = class {
437
+ /** Tick-pass marker written by `MotionSet` so a motion re-added mid-pass isn't ticked twice. */
391
438
  _pass = 0;
439
+ respectsReducedMotion = true;
392
440
  #config;
393
441
  #state;
394
442
  #underdampedSolver = null;
@@ -513,223 +561,7 @@ var Motion = class {
513
561
  }
514
562
  };
515
563
  //#endregion
516
- //#region src/spring.ts
517
- function normalizeTarget(target) {
518
- if (target instanceof Spring) return {
519
- spring: target,
520
- offset: 0
521
- };
522
- if (typeof target === "object" && target !== null && "spring" in target && target.spring instanceof Spring) return {
523
- spring: target.spring,
524
- offset: target.offset ?? 0
525
- };
526
- return null;
527
- }
528
- const RESOLVED$1 = Promise.resolve();
529
- var Spring = class {
530
- #target;
531
- #override;
532
- #resolved;
533
- #motion;
534
- #motions;
535
- #followers = /* @__PURE__ */ new Set();
536
- #leader = null;
537
- #offset = 0;
538
- #unsubLeader = null;
539
- #settled = null;
540
- #resolveSettled = null;
541
- #disposed = false;
542
- constructor(motions, position, config) {
543
- this.#motions = motions;
544
- let numericTarget;
545
- let value;
546
- let normalized = null;
547
- if (typeof position === "number") {
548
- numericTarget = position;
549
- value = position;
550
- } else {
551
- normalized = normalizeTarget(position.target);
552
- if (normalized) {
553
- numericTarget = normalized.spring.value + normalized.offset;
554
- value = position.value ?? numericTarget;
555
- } else {
556
- numericTarget = position.target ?? position.value ?? 0;
557
- value = position.value ?? numericTarget;
558
- }
559
- }
560
- if (motions.reduced) value = numericTarget;
561
- this.#override = config ?? null;
562
- this.#resolved = config ?? (normalized ? normalized.spring.#resolved : SpringConfig.default);
563
- this.#target = numericTarget;
564
- this.#motion = new Motion(this.#resolved, value - numericTarget, 0);
565
- if (!this.#motion.isResting) this.#motions.add(this.#motion);
566
- if (normalized) {
567
- this.#leader = normalized.spring;
568
- this.#offset = normalized.offset;
569
- normalized.spring.#followers.add(this);
570
- this.#unsubLeader = normalized.spring.onUpdate(() => {
571
- this.#setTarget(this.#leader.value + this.#offset);
572
- });
573
- }
574
- }
575
- get target() {
576
- return this.#target;
577
- }
578
- set target(value) {
579
- if (typeof value === "number") {
580
- this.#unfollow();
581
- this.#setTarget(value);
582
- } else {
583
- const normalized = normalizeTarget(value);
584
- this.#follow(normalized.spring, normalized.offset);
585
- }
586
- }
587
- get value() {
588
- return this.#target + this.#motion.position;
589
- }
590
- set value(value) {
591
- if (this.#motions.reduced) {
592
- if (value !== this.value) this.jumpTo(value);
593
- return;
594
- }
595
- const position = value - this.#target;
596
- if (position !== this.#motion.position) {
597
- this.#motions.add(this.#motion);
598
- this.#motion.position = position;
599
- this.#motion.tick(0);
600
- }
601
- }
602
- get velocity() {
603
- return this.#motion.velocity;
604
- }
605
- set velocity(value) {
606
- if (this.#motions.reduced) return;
607
- this.#motions.add(this.#motion);
608
- this.#motion.velocity = value;
609
- }
610
- get config() {
611
- return this.#resolved;
612
- }
613
- get mass() {
614
- return this.#resolved.mass;
615
- }
616
- get tension() {
617
- return this.#resolved.tension;
618
- }
619
- get damping() {
620
- return this.#resolved.damping;
621
- }
622
- get dampingRatio() {
623
- return this.#resolved.dampingRatio;
624
- }
625
- get precision() {
626
- return this.#resolved.precision;
627
- }
628
- set config(value) {
629
- this.#override = value;
630
- this.#applyConfig(value ?? (this.#leader ? this.#leader.#resolved : SpringConfig.default));
631
- }
632
- get timeRemaining() {
633
- return this.#motion.timeRemaining;
634
- }
635
- get isResting() {
636
- return this.#motion.isResting;
637
- }
638
- get settled() {
639
- if (this.#disposed || this.#motion.isResting) return RESOLVED$1;
640
- this.#settled ??= new Promise((resolve) => {
641
- this.#resolveSettled = resolve;
642
- const unsubscribe = this.onStop(() => {
643
- unsubscribe();
644
- this.#settled = null;
645
- this.#resolveSettled = null;
646
- resolve();
647
- });
648
- });
649
- return this.#settled;
650
- }
651
- jumpTo(value) {
652
- this.#target = value;
653
- this.#motion.finish();
654
- }
655
- dispose() {
656
- if (this.#disposed) return;
657
- this.#disposed = true;
658
- if (this.#resolveSettled) {
659
- this.#resolveSettled();
660
- this.#settled = null;
661
- this.#resolveSettled = null;
662
- }
663
- if (this.#unsubLeader) {
664
- this.#unsubLeader();
665
- this.#unsubLeader = null;
666
- }
667
- if (this.#leader) {
668
- this.#leader.#followers.delete(this);
669
- this.#leader = null;
670
- }
671
- for (const follower of this.#followers) follower.#unfollow();
672
- this.#motions.remove(this.#motion);
673
- this.#motion.dispose();
674
- }
675
- onUpdate(callback) {
676
- return this.#motion.onUpdate(callback);
677
- }
678
- onStart(callback) {
679
- return this.#motion.onStart(callback);
680
- }
681
- onStop(callback) {
682
- return this.#motion.onStop(callback);
683
- }
684
- onDispose(callback) {
685
- return this.#motion.onDispose(callback);
686
- }
687
- #setTarget(value) {
688
- if (value !== this.#target) {
689
- if (this.#motions.reduced) {
690
- this.jumpTo(value);
691
- return;
692
- }
693
- this.#motions.add(this.#motion);
694
- const current = this.value;
695
- this.#target = value;
696
- this.#motion.position = current - this.#target;
697
- this.#motion.tick(0, false);
698
- }
699
- }
700
- #follow(leader, offset) {
701
- if (this.#unsubLeader) {
702
- this.#unsubLeader();
703
- this.#leader.#followers.delete(this);
704
- }
705
- this.#leader = leader;
706
- this.#offset = offset;
707
- leader.#followers.add(this);
708
- if (this.#override === null) this.#applyConfig(leader.#resolved);
709
- this.#unsubLeader = leader.onUpdate(() => {
710
- this.#setTarget(this.#leader.value + this.#offset);
711
- });
712
- this.#setTarget(leader.value + offset);
713
- }
714
- #unfollow() {
715
- if (!this.#leader) return;
716
- this.#unsubLeader();
717
- this.#unsubLeader = null;
718
- this.#leader.#followers.delete(this);
719
- this.#leader = null;
720
- this.#offset = 0;
721
- if (this.#override === null) this.#override = this.#resolved;
722
- }
723
- #applyConfig(next) {
724
- if (this.#resolved === next) return;
725
- this.#resolved = next;
726
- this.#motion.configure(next);
727
- if (!this.#motion.isResting) this.#motions.add(this.#motion);
728
- for (const follower of this.#followers) if (follower.#override === null) follower.#applyConfig(next);
729
- }
730
- };
731
- //#endregion
732
- //#region src/channel-tree-node.ts
564
+ //#region src/shape-tree.ts
733
565
  function joinPath(path, key) {
734
566
  return path ? `${path}.${key}` : String(key);
735
567
  }
@@ -738,48 +570,32 @@ function describePath(path) {
738
570
  }
739
571
  /** The sentinel a resolver returns to descend into a node instead of covering it. */
740
572
  const BRANCH = Symbol("branch");
573
+ /** The standard scatter leaf guard: partial numeric shapes take only finite numbers at channels. */
574
+ function acceptNumber(input, path) {
575
+ invariant(isNumber(input) && Number.isFinite(input), () => `Expected a finite number for channel '${path}'`);
576
+ return input;
577
+ }
741
578
  /**
742
- * One node of a shape's tree: a `LeafNode` wraps a single channel's leaf
743
- * object, `ListNode` and `RecordNode` carry the nesting. Every traversal is
744
- * an ordinary method each kind implements for itself, so call sites dispatch
745
- * without inspecting nodes; errors keep their dot paths because each node
746
- * stores the `path` it was built at.
579
+ * The canonical numeric `LeafParser`: finite-number leaves built through
580
+ * `factory`, named "channels" in structural errors. The default for value
581
+ * shapes, whose leaves are all numbers.
747
582
  */
748
- var ChannelTreeNode = class ChannelTreeNode {
749
- /** This node's dot path from the root (`position.x`), fixed at construction. */
750
- path;
751
- constructor(path) {
752
- this.path = path;
753
- }
754
- /** Parses a numeric shape into a node tree, appending each created leaf to `leaves`. */
755
- static build(shape, path, factory, leaves) {
756
- if (isArray(shape)) {
757
- invariant(shape.length > 0, () => emptyShape(path));
758
- return new ListNode(shape.map((child, i) => ChannelTreeNode.#child(child, joinPath(path, i), factory, leaves)), path);
759
- }
760
- const keys = Object.keys(shape);
761
- invariant(keys.length > 0, () => emptyShape(path));
762
- const children = /* @__PURE__ */ new Map();
763
- for (const key of keys) children.set(key, ChannelTreeNode.#child(shape[key], joinPath(path, key), factory, leaves));
764
- return new RecordNode(children, path);
765
- }
766
- static #child(value, path, factory, leaves) {
767
- if (isNumber(value)) {
768
- const leaf = factory(value, path);
769
- leaves.push(leaf);
770
- return new LeafNode(leaf, path);
771
- }
772
- invariant(isRecordOrArray(value), `Invalid value at '${path}': expected a number or a nested shape of numbers`);
773
- return ChannelTreeNode.build(value, path, factory, leaves);
774
- }
775
- };
776
- function emptyShape(path) {
777
- return `Invalid value at ${describePath(path)}: a shape must contain at least one channel`;
583
+ function channelParser(factory) {
584
+ return {
585
+ match(value, path) {
586
+ if (!isNumber(value)) return void 0;
587
+ invariant(Number.isFinite(value), () => `Invalid value at '${path}': channel values must be finite`);
588
+ return factory(value, path);
589
+ },
590
+ mismatch: (path) => `Invalid value at '${path}': expected a number or a nested shape of numbers`,
591
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one channel`
592
+ };
778
593
  }
779
- var LeafNode = class LeafNode extends ChannelTreeNode {
594
+ var LeafNode = class LeafNode {
595
+ path;
780
596
  leaf;
781
597
  constructor(leaf, path) {
782
- super(path);
598
+ this.path = path;
783
599
  this.leaf = leaf;
784
600
  }
785
601
  mirror(read, slots, carrier, key) {
@@ -790,18 +606,12 @@ var LeafNode = class LeafNode extends ChannelTreeNode {
790
606
  carrier
791
607
  });
792
608
  }
793
- scatter(input, apply) {
794
- invariant(isNumber(input), `Expected a number for channel '${this.path}'`);
795
- apply(this.leaf, input);
609
+ scatter(input, accept, apply) {
610
+ apply(this.leaf, accept(input, this.path));
796
611
  }
797
- zip(theirs, values, label, fn) {
612
+ zip(theirs, fn) {
798
613
  invariant(theirs instanceof LeafNode, () => `Shape mismatch at ${describePath(this.path)}`);
799
- let value;
800
- if (values !== void 0) {
801
- invariant(isNumber(values), `Expected a number at '${this.path}' in ${label}`);
802
- value = values;
803
- }
804
- fn(this.leaf, theirs.leaf, value, this.path);
614
+ fn(this.leaf, theirs.leaf);
805
615
  }
806
616
  broadcast(input, resolve, apply, _label) {
807
617
  const resolved = resolve(input, this.path);
@@ -812,10 +622,11 @@ var LeafNode = class LeafNode extends ChannelTreeNode {
812
622
  apply(this.leaf, value);
813
623
  }
814
624
  };
815
- var ListNode = class ListNode extends ChannelTreeNode {
625
+ var ListNode = class ListNode {
626
+ path;
816
627
  children;
817
628
  constructor(children, path) {
818
- super(path);
629
+ this.path = path;
819
630
  this.children = children;
820
631
  }
821
632
  mirror(read, slots, carrier, key) {
@@ -824,23 +635,17 @@ var ListNode = class ListNode extends ChannelTreeNode {
824
635
  const slotcarrier = mine;
825
636
  for (let i = 0; i < this.children.length; i++) this.children[i].mirror(read, slots, slotcarrier, i);
826
637
  }
827
- scatter(input, apply) {
638
+ scatter(input, accept, apply) {
828
639
  invariant(isArray(input), () => `Expected an array at ${describePath(this.path)}`);
829
640
  invariant(input.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}'`);
830
641
  for (let i = 0; i < input.length; i++) {
831
642
  if (input[i] === void 0) continue;
832
- this.children[i].scatter(input[i], apply);
643
+ this.children[i].scatter(input[i], accept, apply);
833
644
  }
834
645
  }
835
- zip(theirs, values, label, fn) {
646
+ zip(theirs, fn) {
836
647
  invariant(theirs instanceof ListNode && theirs.children.length === this.children.length, () => `Shape mismatch at ${describePath(this.path)}`);
837
- let list;
838
- if (values !== void 0) {
839
- invariant(isArray(values), `Expected an array at ${describePath(this.path)} in ${label}`);
840
- invariant(values.length <= this.children.length, `Unknown channel '${joinPath(this.path, this.children.length)}' in ${label}`);
841
- list = values;
842
- }
843
- for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], list?.[i], label, fn);
648
+ for (let i = 0; i < this.children.length; i++) this.children[i].zip(theirs.children[i], fn);
844
649
  }
845
650
  broadcast(input, resolve, apply, label) {
846
651
  const resolved = resolve(input, this.path);
@@ -859,10 +664,11 @@ var ListNode = class ListNode extends ChannelTreeNode {
859
664
  for (const child of this.children) child.cover(value, apply);
860
665
  }
861
666
  };
862
- var RecordNode = class RecordNode extends ChannelTreeNode {
667
+ var RecordNode = class RecordNode {
668
+ path;
863
669
  children;
864
670
  constructor(children, path) {
865
- super(path);
671
+ this.path = path;
866
672
  this.children = children;
867
673
  }
868
674
  mirror(read, slots, carrier, key) {
@@ -870,28 +676,22 @@ var RecordNode = class RecordNode extends ChannelTreeNode {
870
676
  carrier[key] = mine;
871
677
  for (const [childKey, child] of this.children) child.mirror(read, slots, mine, childKey);
872
678
  }
873
- scatter(input, apply) {
679
+ scatter(input, accept, apply) {
874
680
  invariant(isRecord(input), () => `Expected an object at ${describePath(this.path)}`);
875
681
  for (const key of Object.keys(input)) {
876
682
  const value = input[key];
877
683
  if (value === void 0) continue;
878
684
  const child = this.children.get(key);
879
685
  invariant(child !== void 0, () => `Unknown channel '${joinPath(this.path, key)}'`);
880
- child.scatter(value, apply);
686
+ child.scatter(value, accept, apply);
881
687
  }
882
688
  }
883
- zip(theirs, values, label, fn) {
689
+ zip(theirs, fn) {
884
690
  invariant(theirs instanceof RecordNode && theirs.children.size === this.children.size, () => `Shape mismatch at ${describePath(this.path)}`);
885
- let record;
886
- if (values !== void 0) {
887
- invariant(isRecord(values), () => `Expected an object at ${describePath(this.path)} in ${label}`);
888
- for (const key of Object.keys(values)) invariant(this.children.has(key), () => `Unknown channel '${joinPath(this.path, key)}' in ${label}`);
889
- record = values;
890
- }
891
691
  for (const [key, child] of this.children) {
892
692
  const their = theirs.children.get(key);
893
693
  invariant(their !== void 0, () => `Shape mismatch at ${describePath(this.path)}`);
894
- child.zip(their, record?.[key], label, fn);
694
+ child.zip(their, fn);
895
695
  }
896
696
  }
897
697
  broadcast(input, resolve, apply, label) {
@@ -914,11 +714,50 @@ var RecordNode = class RecordNode extends ChannelTreeNode {
914
714
  }
915
715
  };
916
716
  /**
717
+ * A shape parsed into a node tree, paired with its leaves flattened in
718
+ * depth-first shape order. `parser.match` decides leaf vs branch at every
719
+ * position; the root must already be a container — callers validate that
720
+ * (with their own message) before building. Nested non-containers and
721
+ * empty nodes throw through `parser.mismatch`/`parser.empty`.
722
+ *
723
+ * Holds no behavior of its own: traversals run through `root`, and a live
724
+ * view comes from `new ShapeView(root, read)`.
725
+ */
726
+ var ShapeTree = class {
727
+ root;
728
+ leaves;
729
+ constructor(shape, parser) {
730
+ const leaves = [];
731
+ this.root = buildContainer(shape, "", parser, leaves);
732
+ this.leaves = leaves;
733
+ }
734
+ };
735
+ function buildContainer(shape, path, parser, leaves) {
736
+ if (isArray(shape)) {
737
+ invariant(shape.length > 0, () => parser.empty(path));
738
+ return new ListNode(shape.map((child, i) => buildChild(child, joinPath(path, i), parser, leaves)), path);
739
+ }
740
+ const keys = Object.keys(shape);
741
+ invariant(keys.length > 0, () => parser.empty(path));
742
+ const children = /* @__PURE__ */ new Map();
743
+ for (const key of keys) children.set(key, buildChild(shape[key], joinPath(path, key), parser, leaves));
744
+ return new RecordNode(children, path);
745
+ }
746
+ function buildChild(value, path, parser, leaves) {
747
+ const leaf = parser.match(value, path);
748
+ if (leaf !== void 0) {
749
+ leaves.push(leaf);
750
+ return new LeafNode(leaf, path);
751
+ }
752
+ invariant(isRecordOrArray(value), () => parser.mismatch(path));
753
+ return buildContainer(value, path, parser, leaves);
754
+ }
755
+ /**
917
756
  * A live view of a shape: `read(leaf)` at every leaf, mirrored into a stable
918
- * object — `refresh` rewrites the numbers in place through slots recorded at
757
+ * object — `refresh` rewrites the values in place through slots recorded at
919
758
  * construction, so per-frame reads do no traversal or allocation.
920
759
  */
921
- var ChannelView = class {
760
+ var ShapeView = class {
922
761
  root;
923
762
  #read;
924
763
  #slots = [];
@@ -934,83 +773,463 @@ var ChannelView = class {
934
773
  }
935
774
  };
936
775
  //#endregion
937
- //#region src/channel-tree.ts
776
+ //#region src/spring-source.ts
938
777
  /**
939
- * A numeric shape — a plain object or array whose leaves are all numbers —
940
- * flattened into one leaf object per number, with the nesting preserved as a
941
- * tree of `ChannelTreeNode`s. The shape is fixed at construction. Knows nothing
942
- * about what the leaves are: construction, views, scatter, zip, and
943
- * broadcast are all structural; leaf semantics
944
- * live with the caller. Paths appear as dot-joined strings (`position.x`,
945
- * `color.2`) in every error.
778
+ * The property key that brands a `SpringSource` and holds the channel its
779
+ * followers read. A registry symbol (`Symbol.for('coily/spring-source')`),
780
+ * so sources from one copy of coily are recognized by another when a
781
+ * bundle duplicates the library.
946
782
  */
947
- var ChannelTree = class {
948
- #root;
949
- #leaves = [];
950
- constructor(shape, factory) {
951
- invariant(isRecordOrArray(shape), "A shape must be a plain object or an array of numeric channels");
952
- this.#root = ChannelTreeNode.build(shape, "", factory, this.#leaves);
783
+ const SpringSourceSymbol = Symbol.for("coily/spring-source");
784
+ function isSpringSource(value) {
785
+ return typeof value === "object" && value !== null && SpringSourceSymbol in value;
786
+ }
787
+ const RECIPES = /* @__PURE__ */ new WeakMap();
788
+ function applyFns(fns, value) {
789
+ let result = value;
790
+ for (const fn of fns) result = fn(result);
791
+ return result;
792
+ }
793
+ function sourceMismatch(path) {
794
+ return `Invalid value at ${describePath(path)}: expected a SpringSource or a nested shape of SpringSources`;
795
+ }
796
+ /**
797
+ * Parses a shape of sources into a live view, collecting subscription
798
+ * roots along the way (a mapped leaf contributes its recipe's roots and
799
+ * reads through its pipeline). Structure is validated once at creation;
800
+ * reads refresh a stable mirror through the view's slots, so per-update
801
+ * reads do no traversal or allocation.
802
+ */
803
+ function compileShape(shape, roots) {
804
+ const { root } = new ShapeTree(shape, {
805
+ match(value) {
806
+ if (!isSpringSource(value)) return void 0;
807
+ const recipe = RECIPES.get(value);
808
+ if (recipe) {
809
+ roots.push(...recipe.sources);
810
+ return () => applyFns(recipe.fns, recipe.read());
811
+ }
812
+ roots.push(value);
813
+ const api = value[SpringSourceSymbol];
814
+ return () => api.value;
815
+ },
816
+ mismatch: sourceMismatch,
817
+ empty: (path) => `Invalid value at ${describePath(path)}: a shape must contain at least one source`
818
+ });
819
+ return new ShapeView(root, (leaf) => leaf());
820
+ }
821
+ function mapSpring(source, map) {
822
+ const compute = map;
823
+ let recipe;
824
+ if (isSpringSource(source)) {
825
+ const flat = RECIPES.get(source);
826
+ if (flat) recipe = {
827
+ sources: flat.sources,
828
+ read: flat.read,
829
+ fns: [...flat.fns, compute]
830
+ };
831
+ else {
832
+ const api = source[SpringSourceSymbol];
833
+ recipe = {
834
+ sources: [source],
835
+ read: () => api.value,
836
+ fns: [compute]
837
+ };
838
+ }
839
+ } else {
840
+ invariant(isRecordOrArray(source), () => sourceMismatch(""));
841
+ const roots = [];
842
+ const view = compileShape(source, roots);
843
+ recipe = {
844
+ sources: [...new Set(roots)],
845
+ read: () => {
846
+ view.refresh();
847
+ return view.root;
848
+ },
849
+ fns: [compute]
850
+ };
953
851
  }
954
- /** Every leaf in depth-first shape order. */
955
- get leaves() {
956
- return this.#leaves;
852
+ const { sources, read, fns } = recipe;
853
+ const single = sources.length === 1 ? sources[0][SpringSourceSymbol] : null;
854
+ const apis = single ? null : sources.map((leaf) => leaf[SpringSourceSymbol]);
855
+ const mapped = Object.freeze({ [SpringSourceSymbol]: Object.freeze({
856
+ get value() {
857
+ return applyFns(fns, read());
858
+ },
859
+ onUpdate: single ? (callback) => single.onUpdate(callback) : (callback) => {
860
+ const unsubscribes = apis.map((api) => api.onUpdate(callback));
861
+ return () => {
862
+ for (const unsubscribe of unsubscribes) unsubscribe();
863
+ };
864
+ },
865
+ onDispose: single ? (callback) => single.onDispose(callback) : (callback) => {
866
+ let fired = false;
867
+ const unsubscribes = apis.map((api) => api.onDispose(() => {
868
+ if (fired) return;
869
+ fired = true;
870
+ for (const unsubscribe of unsubscribes) unsubscribe();
871
+ callback();
872
+ }));
873
+ return () => {
874
+ for (const unsubscribe of unsubscribes) unsubscribe();
875
+ };
876
+ }
877
+ }) });
878
+ RECIPES.set(mapped, recipe);
879
+ return mapped;
880
+ }
881
+ //#endregion
882
+ //#region src/spring.ts
883
+ /**
884
+ * One animated number, driven toward its target with damped spring
885
+ * motion. Create springs with `SpringSystem.createSpring`; the owning
886
+ * system advances them, so a spring only moves while its system runs.
887
+ *
888
+ * Reads return the exact simulated state; nothing is rounded. Numeric
889
+ * writes — `target`, `value`, `velocity`, `jumpTo` — require finite
890
+ * numbers: `NaN` or an infinity throws rather than poisoning the
891
+ * simulation. See
892
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the
893
+ * numerical contract.
894
+ */
895
+ var Spring = class {
896
+ /** Brands the spring as a `KinematicSource` whose api is the spring itself. */
897
+ get [SpringSourceSymbol]() {
898
+ return this;
899
+ }
900
+ #target;
901
+ #config;
902
+ #purpose;
903
+ #motion;
904
+ #motions;
905
+ #leader = null;
906
+ #unsubLeader = null;
907
+ #settled = null;
908
+ #resolveSettled = null;
909
+ #disposed = false;
910
+ constructor(motions, value, config, purpose = "motion") {
911
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
912
+ invariant(purpose === "motion" || purpose === "appearance", "Spring purpose must be 'motion' or 'appearance'");
913
+ this.#motions = motions;
914
+ this.#config = config ?? SpringDefinition.default;
915
+ this.#target = value;
916
+ this.#purpose = purpose;
917
+ this.#motion = new Motion(this.#config, 0, 0);
918
+ this.#motion.respectsReducedMotion = purpose === "motion";
957
919
  }
958
920
  /**
959
- * Builds a live view of the shape with `read(leaf)` at every leaf —
960
- * see `ChannelView`.
921
+ * The value the spring is animating toward. While following a source,
922
+ * reads return the source's value as of its last update.
923
+ *
924
+ * Assignment accepts any `SpringTarget`:
925
+ * - A number retargets the spring. The current value and momentum carry
926
+ * over, so mid-flight retargets stay smooth. Assigning a number while
927
+ * following also stops the following.
928
+ * - A `SpringSource` — a `Spring`, or a value derived with `mapSpring` —
929
+ * makes this spring follow the source's live value.
930
+ *
931
+ * Under reduced motion, retargets apply instantly — unless `purpose` is
932
+ * `'appearance'`, in which case they animate as normal.
961
933
  */
962
- createView(read) {
963
- return new ChannelView(this.#root, read);
934
+ get target() {
935
+ return this.#target;
936
+ }
937
+ set target(value) {
938
+ if (typeof value === "number") {
939
+ this.#unfollow();
940
+ this.#setTarget(value);
941
+ } else {
942
+ invariant(isSpringSource(value), "Spring target must be a number or a SpringSource");
943
+ invariant(typeof value[SpringSourceSymbol].value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
944
+ this.#follow(value);
945
+ }
964
946
  }
965
947
  /**
966
- * Scatters a partial numeric shape: `apply` runs for each channel the input
967
- * mentions, the rest are untouched. Unknown channels and structure
968
- * mismatches throw; `undefined` entries (and array holes) are skipped.
948
+ * The current animated value: the target plus the remaining
949
+ * displacement.
950
+ *
951
+ * Writing displaces the spring: it keeps its target and springs back
952
+ * from the written value, notifying update listeners synchronously.
953
+ * Writing the current value is a no-op. Under reduced motion a write
954
+ * jumps the spring — target included — to the written value, unless
955
+ * `purpose` is `'appearance'`, in which case it displaces as normal.
969
956
  */
970
- scatter(input, apply) {
971
- this.#root.scatter(input, apply);
957
+ get value() {
958
+ return this.#target + this.#motion.position;
959
+ }
960
+ set value(value) {
961
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
962
+ if (this.#motions.reduced && this.#purpose === "motion") {
963
+ if (value !== this.value) this.jumpTo(value);
964
+ return;
965
+ }
966
+ const position = value - this.#target;
967
+ if (position !== this.#motion.position) {
968
+ this.#motions.add(this.#motion);
969
+ this.#motion.position = position;
970
+ this.#motion.tick(0);
971
+ }
972
+ }
973
+ /**
974
+ * The current velocity, in value units per second.
975
+ *
976
+ * Writing flings the spring: motion continues from the current value
977
+ * with the written velocity, then settles back to the target. Under
978
+ * reduced motion writes are ignored, unless `purpose` is `'appearance'`,
979
+ * in which case the fling applies as normal.
980
+ */
981
+ get velocity() {
982
+ return this.#motion.velocity;
983
+ }
984
+ set velocity(value) {
985
+ invariant(Number.isFinite(value), "Spring velocity must be a finite number");
986
+ if (this.#motions.reduced && this.#purpose === "motion") return;
987
+ this.#motions.add(this.#motion);
988
+ this.#motion.velocity = value;
972
989
  }
973
990
  /**
974
- * Pairs this map's leaves with another map's, requiring the shapes to
975
- * match exactly. `values` is an optional partial numeric shape (labelled
976
- * `valuesLabel` in errors) delivered alongside each pair — absent entries
977
- * arrive as `undefined`.
991
+ * The current acceleration, in value units per second squared — how
992
+ * fast the velocity is changing. Read-only: acceleration is the
993
+ * spring's stiffness and friction acting on its current displacement
994
+ * and velocity, so it follows from the motion rather than being set.
995
+ * To fling the spring, write `velocity` instead.
978
996
  */
979
- zip(other, values, valuesLabel, fn) {
980
- this.#root.zip(other.#root, values, valuesLabel, fn);
997
+ get acceleration() {
998
+ const { tension, damping, mass } = this.#config;
999
+ return -(tension * this.#motion.position + damping * this.#motion.velocity) / mass;
981
1000
  }
982
1001
  /**
983
- * Broadcasts a covering shape — an input mirroring the shape where a value
984
- * at any node covers every leaf below it. At each node, `resolve` decides
985
- * whether the input is a value for the whole subtree or a branch to
986
- * descend into; `apply` runs per covered leaf. `undefined` entries are
987
- * skipped, unknown channels (labelled `label` in errors) throw.
1002
+ * What the spring animates — `'motion'` or `'appearance'` — fixed when
1003
+ * it was created. An `'appearance'` spring opts out of reduced motion:
1004
+ * it keeps animating where a `'motion'` spring snaps to its target. See
1005
+ * `Purpose`.
988
1006
  */
989
- broadcast(input, resolve, apply, label) {
990
- if (input === void 0) return;
991
- this.#root.broadcast(input, resolve, apply, label);
1007
+ get purpose() {
1008
+ return this.#purpose;
1009
+ }
1010
+ /**
1011
+ * The spring's `SpringDefinition`: the one it was created with or last
1012
+ * assigned, otherwise `SpringDefinition.default`. Following a source
1013
+ * never changes it — how a spring chases its target is the spring's
1014
+ * own setting.
1015
+ *
1016
+ * Assigning a config reconfigures the spring in place — value and
1017
+ * velocity are preserved, so mid-flight reconfigures stay smooth.
1018
+ * Assigning `null` reverts to the default.
1019
+ */
1020
+ get config() {
1021
+ return this.#config;
1022
+ }
1023
+ /** The config's mass. */
1024
+ get mass() {
1025
+ return this.#config.mass;
1026
+ }
1027
+ /** The config's tension (stiffness). */
1028
+ get tension() {
1029
+ return this.#config.tension;
1030
+ }
1031
+ /** The config's damping (friction). */
1032
+ get damping() {
1033
+ return this.#config.damping;
1034
+ }
1035
+ /** The config's damping ratio. */
1036
+ get dampingRatio() {
1037
+ return this.#config.dampingRatio;
1038
+ }
1039
+ /** The config's resting precision. */
1040
+ get precision() {
1041
+ return this.#config.precision;
1042
+ }
1043
+ set config(value) {
1044
+ const next = value ?? SpringDefinition.default;
1045
+ if (this.#config === next) return;
1046
+ this.#config = next;
1047
+ this.#motion.configure(next);
1048
+ if (!this.#motion.isResting) this.#motions.add(this.#motion);
1049
+ }
1050
+ /**
1051
+ * Estimated milliseconds until the spring rests: 0 while resting,
1052
+ * Infinity when undamped. The estimate carries a safety margin, so
1053
+ * actual rest usually lands earlier.
1054
+ */
1055
+ get timeRemaining() {
1056
+ return this.#motion.timeRemaining;
1057
+ }
1058
+ /**
1059
+ * Whether the spring's remaining motion is inside its resting
1060
+ * threshold. Resting springs cost nothing until the next write. See
1061
+ * https://github.com/tkofh/coily/blob/main/PRECISION.md for the rest
1062
+ * test.
1063
+ */
1064
+ get isResting() {
1065
+ return this.#motion.isResting;
1066
+ }
1067
+ /**
1068
+ * A promise that resolves when the spring next comes to rest — already
1069
+ * resolved while resting or after dispose. Retargeting mid-flight
1070
+ * extends the wait; disposing resolves it.
1071
+ *
1072
+ * @example
1073
+ * ```ts
1074
+ * spring.target = 300
1075
+ * await spring.settled
1076
+ * // the spring is at 300 and resting
1077
+ * ```
1078
+ */
1079
+ get settled() {
1080
+ if (this.#disposed || this.#motion.isResting) return RESOLVED;
1081
+ this.#settled ??= new Promise((resolve) => {
1082
+ this.#resolveSettled = resolve;
1083
+ const unsubscribe = this.onStop(() => {
1084
+ unsubscribe();
1085
+ this.#settled = null;
1086
+ this.#resolveSettled = null;
1087
+ resolve();
1088
+ });
1089
+ });
1090
+ return this.#settled;
1091
+ }
1092
+ /**
1093
+ * Snaps the spring to `value` with no animation: target and value both
1094
+ * become `value` and velocity clears. Listeners are notified
1095
+ * synchronously — `update`, plus `stop` if the spring was moving.
1096
+ */
1097
+ jumpTo(value) {
1098
+ invariant(Number.isFinite(value), "Spring value must be a finite number");
1099
+ this.#target = value;
1100
+ this.#motion.finish();
1101
+ }
1102
+ /**
1103
+ * Releases the spring permanently: stops following, detaches followers
1104
+ * (each keeps its current target), resolves `settled`, and notifies
1105
+ * dispose listeners. Calling it again is a no-op.
1106
+ *
1107
+ * A disposed spring keeps its final value readable; writes throw.
1108
+ */
1109
+ dispose() {
1110
+ if (this.#disposed) return;
1111
+ this.#disposed = true;
1112
+ if (this.#resolveSettled) {
1113
+ this.#resolveSettled();
1114
+ this.#settled = null;
1115
+ this.#resolveSettled = null;
1116
+ }
1117
+ if (this.#unsubLeader) {
1118
+ this.#unsubLeader();
1119
+ this.#unsubLeader = null;
1120
+ this.#leader = null;
1121
+ }
1122
+ this.#motions.remove(this.#motion);
1123
+ this.#motion.dispose();
1124
+ }
1125
+ /**
1126
+ * Subscribes to value changes: every tick the spring moves, plus
1127
+ * synchronous `value` writes and `jumpTo` calls. Returns an unsubscribe
1128
+ * function.
1129
+ */
1130
+ onUpdate(callback) {
1131
+ return this.#motion.onUpdate(callback);
1132
+ }
1133
+ /**
1134
+ * Subscribes to the spring leaving rest. `start` and `stop` always
1135
+ * alternate; retargeting mid-flight fires neither. Returns an
1136
+ * unsubscribe function.
1137
+ */
1138
+ onStart(callback) {
1139
+ return this.#motion.onStart(callback);
1140
+ }
1141
+ /**
1142
+ * Subscribes to the spring coming to rest, whether by settling or by
1143
+ * `jumpTo`. Always alternates with `start`. Returns an unsubscribe
1144
+ * function.
1145
+ */
1146
+ onStop(callback) {
1147
+ return this.#motion.onStop(callback);
1148
+ }
1149
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1150
+ onDispose(callback) {
1151
+ return this.#motion.onDispose(callback);
1152
+ }
1153
+ #setTarget(value) {
1154
+ invariant(Number.isFinite(value), "Spring target must be a finite number");
1155
+ if (value !== this.#target) {
1156
+ if (this.#motions.reduced && this.#purpose === "motion") {
1157
+ this.jumpTo(value);
1158
+ return;
1159
+ }
1160
+ this.#motions.add(this.#motion);
1161
+ const current = this.value;
1162
+ this.#target = value;
1163
+ this.#motion.position = current - this.#target;
1164
+ this.#motion.tick(0, false);
1165
+ }
1166
+ }
1167
+ #follow(source) {
1168
+ this.#unsubLeader?.();
1169
+ this.#leader = source;
1170
+ const api = source[SpringSourceSymbol];
1171
+ const unsubUpdate = api.onUpdate(() => {
1172
+ this.#setTarget(api.value);
1173
+ });
1174
+ const unsubDispose = api.onDispose(() => {
1175
+ this.#unfollow();
1176
+ });
1177
+ this.#unsubLeader = () => {
1178
+ unsubUpdate();
1179
+ unsubDispose();
1180
+ };
1181
+ this.#setTarget(api.value);
1182
+ }
1183
+ #unfollow() {
1184
+ if (!this.#leader) return;
1185
+ this.#unsubLeader();
1186
+ this.#unsubLeader = null;
1187
+ this.#leader = null;
992
1188
  }
993
1189
  };
994
1190
  //#endregion
995
- //#region src/spring-object.ts
1191
+ //#region src/composite-spring.ts
996
1192
  function invalidConfig(path) {
997
- return `Invalid config for ${describePath(path)}: expected a SpringConfig, null, or a config shape matching the value`;
1193
+ return `Invalid config for ${describePath(path)}: expected a SpringDefinition, null, or a config shape matching the value`;
998
1194
  }
999
1195
  /**
1000
- * Decides what a config node means: `null` and `SpringConfig` instances are
1196
+ * Decides what a config node means: `null` and `SpringDefinition` instances are
1001
1197
  * configs covering the whole subtree, any other object or array is a config
1002
1198
  * shape to descend into (its structure is checked on the way down), and
1003
1199
  * anything else — a bare number, string, or options object — is invalid.
1004
1200
  */
1005
1201
  function resolveConfigNode(node, path) {
1006
1202
  if (node === null) return null;
1007
- if (node instanceof SpringConfig) return node;
1203
+ if (node instanceof SpringDefinition) return node;
1008
1204
  if (isRecordOrArray(node)) return BRANCH;
1009
1205
  throw new Error(invalidConfig(path));
1010
1206
  }
1207
+ function invalidPurpose(path) {
1208
+ return `Invalid purpose for ${describePath(path)}: expected 'motion', 'appearance', or a purpose shape matching the value`;
1209
+ }
1210
+ /**
1211
+ * Decides what a purpose node means: a `Purpose` string covers the whole
1212
+ * subtree, any object or array is a purpose shape to descend into, and
1213
+ * anything else is invalid.
1214
+ */
1215
+ function resolvePurposeNode(node, path) {
1216
+ if (node === "motion" || node === "appearance") return node;
1217
+ if (isRecordOrArray(node)) return BRANCH;
1218
+ throw new Error(invalidPurpose(path));
1219
+ }
1011
1220
  const readTarget = (spring) => spring.target;
1012
1221
  const readValue = (spring) => spring.value;
1013
1222
  const readVelocity = (spring) => spring.velocity;
1223
+ const readAcceleration = (spring) => spring.acceleration;
1224
+ /**
1225
+ * The target scatter's leaf guard: a channel animates toward a number,
1226
+ * or follows a scalar source. Checked here so the error carries the
1227
+ * channel's path.
1228
+ */
1229
+ function acceptChannelTarget(input, path) {
1230
+ invariant(isNumber(input) && Number.isFinite(input) || isSpringSource(input) && typeof input[SpringSourceSymbol].value === "number", () => `Invalid value at '${path}': expected a finite number or a scalar SpringSource`);
1231
+ return input;
1232
+ }
1014
1233
  const assignTarget = (spring, value) => {
1015
1234
  spring.target = value;
1016
1235
  };
@@ -1026,19 +1245,37 @@ const applyJump = (spring, value) => {
1026
1245
  const assignConfig = (spring, config) => {
1027
1246
  spring.config = config;
1028
1247
  };
1029
- const followChannel = (mine, theirs, offset) => {
1030
- mine.target = offset ? {
1031
- spring: theirs,
1032
- offset
1033
- } : theirs;
1248
+ const followChannel = (mine, theirs) => {
1249
+ mine.target = theirs;
1034
1250
  };
1035
- const RESOLVED = Promise.resolve();
1036
- var SpringObject = class SpringObject {
1251
+ /**
1252
+ * A composite spring over a fixed numeric shape — a nested plain object
1253
+ * or array whose leaves are all numbers. Each leaf runs an independent
1254
+ * spring, called a channel, behind one API: targets, values, velocities,
1255
+ * and configs read and write as (partial) shapes, and events are
1256
+ * coalesced across channels. Create composite springs with
1257
+ * `SpringSystem.createSpring`.
1258
+ *
1259
+ * A composite spring is a `SpringSource` of its value shape: `mapSpring`
1260
+ * derives scalar sources from it — alone or at the leaves of a shape —
1261
+ * which springs can then follow. Only scalar sources are followable
1262
+ * directly.
1263
+ *
1264
+ * The shape is fixed at construction. Writes naming unknown channels
1265
+ * throw with the channel's path (`position.z`), and so do non-finite
1266
+ * channel values.
1267
+ */
1268
+ var CompositeSpring = class CompositeSpring {
1269
+ /** Brands the composite as a `KinematicSource` whose api is the composite itself. */
1270
+ get [SpringSourceSymbol]() {
1271
+ return this;
1272
+ }
1037
1273
  #motions;
1038
- #map;
1039
- #targetView;
1274
+ #tree;
1040
1275
  #valueView;
1041
- #velocityView;
1276
+ #targetView = null;
1277
+ #velocityView = null;
1278
+ #accelerationView = null;
1042
1279
  #emitter = new Emitter();
1043
1280
  #running = false;
1044
1281
  #dirty = false;
@@ -1061,14 +1298,18 @@ var SpringObject = class SpringObject {
1061
1298
  this.#emitter.emit("start");
1062
1299
  }
1063
1300
  };
1064
- constructor(motions, value, config) {
1301
+ constructor(motions, value, config, purpose) {
1065
1302
  this.#motions = motions;
1066
- invariant(isRecordOrArray(value), "Spring object value must be a plain object or an array of numeric channels");
1067
- this.#map = new ChannelTree(value, (leafValue) => new Spring(motions, leafValue));
1068
- if (config !== void 0) this.#map.broadcast(config, resolveConfigNode, assignConfig, "config");
1069
- this.#targetView = this.#map.createView(readTarget);
1070
- this.#valueView = this.#map.createView(readValue);
1071
- this.#velocityView = this.#map.createView(readVelocity);
1303
+ invariant(isRecordOrArray(value), "Composite spring value must be a plain object or an array of numeric channels");
1304
+ let purposeByPath = null;
1305
+ if (purpose !== void 0) {
1306
+ const byPath = /* @__PURE__ */ new Map();
1307
+ new ShapeTree(value, channelParser((_leafValue, path) => path)).root.broadcast(purpose, resolvePurposeNode, (path, p) => byPath.set(path, p), "purpose");
1308
+ purposeByPath = byPath;
1309
+ }
1310
+ this.#tree = new ShapeTree(value, channelParser((leafValue, path) => new Spring(motions, leafValue, void 0, purposeByPath?.get(path) ?? "motion")));
1311
+ if (config !== void 0) this.#tree.root.broadcast(config, resolveConfigNode, assignConfig, "config");
1312
+ this.#valueView = new ShapeView(this.#tree.root, readValue);
1072
1313
  const markDirty = () => {
1073
1314
  this.#dirty = true;
1074
1315
  this.#motions.flushes.request(this.#flush);
@@ -1076,66 +1317,141 @@ var SpringObject = class SpringObject {
1076
1317
  const schedule = () => {
1077
1318
  this.#motions.flushes.request(this.#flush);
1078
1319
  };
1079
- for (const channel of this.#map.leaves) {
1320
+ for (const channel of this.#tree.leaves) {
1080
1321
  channel.onUpdate(markDirty);
1081
1322
  channel.onStart(schedule);
1082
1323
  channel.onStop(schedule);
1083
1324
  }
1084
1325
  }
1326
+ /**
1327
+ * The value each channel is animating toward, as a read-only mirror of
1328
+ * the shape. The mirror object is reused: every read refreshes its
1329
+ * numbers in place, so read it fresh rather than holding it across
1330
+ * frames.
1331
+ *
1332
+ * Assignment accepts a `CompositeSpringTarget`:
1333
+ * - A partial shape retargets the channels it names and leaves the
1334
+ * others alone. Each named channel takes a number, or a scalar
1335
+ * `SpringSource` to follow — a `Spring`, or a value derived with
1336
+ * `mapSpring`. While following a leader, naming a channel also
1337
+ * detaches that channel from it.
1338
+ * - A `CompositeSpring` of the same shape follows the leader channel by
1339
+ * channel.
1340
+ *
1341
+ * Unknown channels throw with their path.
1342
+ */
1085
1343
  get target() {
1086
- this.#targetView.refresh();
1087
- return this.#targetView.root;
1344
+ const view = this.#targetView ??= new ShapeView(this.#tree.root, readTarget);
1345
+ view.refresh();
1346
+ return view.root;
1088
1347
  }
1089
1348
  set target(value) {
1090
- if (value instanceof SpringObject) this.#follow(value, void 0);
1091
- else if (isRecord(value) && "spring" in value && value.spring instanceof SpringObject) {
1092
- const { spring, offset } = value;
1093
- this.#follow(spring, offset);
1094
- } else this.#motions.flushes.batch(() => {
1095
- this.#map.scatter(value, assignTarget);
1349
+ if (value instanceof CompositeSpring) this.#follow(value);
1350
+ else this.#motions.flushes.batch(() => {
1351
+ this.#tree.root.scatter(value, acceptChannelTarget, assignTarget);
1096
1352
  });
1097
1353
  }
1354
+ /**
1355
+ * The current animated value of every channel, as a read-only mirror of
1356
+ * the shape. The mirror object is reused: every read refreshes its
1357
+ * numbers in place, so read it fresh rather than holding it across
1358
+ * frames.
1359
+ *
1360
+ * Assignment takes a partial shape and displaces the channels it names:
1361
+ * each keeps its target and springs back from the written value, with
1362
+ * one coalesced `update` fired synchronously. Under reduced motion a
1363
+ * write jumps the named channels — targets included — except channels
1364
+ * whose `purpose` is `'appearance'`, which displace as normal.
1365
+ */
1098
1366
  get value() {
1099
1367
  this.#valueView.refresh();
1100
1368
  return this.#valueView.root;
1101
1369
  }
1102
1370
  set value(value) {
1103
1371
  this.#motions.flushes.batch(() => {
1104
- this.#map.scatter(value, assignValue);
1372
+ this.#tree.root.scatter(value, acceptNumber, assignValue);
1105
1373
  });
1106
1374
  }
1375
+ /**
1376
+ * The current velocity of every channel in value units per second, as a
1377
+ * read-only mirror of the shape — reused and refreshed in place on each
1378
+ * read.
1379
+ *
1380
+ * Assignment takes a partial shape and flings the channels it names;
1381
+ * the rest are untouched. Under reduced motion writes are ignored,
1382
+ * except on channels whose `purpose` is `'appearance'`.
1383
+ */
1107
1384
  get velocity() {
1108
- this.#velocityView.refresh();
1109
- return this.#velocityView.root;
1385
+ const view = this.#velocityView ??= new ShapeView(this.#tree.root, readVelocity);
1386
+ view.refresh();
1387
+ return view.root;
1110
1388
  }
1111
1389
  set velocity(value) {
1112
1390
  this.#motions.flushes.batch(() => {
1113
- this.#map.scatter(value, assignVelocity);
1391
+ this.#tree.root.scatter(value, acceptNumber, assignVelocity);
1114
1392
  });
1115
1393
  }
1394
+ /**
1395
+ * The current acceleration of every channel in value units per second
1396
+ * squared, as a read-only mirror of the shape — reused and refreshed in
1397
+ * place on each read. Read-only, like each channel's: acceleration
1398
+ * follows from the motion. To fling channels, write `velocity`.
1399
+ */
1400
+ get acceleration() {
1401
+ const view = this.#accelerationView ??= new ShapeView(this.#tree.root, readAcceleration);
1402
+ view.refresh();
1403
+ return view.root;
1404
+ }
1405
+ /**
1406
+ * The shared config when every channel resolves to the same
1407
+ * `SpringDefinition`, and `null` when they differ.
1408
+ *
1409
+ * Assignment accepts a `ConfigShape`: one config for every channel,
1410
+ * `null` to revert every channel to the default, or a shape mirroring
1411
+ * the value with configs at any level — a config at a subtree covers
1412
+ * every channel below it. Non-config leaves throw with their path.
1413
+ */
1116
1414
  get config() {
1117
- const channels = this.#map.leaves;
1415
+ const channels = this.#tree.leaves;
1118
1416
  const first = channels[0].config;
1119
1417
  for (let i = 1; i < channels.length; i++) if (channels[i].config !== first) return null;
1120
1418
  return first;
1121
1419
  }
1122
1420
  set config(value) {
1123
1421
  this.#motions.flushes.batch(() => {
1124
- this.#map.broadcast(value ?? null, resolveConfigNode, assignConfig, "config");
1422
+ this.#tree.root.broadcast(value ?? null, resolveConfigNode, assignConfig, "config");
1125
1423
  });
1126
1424
  }
1425
+ /**
1426
+ * The shared purpose when every channel resolves to the same `Purpose`,
1427
+ * and `null` when they differ. Fixed when the spring is created — an
1428
+ * `'appearance'` channel opts out of reduced motion. See `Purpose`.
1429
+ */
1430
+ get purpose() {
1431
+ const channels = this.#tree.leaves;
1432
+ const first = channels[0].purpose;
1433
+ for (let i = 1; i < channels.length; i++) if (channels[i].purpose !== first) return null;
1434
+ return first;
1435
+ }
1436
+ /** The largest `timeRemaining` across channels, in milliseconds. */
1127
1437
  get timeRemaining() {
1128
1438
  let max = 0;
1129
- for (const channel of this.#map.leaves) {
1439
+ for (const channel of this.#tree.leaves) {
1130
1440
  const remaining = channel.timeRemaining;
1131
1441
  if (remaining > max) max = remaining;
1132
1442
  }
1133
1443
  return max;
1134
1444
  }
1445
+ /** Whether every channel is resting. */
1135
1446
  get isResting() {
1136
- for (const channel of this.#map.leaves) if (!channel.isResting) return false;
1447
+ for (const channel of this.#tree.leaves) if (!channel.isResting) return false;
1137
1448
  return true;
1138
1449
  }
1450
+ /**
1451
+ * A promise that resolves when every channel next rests — already
1452
+ * resolved while resting or after dispose. Retargeting any channel
1453
+ * mid-flight extends the wait; disposing resolves it.
1454
+ */
1139
1455
  get settled() {
1140
1456
  if (this.#disposed || this.isResting) return RESOLVED;
1141
1457
  this.#settled ??= new Promise((resolve) => {
@@ -1149,11 +1465,23 @@ var SpringObject = class SpringObject {
1149
1465
  });
1150
1466
  return this.#settled;
1151
1467
  }
1468
+ /**
1469
+ * Snaps the channels the partial shape names to the given values with
1470
+ * no animation, notifying listeners synchronously. Channels it leaves
1471
+ * out are untouched.
1472
+ */
1152
1473
  jumpTo(value) {
1153
1474
  this.#motions.flushes.batch(() => {
1154
- this.#map.scatter(value, applyJump);
1475
+ this.#tree.root.scatter(value, acceptNumber, applyJump);
1155
1476
  });
1156
1477
  }
1478
+ /**
1479
+ * Releases every channel permanently: resolves `settled` and notifies
1480
+ * dispose listeners. Calling it again is a no-op.
1481
+ *
1482
+ * A disposed composite spring keeps its final values readable; writes
1483
+ * throw.
1484
+ */
1157
1485
  dispose() {
1158
1486
  if (this.#disposed) return;
1159
1487
  this.#disposed = true;
@@ -1162,24 +1490,40 @@ var SpringObject = class SpringObject {
1162
1490
  this.#settled = null;
1163
1491
  this.#resolveSettled = null;
1164
1492
  }
1165
- for (const channel of this.#map.leaves) channel.dispose();
1493
+ for (const channel of this.#tree.leaves) channel.dispose();
1166
1494
  this.#emitter.clear();
1167
1495
  }
1496
+ /**
1497
+ * Subscribes to coalesced value changes: at most one `update` per tick,
1498
+ * fired after every channel has advanced, plus one per synchronous
1499
+ * write. Returns an unsubscribe function.
1500
+ */
1168
1501
  onUpdate(callback) {
1169
1502
  return this.#emitter.on("update", callback);
1170
1503
  }
1504
+ /**
1505
+ * Subscribes to the composite leaving rest: a channel starts moving
1506
+ * while all were resting. Always alternates with `stop`. Returns an
1507
+ * unsubscribe function.
1508
+ */
1171
1509
  onStart(callback) {
1172
1510
  return this.#emitter.on("start", callback);
1173
1511
  }
1512
+ /**
1513
+ * Subscribes to the composite coming to rest: every channel resting,
1514
+ * fired after that tick's final `update`. Always alternates with
1515
+ * `start`. Returns an unsubscribe function.
1516
+ */
1174
1517
  onStop(callback) {
1175
1518
  return this.#emitter.on("stop", callback);
1176
1519
  }
1520
+ /** Subscribes to `dispose`, which fires once. Returns an unsubscribe function. */
1177
1521
  onDispose(callback) {
1178
- return this.#map.leaves[0].onDispose(callback);
1522
+ return this.#tree.leaves[0].onDispose(callback);
1179
1523
  }
1180
- #follow(leader, offset) {
1524
+ #follow(leader) {
1181
1525
  this.#motions.flushes.batch(() => {
1182
- this.#map.zip(leader.#map, offset, "offset", followChannel);
1526
+ this.#tree.root.zip(leader.#tree.root, followChannel);
1183
1527
  });
1184
1528
  }
1185
1529
  };
@@ -1190,6 +1534,12 @@ const cancel = (id) => {
1190
1534
  if (typeof window !== "undefined") window.cancelAnimationFrame(id);
1191
1535
  else clearTimeout(id);
1192
1536
  };
1537
+ /**
1538
+ * Drives a `MotionSet` from animation frames. Schedules frames only
1539
+ * while motions exist — otherwise it sleeps and the set's `onWake`
1540
+ * rearms it — paces fps caps to whole display frames, and clamps lag
1541
+ * gaps.
1542
+ */
1193
1543
  var Ticker = class {
1194
1544
  #fps;
1195
1545
  #gap;
@@ -1295,18 +1645,21 @@ var Ticker = class {
1295
1645
  }
1296
1646
  #onFrame(timestamp) {
1297
1647
  if (this.#stopped) return;
1298
- if (this.#primed) {
1299
- const wallElapsed = timestamp - this.#lastWallTime;
1300
- const elapsed = wallElapsed > this.#lagThreshold ? this.#adjustedLag : wallElapsed;
1301
- this.#acc += elapsed;
1302
- if (this.#acc + elapsed / 2 >= this.#capGap) {
1303
- const delta = this.#acc;
1304
- this.#acc = 0;
1305
- this.#step(delta);
1306
- }
1307
- } else this.#primed = true;
1308
- this.#lastWallTime = timestamp;
1309
- this.#schedule();
1648
+ try {
1649
+ if (this.#primed) {
1650
+ const wallElapsed = timestamp - this.#lastWallTime;
1651
+ const elapsed = wallElapsed > this.#lagThreshold ? this.#adjustedLag : wallElapsed;
1652
+ this.#acc += elapsed;
1653
+ if (this.#acc + elapsed / 2 >= this.#capGap) {
1654
+ const delta = this.#acc;
1655
+ this.#acc = 0;
1656
+ this.#step(delta);
1657
+ }
1658
+ } else this.#primed = true;
1659
+ } finally {
1660
+ this.#lastWallTime = timestamp;
1661
+ if (!this.#stopped) this.#schedule();
1662
+ }
1310
1663
  }
1311
1664
  #step(dt) {
1312
1665
  this.#time += dt;
@@ -1340,13 +1693,19 @@ var SpringSystemImpl = class {
1340
1693
  get reducedMotion() {
1341
1694
  return this.#motion.reduced;
1342
1695
  }
1343
- createSpring(position, config) {
1344
- return new Spring(this.#motion, position, config);
1345
- }
1346
- createSpringObject(value, config) {
1347
- return new SpringObject(this.#motion, value, config);
1696
+ createSpring(value, config, options) {
1697
+ if (typeof value === "number") return new Spring(this.#motion, value, config, options?.purpose);
1698
+ if (isSpringSource(value)) {
1699
+ const api = value[SpringSourceSymbol];
1700
+ invariant(typeof api.value === "number", "A spring can only follow a scalar SpringSource; derive one from a composite with mapSpring");
1701
+ const spring = new Spring(this.#motion, api.value, config, options?.purpose);
1702
+ spring.target = value;
1703
+ return spring;
1704
+ }
1705
+ return new CompositeSpring(this.#motion, value, config, options?.purpose);
1348
1706
  }
1349
1707
  advance(dt) {
1708
+ invariant(Number.isFinite(dt), "dt must be a finite number of milliseconds");
1350
1709
  this.#motion.tick(dt / 1e3);
1351
1710
  }
1352
1711
  start() {
@@ -1377,8 +1736,23 @@ var SpringSystemImpl = class {
1377
1736
  this.#ticker.adjustedLag = value;
1378
1737
  }
1379
1738
  };
1739
+ /**
1740
+ * Creates a spring system: the entry point of the library.
1741
+ *
1742
+ * @example
1743
+ * ```ts
1744
+ * const system = createSpringSystem()
1745
+ * system.start()
1746
+ *
1747
+ * const spring = system.createSpring(0)
1748
+ * spring.onUpdate(() => {
1749
+ * element.style.translate = `${spring.value}px 0`
1750
+ * })
1751
+ * spring.target = 300
1752
+ * ```
1753
+ */
1380
1754
  function createSpringSystem(options) {
1381
1755
  return new SpringSystemImpl(options);
1382
1756
  }
1383
1757
  //#endregion
1384
- export { SpringConfig as n, defineSpring as r, createSpringSystem as t };
1758
+ export { SpringDefinition as a, mapSpring as i, SpringSourceSymbol as n, defineSpring as o, isSpringSource as r, invariant as s, createSpringSystem as t };