@msb235/blits 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +31 -7
  3. package/dist/bare.js +7 -1
  4. package/dist/book.d.ts +7 -0
  5. package/dist/book.js +35 -4
  6. package/dist/chain.d.ts +2 -1
  7. package/dist/chain.js +7 -3
  8. package/dist/channels.d.ts +12 -19
  9. package/dist/channels.js +18 -118
  10. package/dist/clock.d.ts +9 -2
  11. package/dist/clock.js +10 -2
  12. package/dist/closed.d.ts +15 -0
  13. package/dist/closed.js +144 -0
  14. package/dist/color.d.ts +61 -0
  15. package/dist/color.js +229 -0
  16. package/dist/columns.js +2 -1
  17. package/dist/crowd.d.ts +1 -1
  18. package/dist/crowd.js +4 -1
  19. package/dist/cue.d.ts +2 -0
  20. package/dist/cue.js +80 -27
  21. package/dist/due.js +3 -0
  22. package/dist/fade.d.ts +13 -0
  23. package/dist/fade.js +57 -4
  24. package/dist/fill.d.ts +1 -1
  25. package/dist/fill.js +15 -8
  26. package/dist/fit.d.ts +144 -0
  27. package/dist/fit.js +181 -0
  28. package/dist/fold.d.ts +3 -3
  29. package/dist/fold.js +18 -11
  30. package/dist/gather.d.ts +6 -0
  31. package/dist/gather.js +18 -0
  32. package/dist/gone.d.ts +32 -0
  33. package/dist/gone.js +134 -0
  34. package/dist/handle.d.ts +27 -2
  35. package/dist/handle.js +60 -11
  36. package/dist/held.js +4 -0
  37. package/dist/history.d.ts +15 -2
  38. package/dist/history.js +56 -9
  39. package/dist/hosts.d.ts +2 -2
  40. package/dist/hosts.js +20 -6
  41. package/dist/index.d.ts +8 -2
  42. package/dist/index.js +5 -1
  43. package/dist/lane.d.ts +1 -1
  44. package/dist/lanes.d.ts +32 -9
  45. package/dist/lanes.js +53 -24
  46. package/dist/locus.d.ts +3 -3
  47. package/dist/locus.js +1 -1
  48. package/dist/marks.d.ts +3 -0
  49. package/dist/marks.js +80 -12
  50. package/dist/meet.d.ts +3 -2
  51. package/dist/meet.js +15 -3
  52. package/dist/mixer.d.ts +56 -36
  53. package/dist/mixer.js +158 -87
  54. package/dist/motion.d.ts +2 -199
  55. package/dist/motion.js +2 -652
  56. package/dist/motions.d.ts +266 -0
  57. package/dist/motions.js +724 -0
  58. package/dist/move.d.ts +1 -1
  59. package/dist/move.js +28 -8
  60. package/dist/owner.d.ts +1 -1
  61. package/dist/owner.js +3 -3
  62. package/dist/pace.d.ts +2 -0
  63. package/dist/pace.js +8 -0
  64. package/dist/pack.d.ts +31 -0
  65. package/dist/pack.js +65 -0
  66. package/dist/paging.d.ts +63 -0
  67. package/dist/paging.js +110 -0
  68. package/dist/patch.d.ts +3 -0
  69. package/dist/patch.js +2 -0
  70. package/dist/place.js +235 -43
  71. package/dist/project.d.ts +7 -4
  72. package/dist/project.js +117 -78
  73. package/dist/pull.js +4 -3
  74. package/dist/qualify.js +4 -2
  75. package/dist/revive.d.ts +58 -0
  76. package/dist/revive.js +158 -0
  77. package/dist/sample.d.ts +1 -1
  78. package/dist/sample.js +6 -1
  79. package/dist/scored.d.ts +57 -0
  80. package/dist/scored.js +196 -0
  81. package/dist/seek.d.ts +3 -0
  82. package/dist/seek.js +311 -0
  83. package/dist/spans.d.ts +29 -0
  84. package/dist/spans.js +165 -0
  85. package/dist/strays.d.ts +1 -1
  86. package/dist/strays.js +21 -0
  87. package/dist/tape.d.ts +21 -0
  88. package/dist/tape.js +65 -0
  89. package/dist/ticker.d.ts +28 -3
  90. package/dist/ticker.js +3 -0
  91. package/dist/transport.d.ts +125 -0
  92. package/dist/transport.js +283 -0
  93. package/dist/types.d.ts +404 -38
  94. package/dist/unpage.d.ts +8 -0
  95. package/dist/unpage.js +165 -0
  96. package/dist/voice.d.ts +75 -7
  97. package/dist/voice.js +119 -4
  98. package/dist/weigh.js +2 -2
  99. package/docs/schema.html +120 -31
  100. package/package.json +2 -1
package/docs/schema.html CHANGED
@@ -142,6 +142,7 @@
142
142
  <li><a href="#time">Time</a></li>
143
143
  <li><a href="#blend">Blending</a></li>
144
144
  <li><a href="#score">Score</a></li>
145
+ <li><a href="#transport">Transport</a></li>
145
146
  <li><a href="#state">Patch state</a></li>
146
147
  <li><a href="#engines">Engines</a></li>
147
148
  <li class="grp">Consumers</li>
@@ -160,7 +161,7 @@
160
161
  <h1>An effects schema, and the API around it</h1>
161
162
  <p class="lede">A small runtime package for concurrent effects. Each effect runs on its own clock with its own state and weight, and a mix folds them into one value per subject per frame by rules that belong to the channel, not the effect.</p>
162
163
  <p class="meta"><b>For:</b> whoever builds <code>blits</code>, and whoever ports klieg, wod, sherpa or magicsmoke onto it. <b>Answers:</b> what the vocabulary is, what an author writes, what a host calls, and where a second engine would plug in. The package implements this page; where the two differ, the code in <code>src/</code> is the one that runs.</p>
163
- <span class="status">Built · v0.6.0 · design 2026-09-15, package 2026-09-28</span>
164
+ <span class="status">Built · v0.7.0 · design 2026-09-15, package 2026-09-28</span>
164
165
  </header>
165
166
 
166
167
  <h2 id="picture">In one picture</h2>
@@ -222,8 +223,8 @@
222
223
  <h2 id="vocab">Vocabulary</h2>
223
224
  <p>Every other name in the package is built from these.</p>
224
225
  <dl>
225
- <dt>Delta</dt><dd>A partial record of channel values. A patch writes only the channels it drives; an absent channel means no influence.</dd>
226
- <dt>Channel</dt><dd>One field of a delta, with its own arithmetic: how two influences merge, what rest is if there is one, how to scale toward rest, how to lerp.</dd>
226
+ <dt>Delta</dt><dd>A partial record of channel values. A patch writes only the channels it drives; an absent channel means no contribution.</dd>
227
+ <dt>Channel</dt><dd>One field of a delta, with its own arithmetic: how two contributions merge, what rest is if there is one, how to scale toward rest, how to lerp.</dd>
227
228
  <dt>Kit</dt><dd>The channel set for one kind of delta. klieg's pose, klieg's part, wod's presence are each a kit.</dd>
228
229
  <dt>Subject</dt><dd>What is being driven: a letter, a part, a wedge, a page, or a unit value for a system with no subject dimension such as klieg's lighting.</dd>
229
230
  <dt>Patch</dt><dd>A pure function of phase and a subject that returns a delta. Optionally stateful. This is klieg's word and it keeps it.</dd>
@@ -233,7 +234,7 @@
233
234
  <dt>Handle</dt><dd>What <code>cue</code> returns: the live controls on one voice, and its completion.</dd>
234
235
  <dt>Engine</dt><dd>The implementation behind a mix. One ships. The seam is the mix's own interface.</dd>
235
236
  <dt>Pose</dt><dd>What one subject looks like at one frame, once every voice has been folded in. Where a delta is partial, a pose is complete: every channel of the kit is filled. This is what a host writes.</dd>
236
- <dt>Influence</dt><dd>What one voice does to one subject in one frame, once its weight is applied. Not a share of the whole: two voices each contributing <code>gain: 0.1</code> leave the subject at 0.01.</dd>
237
+ <dt>Contribution</dt><dd>What one voice does to one subject in one frame, once its weight is applied. Not a share of the whole: two voices each contributing <code>gain: 0.1</code> leave the subject at 0.01.</dd>
237
238
  <dt>Duration</dt><dd>How long one pass of a patch lasts, in milliseconds, whether or not the voice loops. 0 means the patch has no passes.</dd>
238
239
  <dt>Pass</dt><dd>One run through a patch, phase 0 to 1.</dd>
239
240
  <dt>Period</dt><dd>The duration a looping voice repeats. <code>Patch.period</code> is a deprecated alias for <code>duration</code>.</dd>
@@ -247,6 +248,7 @@
247
248
  <p>Informal terms are used in design work and in the code but appear nowhere in the public API, so a consumer never has to learn them.</p>
248
249
  <dl>
249
250
  <dt>Lane</dt><dd>A channel the mix computes for every subject at once, in flat arrays indexed by a number it gives each subject, rather than subject by subject. A channel runs as a lane only while every voice writing it qualifies; otherwise it takes the general path, which folds subject by subject as the mix always has. Lanes are on by default and give the same pose; <code>MixOptions.lanes: false</code> turns them off.</dd>
251
+ <dt>Reach</dt><dd>Whether a voice acts on a subject at all. A cue's <code>subjects</code> or <code>target</code> decides it, once, the first time the mix sees the subject, and the answer is kept; a subject the voice does not reach is never asked about again. Reach is fixed for the life of the voice: a falloff that varies by frame, such as a glow around the pointer, is a weight, and every reached subject pays for it.</dd>
250
252
  </dl>
251
253
 
252
254
  <h2 id="channels">Channels</h2>
@@ -259,9 +261,9 @@
259
261
  <span class="cm">/** The range a numeric value means anything in. The mix clamps the folded value to it, so stacked voices and a
260
262
  retarget that carries speed stop flat at the bound. Part of the kind: mul({ bounds: [0, 1] }) is 'mul[0, 1]'. */</span>
261
263
  bounds?: readonly [min: number, max: number];
262
- <span class="cm">/** Identity. Absent means the channel has none: `color` replaces rather than contributes. */</span>
264
+ <span class="cm">/** Identity. Absent means the channel has none: it replaces rather than contributes. */</span>
263
265
  rest?: V;
264
- <span class="cm">/** Fold two influences into one. */</span>
266
+ <span class="cm">/** Fold two contributions into one. */</span>
265
267
  merge(a: V, b: V): V;
266
268
  <span class="cm">/** Fade toward `rest` by weight 0..1. Required when `rest` is set; absent otherwise. */</span>
267
269
  scale?(v: V, w: number): V;
@@ -277,11 +279,15 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
277
279
  <tr><td><code>sum()</code></td><td class="num">0</td><td>a + b</td><td>v · w</td><td>linear</td><td>position axes, rotation, crawl, yaw, pitch, offset</td></tr>
278
280
  <tr><td><code>mul()</code></td><td class="num">1</td><td>a · b</td><td>1 + (v − 1) · w</td><td>linear</td><td>gain, scale, opacity, hold</td></tr>
279
281
  <tr><td><code>max()</code></td><td class="num">0</td><td>max(a, b)</td><td>v · w</td><td>linear</td><td>dark, aperture</td></tr>
280
- <tr><td><code>last()</code></td><td>none</td><td>b</td><td>—</td><td>caller's</td><td>color, offsetAngle</td></tr>
282
+ <tr><td><code>last()</code></td><td>none</td><td>b</td><td>—</td><td>caller's</td><td>offsetAngle</td></tr>
281
283
  <tr><td><code>vec(n, ch)</code></td><td>per axis</td><td>per axis</td><td>per axis</td><td>per axis</td><td>Vec3 position, premultiplied light</td></tr>
284
+ <tr><td><code>color()</code></td><td>[0, 0, 0, 0]</td><td>per axis</td><td>[L, a, b, 1] · w</td><td>OKLab or OKLCH</td><td>tints, glows, lighting color</td></tr>
285
+ <tr><td><code>color(last())</code></td><td>none</td><td>b</td><td>—</td><td>OKLab or OKLCH</td><td>a color one voice owns at a time</td></tr>
282
286
  </table></div>
283
287
 
284
- <p>A channel with no rest cannot fade. The mix handles that in one way: below a weight threshold the influence is dropped, above it the value passes through whole. That is the rule hinge's blend mode already applies to color, and it is stated here once so no consumer rediscovers it. <b>The threshold is a band, not an edge</b> &mdash; it drops at <code>off</code> and passes at <code>on</code>, and between the two it holds whatever it last did. One edge chatters: a weight resting on it is carried back and forth by frame noise, and every crossing drops or restores the whole influence. Retargeting is the way a rest-less channel does move smoothly, since a lerp from a sampled value needs no identity.</p>
288
+ <p><b>Color is a value; how it merges is a parameter.</b> A <code>color</code> value is OKLab <code>[L, a, b, coverage]</code>, and <code>oklab(0xrrggbb)</code> makes one at full coverage. <code>color()</code> averages: voices stack as a premultiplied sum, so the pose is <code>[Σw·L, Σw·a, Σw·b, Σw]</code>, which lanes and <code>pull</code> fold as any <code>vec(4, sum())</code>. blits knows no subject's own color, so the host lays the result over its base at write: <code>toHex(pose, under)</code> divides the coverage out, caps it at 1, mixes over <code>under</code> in OKLab and clips to sRGB per channel, and <code>css(pose)</code> writes an <code>oklab()</code> with the coverage as alpha. <code>color(last())</code> replaces, the last voice to pass winning behind the band. <code>mul</code> and <code>max</code> per OKLab axis mean nothing anyone asks for, so <code>color</code> refuses them; a tint, a hue shift, chroma and lightness are channels of their own. OKLab is the store because voices average per axis there, and OKLCH's hue wraps. How two colors interpolate is the channel's choice, <code>{ lerp: 'oklab' | 'oklch' }</code>: OKLab, the default, crosses opposite hues through a pale gray, and OKLCH goes round the hue as <code>mixHex</code> does, at the cost of leaving the lanes.</p>
289
+
290
+ <p>A channel with no rest cannot fade. The mix handles that in one way: below a weight threshold the contribution is dropped, above it the value passes through whole. That is the rule hinge's blend mode already applies to color, and it is stated here once so no consumer rediscovers it. <b>The threshold is a band, not an edge</b> &mdash; it drops at <code>off</code> and passes at <code>on</code>, and between the two it holds whatever it last did. One edge chatters: a weight resting on it is carried back and forth by frame noise, and every crossing drops or restores the whole contribution. Retargeting is the way a rest-less channel does move smoothly, since a lerp from a sampled value needs no identity.</p>
285
291
 
286
292
  <p>The stock set is not the limit: a consumer that needs arithmetic it lacks writes its own <code>Channel</code>, and nothing in the mix knows the difference. sherpa's <code>transform</code> is one, composed by the host from whatever it puts in a matrix.</p>
287
293
 
@@ -289,7 +295,7 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
289
295
  <pre><code>const PART = kit&lt;PartOffset&gt;({
290
296
  gain: mul(), scale: mul(), dark: max(), crawl: sum(),
291
297
  position: vec(3, sum()), rotation: vec(3, sum()),
292
- light: vec(3, sum()), color: hex(),
298
+ light: vec(3, sum()), color: color(last(), { lerp: 'oklch' }),
293
299
  });</code></pre>
294
300
 
295
301
  <h2 id="patches">Patches</h2>
@@ -311,7 +317,8 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
311
317
  <span class="cm">/** The fields of `setting.host` it reads. `cue` refuses a mix whose host lacks one. */</span>
312
318
  readonly reads?: readonly string[];
313
319
  <span class="cm">/** `phase` is 0..1 across one pass, wrapping. Not asked for a subject its voice gives no weight, unless the
314
- voice is fading to rest; `step` still runs. */</span>
320
+ voice is fading to rest; `step` still runs. A `keys` patch's stops are not read there either, unless its
321
+ voice is in a locus. */</span>
315
322
  at(phase: number, subject: I, setting: Setting&lt;S, H&gt;): Partial&lt;O&gt;;
316
323
 
317
324
  <span class="cm">/** Per-subject state, created on the first frame this patch sees a subject. */</span>
@@ -364,9 +371,9 @@ interface Setting&lt;S = void, H = unknown&gt; {
364
371
 
365
372
  <p><code>writes</code> is the patch's half of the contract the rest of the package already states: a channel kit declares what a consumer has, an engine declares its <code>runs</code> set, and both are checked at <code>cue</code>. Without it the only way to learn what a patch touches is to call it, per subject, per frame. With it the mix refuses a patch contributing a channel the kit lacks, resolves each voice's channels once at <code>cue</code> instead of walking the whole kit per probe, and can answer what a voice affects without probing it. A <code>keys</code> patch derives it from its stops.</p>
366
373
 
367
- <p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be &mdash; CSS&rsquo;s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read &mdash; or as a <code>(u: number) =&gt; number</code>, which only the CPU engine runs, and <code>Keyframe&lt;O&gt;</code> is one stop, <code>{ at: number; delta: Partial&lt;O&gt;; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>mixHex</code> is the <code>hex</code> channel's <code>lerp</code>, which blends in OKLCH with hue taken the short way round, so a crossfade between hues keeps its lightness and saturation; a mix outside sRGB&rsquo;s gamut is clipped per channel. <code>hex({ space: 'srgb' })</code> blends red, green and blue each on its own instead, as klieg&rsquo;s light channel does.</p>
374
+ <p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be &mdash; CSS&rsquo;s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read &mdash; or as a <code>(u: number) =&gt; number</code>, which only the CPU engine runs, and <code>Keyframe&lt;O&gt;</code> is one stop, <code>{ at: number; delta: Partial&lt;O&gt;; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>hex</code>, deprecated for <code>color(last(), { lerp: 'oklch' })</code>, holds 0xrrggbb, and <code>mixHex</code> is its <code>lerp</code>, which blends in OKLCH with hue taken the short way round, so a crossfade between hues keeps its lightness and saturation; a mix outside sRGB&rsquo;s gamut is clipped per channel. <code>hex({ space: 'srgb' })</code> blends red, green and blue each on its own instead, as klieg&rsquo;s light channel does.</p>
368
375
 
369
- <p>Wrappers stay wrappers. klieg's <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>hinge</code> and <code>partition</code> take a patch and return a patch, and none of them changes except to pass <code>writes</code> through &mdash; a wrapper reshapes one influence and never adds a channel to it. Composition at the patch level is for reshaping one influence; composition at the mix level is for running several. The two do not merge.</p>
376
+ <p>Wrappers stay wrappers. klieg's <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>hinge</code> and <code>partition</code> take a patch and return a patch, and none of them changes except to pass <code>writes</code> through &mdash; a wrapper reshapes one contribution and never adds a channel to it. Composition at the patch level is for reshaping one contribution; composition at the mix level is for running several. The two do not merge.</p>
370
377
 
371
378
  <h2 id="voices">Voices</h2>
372
379
  <p>A voice is a patch given a clock and a volume knob. This is the unit that answers "independent state and timeframe": two voices of the same patch are two clocks, two states, two weights.</p>
@@ -401,7 +408,7 @@ interface Setting&lt;S = void, H = unknown&gt; {
401
408
  weight?: number | Signal&lt;I, H&gt;;
402
409
  <span class="cm">/** Ramp in and out, ms. Out applies on `fade()` and when a finite loop ends. */</span>
403
410
  fade?: { in?: number; out?: number; ease?: Easing };
404
- <span class="cm">/** Voices sharing a locus are alternatives: the mix folds them into one influence through each
411
+ <span class="cm">/** Voices sharing a locus are alternatives: the mix folds them into one contribution through each
405
412
  channel's `lerp` before anything else sees them. A crossfade between two behaviors, not what
406
413
  klieg's phases are: those layer. */</span>
407
414
  locus?: string;
@@ -454,13 +461,15 @@ interface Handle&lt;I&gt; {
454
461
  readonly played: Promise&lt;boolean&gt;;
455
462
  }</code></pre>
456
463
 
464
+ <p><b>Fading at a time.</b> <code>fade({ at, over })</code> with <code>at</code> a mix time begins the fade exactly there rather than at the call: ahead, the voice plays untouched until then, its <code>out</code> and <code>end</code> marks fixed from the call, so <code>marks</code>, <code>project</code> and anchors see them; at or behind now, the fade began then and is partway. A <code>rise</code> before it begins takes it back with nothing changed. It wins over an anchored <code>out</code>. Built 2026-10-07 for astv&rsquo;s rosters, whose departures begin exactly when an arrival ends; <code>at: 'rest'</code> keeps its meaning.</p>
465
+
457
466
  <p><b>Fading one subject.</b> <code>fade({ subject, over })</code> ramps one subject out of a voice and leaves the voice playing for every other subject; <code>over: 0</code> takes it out at once. When the ramp ends the voice forgets the subject &mdash; its record, its lane position and a motion patch&rsquo;s state for it &mdash; and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch&rsquo;s <code>to</code> brings it back, met afresh: <code>from</code>, <code>target</code> and <code>stagger</code> are asked again. Any other voice reaches it again only once <code>drop</code> forgets the subject. This is what lets a host that starts many short animations, one per node, run them as one voice on one lane instead of a voice per call, while still cancelling a node or handing it to a different patch.</p>
458
467
 
459
468
  <p><b>Freezing.</b> A voice shows nothing outside its passes unless it freezes, the name SMIL gives the same thing (<code>fill="freeze"</code>). <code>freeze: 'before'</code> shows a subject the voice&rsquo;s first frame from the cue until the subject starts: while the voice is pending, and while the subject waits out its <code>stagger</code>. <code>freeze: 'after'</code> shows the last frame of the last pass once a finite loop has played out, and the voice stays until <code>fade()</code> takes it out, ramping from that frame. <code>'both'</code> does both. wod asked for it to replace WAAPI&rsquo;s <code>fill</code>: a spin lands and freezes on its angle, and staggered wedges wait on their first frame rather than popping in.</p>
460
469
  <ul>
461
470
  <li><b>A frozen subject&rsquo;s clock stands still.</b> It reads phase 0 of pass 0 before, and phase 1 of the last pass after, with <code>elapsed</code> pinned to that edge. <code>at</code> is asked for that frame. <code>step</code> does not run while a subject waits, so a stateful patch starts playing from the state it was cued with; after, it runs once more to where the last pass ended and then no more, so the frozen frame does not depend on when the last frame before it fell. A voice freezing before stays <code>pending</code> until it starts, and <code>weightOf</code> reports the weight it gives a waiting subject.</li>
462
471
  <li><b><code>fade.in</code> counts from the first frame the voice shows</b>, so with <code>'before'</code> it ramps in on the frozen frame rather than at full weight, and does not dip when play begins.</li>
463
- <li><b>A voice freezing after reaches <code>frozen</code>, not <code>fading</code>,</b> at the moment a voice without a freeze would begin its fade out. <code>played</code> resolves true then. Its <code>out</code> and <code>end</code> marks are unfixed until it is faded, like those of a voice that loops for good, so <code>{ after: 'spin' }</code> waits for the fade. A host that wants to start something when the passes run out awaits <code>played</code>.</li>
472
+ <li><b>A voice freezing after reaches <code>frozen</code>, not <code>fading</code>,</b> at the moment a voice without a freeze would begin its fade out. <code>played</code> resolves true then. Its <code>out</code> and <code>end</code> marks are unfixed until it is faded, like those of a voice that loops for good, so <code>{ after: 'spin' }</code> waits for the fade. Something that should start when the passes run out anchors to its <code>coast</code> mark, or awaits <code>played</code>.</li>
464
473
  <li><b>The host owns every frozen voice.</b> One cued per spin and never faded stays in the mix for good, one more voice folded every frame. The next voice&rsquo;s cue is the time to fade the last, at once or <code>at: 'rest'</code>.</li>
465
474
  <li><b>Where it changes nothing.</b> A voice that loops for good never runs out of passes, so only <code>'before'</code> applies to it. A motion patch (<code>spring</code>, <code>glide</code>, <code>tween</code>) already keeps its target and has no passes, so <code>freeze</code> does nothing to it. Lanes give the same pose with it as without them, as for everything else.</li>
466
475
  <li><b>What it costs.</b> A voice freezing before runs on the general path while it is pending, which takes the channels it writes off their lanes until it starts; one that is already live, waiting only on a subject&rsquo;s stagger, stays on its lane. A mix with no freeze reads no slower (<code>keys</code> and <code>fn</code> rows of <code>bench/frame.mjs</code>, three passes alternated against <code>main</code> on a laptop, 2026-10-03).</li>
@@ -479,14 +488,17 @@ interface MixOptions&lt;H = unknown&gt; {
479
488
  reduce?: boolean | (() =&gt; boolean);
480
489
  <span class="cm">/** Merged into every `setting.host`. `H` types it for every patch and signal the mix cues, so none needs a cast. */</span>
481
490
  host?: H;
482
- <span class="cm">/** The band a rest-less channel's influence switches across. Default 0.4 / 0.6, a placeholder. */</span>
491
+ <span class="cm">/** The band a rest-less channel's contribution switches across. Default 0.4 / 0.6, a placeholder. */</span>
483
492
  band?: { on: number; off: number };
484
493
  <span class="cm">/** The most `dt` a step or signal is handed, however long the gap. Off by default. */</span>
485
494
  maxDt?: number;
486
495
  <span class="cm">/** Run every `step` once per interval of this many ms instead of once by the frame's gap. Off by default. */</span>
487
496
  stepMs?: number;
488
497
  <span class="cm">/** How far back `project` may read, and how often a stateful voice's state is copied on the way. Off by default. */</span>
489
- history?: { ms: number; every?: number; inputs?: boolean };
498
+ history?: { ms: number; every?: number; inputs?: boolean; tape?: TapeMaker; store?: HistoryStore;
499
+ revive?(d: Described): VoiceSpec };
500
+ <span class="cm">/** A subject's key for a history `store`, where subjects are objects. */</span>
501
+ keyOf?(subject: I): string | number;
490
502
  <span class="cm">/** Whether a channel may run as a lane. On by default; the pose is the same either way, apart from
491
503
  a stateless patch's `send` from `at` and a patch that starts keeping state partway through. */</span>
492
504
  lanes?: boolean;
@@ -504,7 +516,7 @@ interface Mix&lt;I, O, H = unknown&gt; {
504
516
 
505
517
  <span class="cm">/** The host reports the clock, once a frame. Nothing advances at the call: it publishes the reading, and
506
518
  each subject catches up by the whole gap the first time it is probed afterward. The same timestamp twice
507
- changes nothing, and the reading only ever goes forward. */</span>
519
+ changes nothing, and an earlier one throws short of a `rebase`: `seek` moves the mix. */</span>
508
520
  sync(timestamp: number): void;
509
521
  <span class="cm">/** The time between the last sync and the next one does not count. Called when a hidden tab comes back. */</span>
510
522
  rebase(): void;
@@ -519,8 +531,17 @@ interface Mix&lt;I, O, H = unknown&gt; {
519
531
  pull(subjects: Iterable&lt;I&gt;, into: { [K in numeric channels of O]?: Float64Array }): void;
520
532
  <span class="cm">/** Every channel at rest for this subject this frame, so a host can skip the write. */</span>
521
533
  atRest(subject: I): boolean;
522
- <span class="cm">/** The mix read at another timestamp without moving it: ahead plays what is cued forward, behind needs `history`. */</span>
523
- project(timestamp: number): Projection&lt;I, O&gt;;
534
+ <span class="cm">/** The mix clock at the last sync or seek, which `seek` and `project` take; NaN before the first sync. */</span>
535
+ readonly now: number;
536
+ <span class="cm">/** The mix read at another mix time without moving it: ahead plays what is cued forward, behind needs `history`. */</span>
537
+ project(time: number): Projection&lt;I, O&gt;;
538
+ <span class="cm">/** Moves the mix to a mix time under `history` with a `tape`, and plays on from there, making the host's
539
+ recorded calls again as it passes them. See Score. */</span>
540
+ seek(time: number): void;
541
+ <span class="cm">/** Loads from the history `store` what a seek or read to `time` needs, where that is past memory. */</span>
542
+ prepare(time: number): Promise&lt;void&gt;;
543
+ <span class="cm">/** Where the mix records the host's calls: weasel-history's `History`, made by `history.tape`. */</span>
544
+ readonly tape: Tape | undefined;
524
545
  <span class="cm">/** Puts a named mark on a score, now or at a timestamp, for anchors to target. */</span>
525
546
  announce(name: string, opts?: { at?: number; score?: string; tags?: readonly string[] }): void;
526
547
  <span class="cm">/** Every mark the plan knows between two timestamps, earliest first. */</span>
@@ -577,7 +598,7 @@ function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;;
577
598
  <li><b>The mix is pull-based on a host clock.</b> The host owns the frame loop and calls <code>sync(timestamp)</code> with milliseconds on any monotonic clock it likes. klieg passes its <code>Clock.now()</code>; wod passes <code>performance.now()</code>; a test passes integers. A host with no loop of its own can take <code>ticker()</code>'s: it syncs every mix it was given on each animation frame, or on a timer where there are none, as in a worker, sleeps while every mix is <code>inert</code>, wakes on <code>onWake</code>, and caps the rate at <code>fps</code> when asked.</li>
578
599
  <li><b>Everything in a frame reads one timestamp.</b> A second <code>sync</code> with the same value is a no-op, and every <code>probe</code> between syncs answers from the same frame. This is what makes state advance once however many probes a wrapper makes.</li>
579
600
  <li><b>A voice's clock is <code>(timestamp − start) · rate</code>.</b> That reading is <code>setting.elapsed</code>, and its position within the current pass, <code>(elapsed mod duration) / duration</code>, is the <code>phase</code> the patch is handed first. Both reach the patch. A rate change rebases so <code>elapsed</code> is continuous.</li>
580
- <li><b>The mix has a rate of its own,</b> <code>mix.rate</code> and <code>mix.ramp(rate, over)</code>, shaped like a handle's and multiplied into every voice's: 0 pauses the whole mix, and a negative rate throws, since the mix only goes forward. It runs the mix clock: mix time is the host's timestamp, less what <code>rebase</code> took out, integrated at the mix's rate, and each voice integrates its own rate over mix time, so a ramp on both multiplies and no position jumps. Everything a voice owns runs on mix time and slows with it, which is what a pause has to mean for a fade caught halfway. A timestamp the host gives or reads stays on the host's clock, so a beat announced from an audio clock stays on the beat when the mix slows, and a start ahead of a paused mix waits for the host's clock to reach it. The mix's own ramp runs over host ms, since a ramp over its own slowing time would never arrive. A change made while paused lands at the next sync as always, and a weight signal is still asked at every probe, so one reading input follows it. At rate 0 the mix is <code>inert</code> once every voice not done has started, at a plain weight, with no anchor. Under <code>history</code> every rate change is kept, and a read back finds mix time through them as it finds a voice's clock through its handle's changes; without it a timestamp from before the last change converts at the rate in force, as one from before a <code>rebase</code> converts at the offset in force. weasel also scales a key and a handle; a mix leaves those levels to tags and to the host.
601
+ <li><b>The mix has a rate of its own,</b> <code>mix.rate</code> and <code>mix.ramp(rate, over)</code>, shaped like a handle's and multiplied into every voice's: 0 pauses the whole mix, and a negative rate throws: the mix plays only forward, and <code>seek</code> moves it back. It runs the mix clock: mix time is the host's timestamp, less what <code>rebase</code> took out, integrated at the mix's rate, and each voice integrates its own rate over mix time, so a ramp on both multiplies and no position jumps. Everything a voice owns runs on mix time and slows with it, which is what a pause has to mean for a fade caught halfway. A timestamp the host gives or reads stays on the host's clock, so a beat announced from an audio clock stays on the beat when the mix slows, and a start ahead of a paused mix waits for the host's clock to reach it. The mix's own ramp runs over host ms, since a ramp over its own slowing time would never arrive. A change made while paused lands at the next sync as always, and a weight signal is still asked at every probe, so one reading input follows it. At rate 0 the mix is <code>inert</code> once every voice not done has started, at a plain weight, with no anchor. Under <code>history</code> every rate change is kept, and a read back finds mix time through them as it finds a voice's clock through its handle's changes; without it a timestamp from before the last change converts at the rate in force, as one from before a <code>rebase</code> converts at the offset in force. weasel also scales a key and a handle; a mix leaves those levels to tags and to the host.
581
602
  <div class="tbl"><table>
582
603
  <tr><th>runs on mix time, at the mix's rate</th><th>stays on the host's clock</th></tr>
583
604
  <tr><td>voice clocks, fades, a handle's <code>ramp</code>, <code>stagger</code>, an anchor's <code>by</code>, <code>stepMs</code>, <code>maxDt</code>, <code>history.ms</code>, every <code>dt</code>, <code>setting.timestamp</code> and what <code>drain</code> stamps</td><td><code>sync</code>, a <code>start</code>, an anchor given as a number, <code>announce</code>'s <code>at</code>, what <code>marks</code> reports, <code>project</code>'s timestamp, the mix's own <code>ramp</code>, and <code>book</code>'s <code>ahead</code>, <code>late</code> and <code>lateBy</code></td></tr>
@@ -628,11 +649,11 @@ mix.book({
628
649
  <tr><td>Retarget on interruption</td><td><code>from: 'current'</code> on a <code>keys</code> voice takes stop 0 from the subject's pose in the frame before it first contributes &mdash; every other voice folded in, itself excluded &mdash; and lerps from there through the channel's <code>lerp</code>. It also leaves at the velocity the subject had, taken from its last two probed poses: the first segment gains <code>c·u(1−u)²</code>, which is zero at both ends and flat at the end, so the segment's own curve still lands on its stop while the start matches the subject's speed. A subject that was still leaves at rest. With fewer than two poses on record, as on a mix whose host probes into its own object before any retarget is cued, it starts on the segment's own curve.</td><td>wod's base-sampling. New to klieg: a regroup could tween from mid-float instead of carrying it away.</td></tr>
629
650
  </table></div>
630
651
 
631
- <p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one influence first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. That one influence then contributes at weight <code>min(1, &Sigma;w)</code> and is scaled toward rest like any other. On a <code>sum</code> channel this is exactly <code>w&#8321;a + w&#8322;b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 &mdash; which is what one alternative means &mdash; where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. Cue order is visible only to a <code>lerp</code> that is not linear, such as a hue taking the short way round.</p>
652
+ <p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. That one contribution then contributes at weight <code>min(1, &Sigma;w)</code> and is scaled toward rest like any other. On a <code>sum</code> channel this is exactly <code>w&#8321;a + w&#8322;b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 &mdash; which is what one alternative means &mdash; where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. Cue order is visible only to a <code>lerp</code> that is not linear, such as a hue taking the short way round.</p>
632
653
 
633
654
  <div class="callout">
634
655
  <div class="eyebrow">the one rule</div>
635
- <p>A weight scales an influence toward its channel's rest. A lerp moves between two values. Every blend is one or the other, chosen by the channel. A consumer that needs a third arithmetic adds a channel, not a mix option.</p>
656
+ <p>A weight scales a contribution toward its channel's rest. A lerp moves between two values. Every blend is one or the other, chosen by the channel. A consumer that needs a third arithmetic adds a channel, not a mix option.</p>
636
657
  </div>
637
658
 
638
659
  <h2 id="motion">Springs, glides and tweens</h2>
@@ -658,14 +679,16 @@ t.to(node, 40); <span class="cm">// from wherever it is, over a ful
658
679
  </ul>
659
680
 
660
681
  <h2 id="score">Score</h2>
661
- <p>The score is the plan: when each voice plays, for how long, and relative to which other voice. It never says what a voice contributes, which stays in the patch and the channels, and it never reads a channel's value. A voice has four <b>marks</b> on the mix clock: <code>start</code> (its fade in begins), <code>in</code> (full weight), <code>out</code> (its fade out begins) and <code>end</code> (gone).</p>
662
- <pre><code>type Mark = 'start' | 'in' | 'out' | 'end';
682
+ <p>The score is the plan: when each voice plays, for how long, and relative to which other voice. It never says what a voice contributes, which stays in the patch and the channels, and it never reads a channel's value. A voice has five <b>marks</b> on the mix clock: <code>start</code> (its fade in begins), <code>in</code> (full weight), <code>coast</code> (its last pass ends), <code>out</code> (its fade out begins) and <code>end</code> (gone). A voice coasts when <code>played</code> resolves true, for its latest-staggered subject: with its <code>out</code> where it does not freeze, and where it starts showing its last frame where it does, so <code>{ of: 'run', mark: 'coast' }</code> waits on a frozen voice that <code>{ after: 'run' }</code> would wait on until it is faded. An owner coasts with its last child, a span where its fit ends. A voice that loops for good, or fades before its last pass ends, never coasts. Named 2026-10-07.</p>
683
+ <pre><code>type Mark = 'start' | 'in' | 'coast' | 'out' | 'end';
663
684
  interface Placement { start?: number | Anchor; in?: number | Anchor; out?: number | Anchor; end?: number | Anchor }
664
685
  type Anchor =
665
686
  | { of: string | Query; mark: Mark; by?: number }
666
687
  | { after: string | Query; by?: number } <span class="cm">// its end</span>
667
688
  | { with: string | Query; by?: number } <span class="cm">// its start</span>
668
- | { before: string | Query; by?: number }; <span class="cm">// its start, less by</span>
689
+ | { before: string | Query; by?: number } <span class="cm">// its start, less by</span>
690
+ | { all: readonly Anchor[] } <span class="cm">// the latest of them</span>
691
+ | { any: readonly Anchor[] }; <span class="cm">// the earliest of them</span>
669
692
  interface Query { score?: string; name?: string; tag?: string; writes?: string; resolver?: 'last' | 'first' | 'next' | 'earliest' | 'latest' }
670
693
 
671
694
  interface Projection&lt;I, O&gt; {
@@ -677,6 +700,7 @@ interface Projection&lt;I, O&gt; {
677
700
  <li><b>Placing.</b> A cue anchors at most one of <code>start</code> and <code>in</code>, and one of <code>out</code> and <code>end</code>, to a timestamp or to another voice's mark: <code>anchor: { in: 12_400 }</code> is fully in by 12.4&nbsp;s, <code>anchor: { start: { after: 'intro' } }</code> starts once <code>intro</code> is gone. Anchoring <code>in</code> or <code>end</code> counts the voice's own fade. A query selects by <code>name</code>, <code>tag</code> or a channel the voice <code>writes</code>, and its resolver picks among several, <code>last</code> cued by default.</li>
678
701
  <li><b>Several scores.</b> Each source keeps its own plan: a voice's <code>score</code> names it, and the mix plays every score it holds at once, the way a DAW plays every track. Names are namespaced by score, so a bare name in an anchor means a voice in the asker's score and a query names <code>score</code> to reach into another. A voice with none is in the mix's one unnamed score.</li>
679
702
  <li><b>Waiting and following.</b> An anchor with no answer &mdash; a name not cued, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh only while the mix's history reaches it. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
703
+ <li id="joins"><b>Joins.</b> <code>{ all: [...] }</code> waits on several anchors and answers the latest of them, once every one has answered: <code>{ start: { all: [{ after: 'change-3' }, { of: 'orb-7', mark: 'end' }] } }</code> starts once both are done. <code>{ any: [...] }</code> answers the earliest, once one of them has passed or every one has answered, since a member still unknown might yet answer earlier. A join holds only the anchors listed in it, each with its own <code>by</code>, and nests. A member whose target has left keeps the time it last gave, as a lone anchor does, so one target finishing early no longer leaves the whole join unanswered. Added 2026-10-07, named after <code>Promise.all</code> and <code>Promise.any</code>, which behave the same way.</li>
680
704
  <li><b>Announced marks.</b> The host puts a named mark on a score with <code>mix.announce('reply')</code>, or <code>announce('beat', { at })</code> for one it knows ahead, and anchors target it as they target a voice's marks: a voice placed <code>{ with: 'reply' }</code> waits pending until the reply is announced. A mark announced ahead is seen by a read ahead; one not yet announced makes a read ahead say <code>held</code> for whatever waits on it, and a read back sees only what had been announced by then. An announced mark stays while it is ahead or while history reaches it. Decided 2026-10-01: a <b>mark</b> is any named time an anchor can hang from, a voice's own four or one the host announced, and an <b>event</b> is only what a patch <code>send</code>s to the host. What fires a mark on a condition, such as a channel crossing a value, stays the host's: blits does not read values.</li>
681
705
  <li id="owners"><b>Owners.</b> <code>mix.owns(spec)</code> cues an <b>owner</b>, a voice with no patch, and a voice cued with <code>owner: handle</code> plays under it, so a group can be placed, timed and faded as one. The mix's own clock is the outermost owner, and an owner is a link in the same chain: owners nest.
682
706
  <pre><code>const intro = mix.owns({ anchor: { start: { after: 'title' } }, fade: { out: 300 }, name: 'intro' });
@@ -698,12 +722,75 @@ intro.fade(); <span class="cm">// one envelope over both</span></code></pr
698
722
  <tr><td><code>loop</code></td><td>Refused at <code>owns</code>: a pass would have to restart stateful children (decided 2026-10-04).</td></tr>
699
723
  </table></div>
700
724
  What stays on the mix clock is what a voice's own rate leaves there: a child's fades, its <code>dt</code> and <code>setting.timestamp</code>, as a voice at half rate still fades over its <code>fade.out</code> in mix ms. An owner's fade in counts from its start. <code>mix.voices()</code> lists owners and children alike, and <code>handle.owner</code> names a child's owner. <code>marks</code> maps a child's marks to the host clock through every owner's clock, and lists one only while every clock above it is fixed. A hit a child books sits on that same chain, so an owner's rate or seek retracts it and books it again. Under <code>history</code> an owner's control changes are kept like a voice's, so a read back or ahead composes them. On lanes a child's clock is read through its owners and their weight folded into its fade, the same arithmetic the general path takes; a child whose owner weighs by a signal runs on the general path. A mix with no owner pays nothing for them.</li>
725
+ <li id="spans"><b>Spans.</b> <code>mix.span(spec)</code> cues an owner that lays out the voices it holds and fits them into a budget, so a host says what it wants once instead of scheduling every child. astv's phase was the case: meant to last a fixed time, its text changes ran back to back at their own lengths and pushed it to 15 times that.
726
+ <pre><code>const phase = mix.span({ name: 'phase', duration: 2000, order: 'queue' });
727
+ mix.cue({ patch: writeChange(text), owner: phase, faster: 4, ballast: true });
728
+ mix.cue({ patch: next, anchor: { start: { after: 'phase' } } }); <span class="cm">// at 2000, or wherever the children end, if later</span>
729
+ phase.result; <span class="cm">// { budget, length, over, skipped, fell }</span></code></pre>
730
+ <div class="tbl"><table>
731
+ <tr><th>On the span</th><th>Means</th></tr>
732
+ <tr><td><code>duration</code></td><td>Its budget, ms on its own clock. Without one the span only keeps its children's order, which makes it a sequencer for voices that arrive late.</td></tr>
733
+ <tr><td><code>order</code>, <code>share</code></td><td><code>queue</code> starts each child once the one before ends, <code>stagger</code> starts it <code>share</code> of the way through (0.5), <code>together</code> starts them all at once. Children are laid out in the order they were cued.</td></tr>
734
+ <tr><td><code>fit</code></td><td>A strategy, a plain function from the span's claim and its children's claims to a rate and a skip per child. It starts from the children already retimed: before any fit, the span moves every child's rate by one factor toward its budget, faster where they run past it and slower where they leave room, each as far as its <code>faster</code> or <code>slower</code> allows. Default <code>pipe(condense(), shed())</code>.</td></tr>
735
+ <tr><td><code>priority</code></td><td>How hard its length holds, <code>weak</code>, <code>strong</code> (default) or <code>required</code>: against its children here, and against its own span when it is held by one.</td></tr>
736
+ <tr><td><code>spill</code></td><td>What happens when the fit leaves the children too long for a span at least <code>strong</code>: <code>instant</code> (default) jumps every child weaker than the span to its end at once, so the budget holds; <code>overrun</code> lets them run long. A <code>weak</code> span runs long and does not count it as falling back.</td></tr>
737
+ </table></div>
738
+ <div class="tbl"><table>
739
+ <tr><th>On a child</th><th>Means</th></tr>
740
+ <tr><td><code>faster</code>, <code>slower</code></td><td>How many times faster or slower than its own rate it may play, which the span's retiming reads before its fit. Default 1: neither.</td></tr>
741
+ <tr><td><code>overlap</code></td><td>It may start before the one before it ends where the order would not have it.</td></tr>
742
+ <tr><td><code>ballast</code></td><td>It may jump to its end instead of playing. A span held by a span is never skipped.</td></tr>
743
+ <tr><td><code>priority</code></td><td>How hard its own length holds against the span's budget. Default <code>weak</code>.</td></tr>
744
+ </table></div>
745
+ <div class="tbl"><table>
746
+ <tr><th>Strategy</th><th>Does</th></tr>
747
+ <tr><td><code>condense()</code></td><td>Starts children allowing it sooner after the one before, toward all at once.</td></tr>
748
+ <tr><td><code>shed()</code></td><td>Jumps children allowing it to their end, latest first.</td></tr>
749
+ <tr><td><code>conclude()</code></td><td>Jumps every child to its end at one instant.</td></tr>
750
+ <tr><td><code>overrun({ cap })</code></td><td>Lets the span run up to <code>cap</code> times its budget, or without limit.</td></tr>
751
+ <tr><td><code>pipe(...fits)</code></td><td>Tries each in turn, handing the plan on, until the children fit.</td></tr>
752
+ <tr><td><code>lax({ cap })</code></td><td><code>pipe(condense(), shed(), overrun({ cap }))</code>: every way of giving way, running long only once the rest are spent.</td></tr>
753
+ </table></div>
754
+ A strategy of the host's own takes the same arguments; <code>layout</code> says where a plan puts each child and how long they take, and <code>plain</code> is the plan before any fit. A child's start and rate are the span's: it takes no <code>start</code> or anchored start, a rate the host writes on its handle lasts until the next fit, and a voice that loops for good or a plain owner is refused. The span fits again when a child joins and when one leaves, and after a seek. Only what is still to come moves: a playing child keeps its place and changes rate without a jump, as a handle's <code>rate</code> write does, and a pending one is placed. A fit sees only the children that have arrived, so late ones are squeezed into what is left. A skipped child lands on its final state at that moment and its <code>end</code> mark is there, but the events its patch would have sent on the way are not sent. A span with a budget stays until the budget has passed, though its children finish sooner, so a late child can still join, and its <code>end</code> mark is wherever its children end or its budget does, whichever is later. A span held by a span is fitted through its own rate. A fit is made on the live mix and logged as the children's control changes, so a read back, a seek and its replay all see it, and the tape records nothing for it. Designed and built 2026-10-07.</li>
701
755
  <li><b>What comes next.</b> <code>mix.marks(from, to)</code> lists every mark the plan has fixed in a range, with each voice's id, name and tags. One nothing has fixed is left out.</li>
702
756
  <li><b>Reading another time.</b> <code>mix.project(t)</code> copies what each subject holds and reads it at <code>t</code>, sending nothing and committing nothing, so the live mix plays on exactly as if it had not been read. Ahead, it plays what is cued forward in one step across the gap. Behind, it needs <code>history</code>: the mix keeps every voice's control changes with when they landed, the voices that have left, and a copy of each stateful record per subject every <code>every</code> ms, and a read back restores the nearest copy at or before <code>t</code> and steps forward. A subject it kept nothing for starts from its voice's start with fresh state. A patch's own <code>clone</code> copies state that <code>structuredClone</code> cannot. With <code>inputs</code>, history also keeps what each input signal on a voice's weight read per subject, and a copy of the host fields patches <code>reads</code>, each time they changed, and a read back uses those instead of asking the signal or the host. Where anchors fixed a voice's start or out after the time read, it reads back as it stood then.</li>
757
+ <li id="seek"><b>Seeking.</b> <code>mix.seek(t)</code> moves the live mix to mix time <code>t</code>, as it stood at the end of that frame, and it plays on from there. It needs <code>history</code> with a <code>tape</code>, and reaches back as far as a read back does. <code>history.tape</code> takes weasel-history's <code>createHistory</code> from <code>@weasel-js/history</code>, or anything of the <code>Tape</code> shape, and the mix records on it every call the host makes on the mix, its handles and its spring and glide patches: cues, weight, rate, seeks and fades, retargets and pushes, the mix's rate, marks, <code>mute</code> and <code>drop</code>, each at the mix time it was made. The tape lives as long as the mix and is never saved.
758
+ <ul>
759
+ <li><b>Back.</b> The mix restores itself from what history kept: each voice's controls, the voices and marks, and the mix's rate, as they stood at <code>t</code>. Voices that left after it are back on the handles the host holds, with <code>done</code> and <code>played</code> starting over where they had settled since. Voices cued after it wait as <code>pending</code> on theirs until the mix plays their cue again; on a branch that never does, they wait for good. Each stateful record is put back from the copy kept nearest before <code>t</code> the first time it is touched, and steps once to where it is next probed, exact under <code>stepMs</code>. A subject faded out of a voice or dropped after <code>t</code> comes back with the record it had, a motion patch&rsquo;s state for it included, which history keeps for it from when it left; the lanes qualify afresh. Recorded input and host fields after <code>t</code> are let go, so signals and host fields read live again, and <code>assess</code> says <code>held</code> for what they feed.</li>
760
+ <li><b>Forward.</b> Through a sync or a later seek, the mix makes the recorded calls again, each with the mix moved to the time it was made at, so the mix plays on as it did, frame for frame under <code>stepMs</code>. Ahead of anything recorded, a seek forward is a sync to there without the host's clock moving.</li>
761
+ <li><b>Branches.</b> A call the host makes while recorded calls lie ahead starts a new branch, and the tape keeps the old future beside it rather than dropping it: scrubbing never loses a recording, and neither does a new call. <code>mix.tape.branches()</code> lists the futures at the tape's current entry and <code>switchBranch</code> picks one. To reach one, seek to just before the call that started the other; a call made at the same mix time as a recorded call it follows cannot be reached apart from that call.</li>
762
+ <li><b>The host's clock.</b> The host goes on passing its own clock: the next <code>sync</code> reads <code>t</code> plus the host's time since its last sync, and <code>rebase</code> composes with it. <code>sync</code> refuses an earlier timestamp short of a <code>rebase</code>. <code>seek</code>, <code>project</code> and <code>mix.now</code> are mix time, since once the mix replays a recording one host time can name two moments.</li>
763
+ <li><b>Events.</b> Playing past a time again sends its events and books its marks and hits again, since the host knows it went back; what was sent or booked at or before <code>t</code> is not sent again, and undrained events after <code>t</code> are dropped.</li>
764
+ <li><b>Reading ahead past the tape.</b> A read ahead of the mix after a seek back throws for a time past a call the tape will make again: a recorded call is bound to the live mix and its voices, and a projection is a copy, so it would leave the call out. A seek there makes it.</li>
765
+ </ul>
766
+ Decided 2026-10-06 and 2026-10-07, reversing the 2026-10-01 decision that mix time is only ever a reading. The mix goes back by itself rather than through snapshots the host saves and loads, since a host-kept log of its own calls would be a second record of what the mix did. What happened after <code>t</code> plays again rather than vanishing, because scrubbing must never cut off a recorded future. A call made while rewound branches rather than dropping the old future, and the mix drops its own copies of state after the fork rather than keeping them per branch, so memory stays inside the horizon. History lives only for the session. The mix's own logs hold what happened up to the playhead and the tape holds the calls after it, so nothing is recorded twice and the logs never branch. A recorded call's <code>invert</code> does nothing, since the mix restores rather than undoing; so the mix moves its tape back only with <code>goto</code>, forward one <code>redo</code> at a time, and opens no journals.</li>
703
767
  <li><b>How sure.</b> <code>assess</code> says per channel <code>held</code> where a voice feeding it reads input from outside the clock with no recording of it (a signal marked <code>input</code>, a patch that <code>reads</code> host fields, or a <code>from: 'current'</code> start read back with nothing kept), <code>stepped</code> where state crossed a gap in one go without <code>stepMs</code>, or a stateful signal did, and <code>exact</code> otherwise. The least sure voice decides. Under <code>stepMs</code> a read back lands on the pose the mix showed, so a scrub back and forth gives the same picture.</li>
704
768
  <li><b>Cost.</b> A projection copies each subject's record on first probe: about 3&nbsp;ms for 1000 subjects under three voices, three times a live frame (<code>npm run bench</code>, rows <code>ahead</code> and <code>back</code>). That is cheap enough to make one per frame for a continuous scrub. History costs one control entry per handle change and one copy per stateful subject per <code>every</code>, all dropped past the horizon.</li>
705
769
  </ul>
706
770
 
771
+ <h2 id="transport">Transport</h2>
772
+ <p>A <b>transport</b> is one clock several mixes play on, each over its own kit: astv&rsquo;s orb flights, text runs and snapshot rail are mixes whose poses never fold together but whose timing does. Every mix has one; a mix made alone makes its own, so the transport is the mix clock under its own name rather than a layer on top. A host shares one by making it and passing it to each mix.</p>
773
+ <pre><code>const t = transport({ history: { ms: 10_000, tape: createHistory } });
774
+ const orbs = mix(FLIGHT, { transport: t, name: 'orbs' });
775
+ const runs = mix(ROWS, { transport: t, name: 'runs' });
776
+
777
+ rail.cue({ patch: next, anchor: { start: { all: [
778
+ { of: { score: 'step:12', tag: 'motion', resolver: 'latest' }, mark: 'coast', by: restMs },
779
+ { with: { score: 'step:12', name: 'built' } },
780
+ ] } } });
781
+ t.sync(now); <span class="cm">// once a frame, after the frame's cues</span></code></pre>
782
+ <ul>
783
+ <li><b>One clock.</b> <code>t.sync</code>, <code>rebase</code>, <code>rate</code>, <code>ramp</code> and <code>seek</code> move every mix on it; a mix on a shared transport throws for its own, naming the transport. Probing, cueing and handles stay per mix.</li>
784
+ <li><b>One score across mixes.</b> A query that names a <code>score</code> looks in every mix on the transport, and among the marks announced there; one that names none stays in its own mix, so two windows&rsquo; bare names never meet. <code>t.announce</code> takes a named score. Voice ids come from the transport, so resolvers order across mixes by cue and <code>marks</code> and <code>book</code> keys stay unique; <code>Marked.mix</code> names the mix a mark came from.</li>
785
+ <li><b>Cue before sync.</b> A pending voice&rsquo;s anchor is answered again at every sync until it starts, so motion cued while it waits pushes it later. A voice cued after the sync that reached the anchor&rsquo;s time is cued after the start: a host cues a frame&rsquo;s voices before <code>t.sync</code>.</li>
786
+ <li><b>Reading and waking.</b> <code>t.marks</code> lists every mix&rsquo;s; <code>t.project(time).of(mix)</code> reads every mix at once, so anchors across them answer, and a mix&rsquo;s own <code>project</code> does the same and returns its part. <code>t.inert</code> holds while every mix is inert, and <code>t.onWake</code> fires once a change in any mix needs frames.</li>
787
+ <li><b>Seeking.</b> One tape records every mix&rsquo;s calls. Back, each mix restores itself; forward, the tape makes the calls again in the order they were made, every mix moved to each call&rsquo;s time first, so an anchor across mixes answers as it did live. A call after a seek back branches the whole transport. A mix&rsquo;s history is the transport&rsquo;s, and a mix given its own throws.</li>
788
+ <li><b>Joining and leaving.</b> A mix joins at the transport&rsquo;s <code>now</code> and plays a voice cued with an earlier <code>start</code> partway, as a mix alone does. <code>t.drop(mix)</code> takes one off; under history a seek back before the drop puts it back as it was and playing on drops it again, and without history it is let go.</li>
789
+ <li><b>What history keeps.</b> Everything within its reach comes back exactly, or the read throws: a seek or read back older than history, and a read ahead past a call the tape will make again. <code>ms: Infinity</code> keeps everything. Recorded input after a seek back is let go and read live, and <code>assess</code> says so. A seek back moves the reach with it only as far as what was kept: a second seek further back than the first one's memory throws.</li>
790
+ <li id="store"><b>History behind a store.</b> With <code>history.store</code>, what <code>ms</code> would drop is handed to <code>store.page</code> as plain data instead, and nothing is ever just gone: <code>ms</code> bounds memory, not reach. A seek or read past memory needs <code>await t.prepare(time)</code> first, which calls <code>store.load(time)</code>; without it, it throws <code>HistoryMiss</code>, naming the mix, before anything moves. A seek back calls <code>store.cut(time)</code>, since what was paged after it is from a future the tape makes again. <code>load</code> returns everything a restore needs for every mix; a store that returns all it holds is correct. Records are keyed by the mix&rsquo;s <code>name</code>, which every mix on a transport with a store needs, the voice&rsquo;s id and the subject&rsquo;s key: a string or number subject is its own, and an object subject needs <code>keyOf</code>, or the mix throws the first time it meets one. A patch with <code>state</code> or <code>step</code> gives <code>pack</code> and <code>unpack</code>, or <code>cue</code> refuses it; spring, glide and tween runs are paged by blits itself. What a stateful signal keeps goes by the order its owners first kept it on the record, since a signal is code no store can hold, and the same code keeps in the same order. A voice cued with <code>as: { kind, data }</code> leaves memory once history no longer reaches its end, and <code>history.revive</code> builds it again when a seek needs it, behind the handle the host holds; one cued without <code>as</code>, an owner, a span, a blend member or a voice still holding a join&rsquo;s answers stays in memory. With a store blits never prunes the tape, nor dropped mixes, rate changes or announced marks: the host bounds the tape. Built 2026-10-07.</li>
791
+ </ul>
792
+ <p>Designed and built 2026-10-07 (<code>docs/superpowers/specs/2026-10-07-transport-design.md</code>). Not built: owners or spans holding voices in another mix.</p>
793
+
707
794
  <h2 id="state">Patch state</h2>
708
795
  <p>klieg built three stateful patches in one week, <code>power</code>, <code>kicks</code> and <code>dwell</code>, and each rediscovered the same three rules: key state by the subject object, step once per frame by the gap in the timestamp, snap under infinite <code>dt</code>. Those rules are the package's, applied by the mix, and a patch declares only what its state is and how it advances.</p>
709
796
 
@@ -730,7 +817,7 @@ intro.fade(); <span class="cm">// one envelope over both</span></code></pr
730
817
 
731
818
  export const mixer: Engine; <span class="cm">// the one that ships</span></code></pre>
732
819
 
733
- <p><b>mixer</b> runs every form on the CPU, samples per subject on demand except where a channel runs as a lane (<a href="#lanes">below</a>), and is the extraction of what klieg does today. It folds each influence into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a locus folds to one influence before that influence is merged. That order is what would let a later revision hand patches one scratch object to write into instead of returning a fresh partial per patch per subject per frame, which <code>writes</code> makes safe to hand out without clearing. What later engines would look like, so the seam is judged against something real and none of them is being built:</p>
820
+ <p><b>mixer</b> runs every form on the CPU, samples per subject on demand except where a channel runs as a lane (<a href="#lanes">below</a>), and is the extraction of what klieg does today. It folds each contribution into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a locus folds to one contribution before that contribution is merged. That order is what would let a later revision hand patches one scratch object to write into instead of returning a fresh partial per patch per subject per frame, which <code>writes</code> makes safe to hand out without clearing. What later engines would look like, so the seam is judged against something real and none of them is being built:</p>
734
821
  <ul>
735
822
  <li><b>A baked engine</b> runs <code>keys</code> voices only and emits Web Animations or CSS keyframes, so a DOM consumer like wod gets compositor-thread playback. It refuses <code>fn</code> and <code>motion</code> patches at <code>cue</code>.</li>
736
823
  <li><b>A recording engine</b> wraps another engine and captures every pose into keyframes, turning a procedural patch into data for the baked one.</li>
@@ -749,13 +836,13 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
749
836
  <p>A channel is a lane only while every voice writing it qualifies, and a voice runs on lanes only while every channel it writes is one, so no patch is called twice in a frame. The mix works this out again when a voice is cued or retires, starts a fade to rest, or first keeps state; never per probe. A voice that names one subject and writes only laned channels is the exception: it joins the crowd of voices writing those channels, below, or leaves it, without the mix qualifying anything again, since a voice that fits joining laned channels and a laned voice leaving cannot move the fixed point. Such a voice also relinks only its own subject&rsquo;s chain, and only a voice over every subject makes every subject meet the lanes again. A projection always reads through the general path.</p>
750
837
  <ul>
751
838
  <li><b>Subjects are numbered on first sight.</b> A subject's first probe takes the general path, where <code>target</code>, <code>stagger</code> and the fade-in origin are worked out, and gives it a number; so does each voice's first meeting with it later. <code>drop</code> frees the number, and for an object subject so does the host letting it go. A subject the host never probes is never numbered and costs a lane nothing.</li>
752
- <li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches, then copies the subject's values into the pose; later probes that frame only copy. A cue, retirement or handle change between two probes refills at the next one. So a frame costs the fill plus a probe per subject the host reads.</li>
839
+ <li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches that was probed this frame or in the last frame with a probe, then copies the subject's values into the pose; later probes that frame only copy. A subject probed in neither is left out of the fill and takes the general path if it is probed before the next one, so a subject that scrolled out of view costs a lane nothing. A cue, retirement or handle change between two probes refills at the next one. So a frame costs the fill plus a probe per subject the host reads.</li>
753
840
  <li><b>A lane rests while its subjects go unprobed.</b> When fewer than 15% of a <code>keys</code> lane's subjects were probed in the last frame with a probe, or fewer than 60% of a <code>fn</code> or <code>motion</code> lane's, the lane stops filling and probes of its subjects take the general path; it fills again once that share reaches 25%, or 80%. Those lines sit either side of where filling and probing break even, measured at 1,000 and 10,000 subjects: about 19% for <code>keys</code>, 71&ndash;77% for a spring, and 63&ndash;100% for a stateless <code>fn</code>. Both paths give the same pose, so the switch only changes the cost.</li>
754
841
  <li><b>Single-subject voices share a crowd.</b> A laned voice whose <code>subjects</code> names one subject does not get a lane of its own, since it shares nothing across subjects for a lane to save: every such voice writing the same channels is a row in one crowd, in voice order. A motion voice writes one channel; a <code>keys</code> or stateless <code>fn</code> voice may write several. Crowds that share a channel take turns in voice order as a fill runs them, so a subject&rsquo;s rows fold in the order the general path folds them. A row holds the per-subject numbers a lane keeps and a copy of what a fill reads of the voice (clock, rate, weight) and of its current stretch. The voice&rsquo;s copy is rewritten when a handle or the mix changes the voice; the stretch&rsquo;s when the patch replaces it (<code>Motions.touched</code>). A fill reads the crowd&rsquo;s flat arrays and calls the same <code>closed</code> forms, and folds each row between the lanes on either side of its voice, so the order of the fold, and so its bits, are the general path&rsquo;s. A row with a fade, a ramp, a change pending or a probe already this frame takes the per-subject step instead. Measured on teitou against the build before (2026-10-03, three alternated passes of <code>bench/frame.mjs</code>): 10k tween voices of one subject each cost 0.54&ndash;0.59 of what they did, as much as one tween voice over the same 10k subjects; 100k cost 0.31&ndash;0.33; 10k springs 0.64&ndash;0.70. Before it, the extra cost of a voice over a shared voice grew with the count, 18&nbsp;ns at 1k, 48 at 10k, 226 at 100k (<code>bench/split.mjs</code>): the fill was bound by memory, a <code>Voice</code>, a <code>Lane</code> and a patch buffer per voice. A <code>keys</code> or stateless <code>fn</code> row reads its voice the same way and then does what a lane does for one subject: reads the stops, or calls the patch through the same function a lane calls it through. Rows join at the end as voices are cued, so they stay in voice order; a retired voice&rsquo;s row is left empty until the crowd is more than half empty rows, when the next probe slides the rows left down over the empty ones, in order. Rebuilding the crowds through a full qualify instead made that one frame about 9&nbsp;ms at 10k rows; sliding them keeps the worst of 12,000 <code>churn</code> frames near 3&nbsp;ms, which is collection (teitou, 2026-10-04). Measured on teitou against the build before (2026-10-03, five alternated passes, ms a frame): 10k <code>keys</code> voices of one subject each by probe 1.30&ndash;1.89 from 1.60&ndash;1.77, and by <code>pull</code> 1.25&ndash;2.01 from 1.71&ndash;1.84, both bimodal from run to run; 10k <code>fn</code> voices by <code>pull</code> 0.93&ndash;1.18 from 1.36&ndash;1.52, and by probe 1.20&ndash;1.43 against 1.09&ndash;1.21, which a profiled run reversed. Folding each row's segment straight into the lanes, through the same search <code>readKeys</code> makes, rather than through a delta, took 10k such rows read by <code>pull</code> to 0.74 of their time and by probe to 0.85 (teitou, 2026-10-04). A row's stops are now copied as it joins into one array the crowd keeps for all its rows, phase, then value per axis, so a fill reads them in place rather than through the row's voice, its tracks and each stop's own array; the search is still the one <code>segment</code> makes. A row whose stops are not plain numbers on the channel's axes reads them through its voice as before. That took 10k such rows read by <code>pull</code> from 0.66&ndash;1.02&nbsp;ms a frame to 0.34&ndash;0.36, 34&ndash;36&nbsp;ns a subject against 25 a subject and voice for keys voices over every subject, and by probe from 1.00&ndash;1.37 to 0.81&ndash;0.83 (teitou, 2026-10-05). Copying the stops into each voice's own flat arrays, with the row still reaching them through its voice, had measured no different. With one of 10k tween voices stopped, its subject dropped and another cued every frame (<code>churn</code>), a frame costs 0.91&ndash;1.01&nbsp;ms by probe and 0.54&ndash;0.62 by <code>pull</code>, from 9.82&ndash;9.94 and 8.73&ndash;9.07. A <code>fn</code> voice of one subject writing two channels, which took the general path before crowds took several channels, reads at 0.65&times; at 1k voices and 0.79&times; at 10k, and 1.05&ndash;1.11&times; at 100, where a fill&rsquo;s bookkeeping costs more than the general path it saves (rows <code>named</code>, teitou, 2026-10-04). A voice naming several subjects has a lane of its own.</li>
755
842
  <li><b>A locus runs on lanes in two passes.</b> The general path folds a locus at its first member&rsquo;s place in the voice order, and which voice that is differs by subject. So a fill first takes each locus&rsquo;s members in voice order and, for each subject, sums their weights and lerps their values by share of the weight taken, as <code>foldLocus</code> does, with the stock channels&rsquo; own lerp; then, folding the voices in order, it folds each subject&rsquo;s locus in at whichever member met that subject first. A locus&rsquo;s members are on lanes all together or not at all, since a member left on the general path would count in its sum there but not on a lane; a motion member keeps the whole locus off. A blend&rsquo;s signal is read by whichever member fills a subject first, and kept by subject number for the others. On teitou (2026-10-04) three voices in a locus over 10k subjects take 0.72 of the time they did on the general path, and <code>mix.blend</code> of three by a stateless signal 0.84.</li>
756
843
  <li><b><code>sync</code> visits only the voices with something due.</b> Each live voice sits in a heap by the earliest mix time a sync would change it: its start, its anchored out, the end of its last pass, the end of a fade or of a subject&rsquo;s ramp out. A handle change that could bring that earlier files it again. A voice whose time cannot be put ahead, one fading to rest or held and running backwards, is visited every frame. <code>test/due.test.ts</code> plays random mixes both ways, against a full walk, and compares states, poses and marks after every sync.</li>
757
844
  <li><b>Lanes give the same pose, to the bit.</b> Both paths call the same functions for phase, envelope, weight clamp, each stock channel's fold, the easing curves and the spring solver, and clamp <code>bounds</code> the same way after. <code>test/lanes.test.ts</code> runs every case both ways and compares each probe with <code>Object.is</code>. <code>weightOf</code>, <code>atRest</code> and a finite loop's end read what the fill keeps.</li>
758
- <li><b>Two things differ, both documented on the API.</b> A stateless patch calling <code>setting.send</code> from <code>at</code> sends every frame for every subject a lane fills, probed or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host did not probe that frame, advances that state once more for that subject than the general path would; a patch that keeps state from its first call, or declares <code>state</code>, never does.</li>
845
+ <li><b>Two things differ, both documented on the API.</b> A stateless patch calling <code>setting.send</code> from <code>at</code> sends every frame for every subject a lane fills, which is each subject probed that frame or the last, whether the host reads it this frame or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host probed the frame before and not that frame, advances that state once more for that subject than the general path would; a patch that keeps state from its first call, or declares <code>state</code>, never does.</li>
759
846
  <li><b>Motion runs on lanes as data.</b> <code>spring</code>, <code>glide</code> and <code>tween</code> are their own form so a lane can sample each subject's stretch from the patch's arrays rather than calling <code>at</code>. A fill samples only where sampling changes nothing; a subject with a change to apply is left to the general path, so a retarget lands where it would with lanes off.</li>
760
847
  </ul>
761
848
  <p>What they cost, measured on the fleet on 2026-10-02 against the build before them (the table is in <code>spikes/gpu-engine/README.md</code>, under Lanes, as built): three <code>keys</code> voices over 10,000 subjects fell from 6.1 to 2.6&nbsp;ms a frame, and a stateless <code>fn</code> gained up to 37%. A spring frame first rose from 4.3 to 5.4&nbsp;ms; once the spring solver stopped allocating per sample, a rerun put a spring frame at 0.53&ndash;0.72 of the earlier build's, <code>keys</code> at 0.38&ndash;0.57 and a stateless <code>fn</code> at 0.70&ndash;0.88. A host that probed 5% of the subjects it had met paid for all of them, 1.3&nbsp;ms against 0.1 at 10,000, until resting lanes brought that to about 0.15&nbsp;ms (one local machine). And a probe costs about 110&ndash;140&nbsp;ns at 10,000 subjects even when a lane did all the work, so a host probing every subject pays 1.1&ndash;1.4&nbsp;ms before any arithmetic.</p>
@@ -818,11 +905,12 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
818
905
  <li><b>Catch-up.</b> A subject unsampled for three frames steps by the whole gap on the fourth.</li>
819
906
  <li><b>Fade continuity.</b> A voice stopped mid-pass with a fade produces no frame-to-frame jump larger than its per-frame ramp, on every channel with rest.</li>
820
907
  <li><b>Retarget continuity.</b> A <code>from: 'current'</code> voice cued on a moving subject has a first pose equal to the subject's previous pose.</li>
821
- <li><b>Band quiet.</b> A signal held inside a <code>gate</code>'s band for a whole pass produces no switch, whatever it does inside it. Same for a rest-less channel's influence under a weight that wanders between <code>off</code> and <code>on</code>.</li>
908
+ <li><b>Band quiet.</b> A signal held inside a <code>gate</code>'s band for a whole pass produces no switch, whatever it does inside it. Same for a rest-less channel's contribution under a weight that wanders between <code>off</code> and <code>on</code>.</li>
822
909
  <li><b>Locus fold.</b> Two voices in a locus driving the same value produce that value, on every stock channel &mdash; the invariant the old cap met on <code>sum</code> alone. At unequal values they produce the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the number scaling and merging already gives and is what keeps klieg's baselines green. A locus whose weights sum below 1 scales the folded value toward rest by the sum; above 1 it caps at 1.</li>
823
910
  <li><b>Handover.</b> <code>fade({ at: 'rest' })</code> removes per subject at the first resting frame, and at the deadline for a patch that never rests.</li>
824
911
  <li><b>Declared writes.</b> A patch's <code>at</code> sets no key outside its <code>writes</code>, on every subject and every pass. A voice whose patch writes a channel the kit lacks throws at <code>cue</code> naming both.</li>
825
912
  <li><b>Reading another time.</b> A projection ahead gives what the mix probes when it gets there, and moves nothing: a mix read ahead every frame plays exactly as one never read. A read back under <code>stepMs</code> equals the pose the mix showed at every frame, through fades, rate and weight changes, seeks and voices that have left, and a round trip back and forth repeats it. History stays inside its horizon.</li>
913
+ <li><b>Seeking.</b> After <code>seek(t)</code> back under <code>stepMs</code>, with lanes and without, and through a change of the mix's rate, the mix plays on equal to what it showed at every frame, the host's recorded calls made again: cues, handle writes, fades, voice seeks, spring retargets and pushes, owners and their children, across two seeks before a subject is read again, and forward by a seek as by a sync. A call made after going back plays the new future and keeps the old one, which <code>switchBranch</code> plays again exactly. The suite runs on weasel-history's own <code>History</code>, a dev dependency. Handles held across a seek work, and <code>done</code> and <code>played</code> start over; events and hits after <code>t</code> come again and none at or before it does. <code>sync</code> refuses an earlier timestamp short of a <code>rebase</code>, and <code>project</code> takes mix time, which a <code>rebase</code> leaves where it was.</li>
826
914
  <li><b>Placement.</b> astv's orb, landing text and tint, each anchored on the one before, list their marks and play to them; a waiting voice follows a target faded early; a placement that waits on itself is refused.</li>
827
915
  <li><b>Lanes off against lanes on.</b> Every qualifying case runs with <code>lanes: false</code> and without, and each probe's channels must agree under <code>Object.is</code>: every patch form, <code>stagger</code>, <code>target</code>, <code>subjects</code>, loops, <code>rate</code>, <code>ramp</code>, <code>seek</code>, fades, <code>bounds</code>, a spring retargeted and pushed mid-flight, and a channel moving between a lane and the general path mid-animation. Subject numbers are freed and reused without showing an old subject's values.</li>
828
916
  <li><b>Engine refusal.</b> An engine whose <code>runs</code> lacks <code>fn</code> throws at <code>cue</code> naming the voice.</li>
@@ -831,11 +919,12 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
831
919
 
832
920
  <h2 id="open">Open</h2>
833
921
  <ul>
834
- <li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line &mdash; an orientation that wraps, where 350° and 10° must meet at 0°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code> 0xrrggbb sRGB, blended in OKLCH. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
922
+ <li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line &mdash; an orientation that wraps, where 350° and 10° must meet at 0°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code> as <code>color()</code>, OKLab with coverage. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
835
923
  <li><b>What the score still lacks.</b> The score is built (see Score). Decided 2026-09-29 and not built: <b>marks placed inside a voice</b>, such as a hit 340&nbsp;ms in, as named marks beside its four. <b>Tags absorb loci</b>: a tag declared to fold its voices as alternatives does what <code>locus</code> does now; where the declaration lives is not decided. <b>Splitting a read ahead at known events</b>, one step per interval between a voice starting, a fade ending and a loop running out; today a read ahead crosses the gap in one step and says <code>stepped</code> where that matters.</li>
924
+ <li><b>What spans do not do yet.</b> Hold back part of a budget for children the host knows are coming, short of a strategy of its own; skip a span held by a span; send the events a skipped child would have sent on the way. Found while building them, 2026-10-07.</li>
836
925
  <li><b>The package name.</b> Published as <code>@msb235/blits</code> for now, decided 2026-09-30, while npm answers a request for the bare <code>blits</code>, which the registry 404s. Moving to <code>blits</code> later changes every import. <code>@lightningjs/blits</code> is a live TV app framework with its own animation plugin, so searches for the name land there first.</li>
837
926
  <li><b>Whether <code>at</code> writes into a buffer: no, it returns.</b> A patch allocates one partial delta per subject per frame, and <code>writes</code> plus the fold order would let the mix hand it one scratch object instead. The port measured what that would buy: a frame of klieg&rsquo;s motion — 12 letters through four voices — costs 0.017&nbsp;ms, and a frame of its effects — 60 parts under three effects — costs 0.134&nbsp;ms, against a 16.7&nbsp;ms budget. Under 1% of a frame is not worth the nicest property <code>at</code> has, which is being a pure function a test calls once. The p99 of the effects frame is 1.3&nbsp;ms, which is the collector catching up on exactly these allocations, so a subject count an order of magnitude past klieg&rsquo;s would reopen it. <code>packages/core/test/bench/frame-cost.bench.ts</code> in klieg is the measurement</li>
838
- <li><b>Reading back across a subject faded out of a voice.</b> Once its ramp ends the voice forgets the subject, as <code>drop</code> forgets one, so a <code>project</code> back to before that keeps the subject reached by a stateless voice but has lost a motion patch's stretches for it; and a subject brought back reads as never having left. Keeping the parted records under <code>history</code> would fix both. Found 2026-10-02 while building subject fades.</li>
927
+ <li><b>A subject brought back, read back to while it was out.</b> Under <code>history</code> a voice keeps the record a subject left with, faded out or dropped, and a motion patch keeps its stretches, so a <code>seek</code> or <code>project</code> back to before it left finds both (built 2026-10-07). Untested: a read back to a moment between the subject leaving and a motion's <code>to</code> bringing it back, which may read it as never having left. Found 2026-10-02 while building subject fades.</li>
839
928
  <li><b>The stock band's width.</b> Whether <code>gate</code>'s default band is symmetric around <code>at</code> and how wide it is. It has to clear the frame noise in a real signal, so it is a measurement from klieg's <code>near</code> and <code>dwell</code>, not a guess.</li>
840
929
  </ul>
841
930
  </main>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Concurrent effects: each on its own clock with its own state and weight, folded into one value per subject per frame",
5
5
  "keywords": [
6
6
  "animation",
@@ -53,6 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@biomejs/biome": "^2.5.8",
55
55
  "@types/node": "^26.2.0",
56
+ "@weasel-js/history": "^1.9.1",
56
57
  "typescript": "^7.0.2",
57
58
  "vitest": "^4.1.10"
58
59
  }