@cardstack/choreo 0.0.0 → 0.1.0-unstable.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 (165) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -6
  4. package/addon-main.cjs +4 -0
  5. package/declarations/anchors.d.ts +26 -0
  6. package/declarations/anchors.d.ts.map +1 -0
  7. package/declarations/arming.d.ts +33 -0
  8. package/declarations/arming.d.ts.map +1 -0
  9. package/declarations/beacon.d.ts +9 -0
  10. package/declarations/beacon.d.ts.map +1 -0
  11. package/declarations/beacons.d.ts +23 -0
  12. package/declarations/beacons.d.ts.map +1 -0
  13. package/declarations/changeset.d.ts +46 -0
  14. package/declarations/changeset.d.ts.map +1 -0
  15. package/declarations/choreo.d.ts +261 -0
  16. package/declarations/choreo.d.ts.map +1 -0
  17. package/declarations/compile.d.ts +45 -0
  18. package/declarations/compile.d.ts.map +1 -0
  19. package/declarations/deliver.d.ts +44 -0
  20. package/declarations/deliver.d.ts.map +1 -0
  21. package/declarations/easings.d.ts +20 -0
  22. package/declarations/easings.d.ts.map +1 -0
  23. package/declarations/far.d.ts +33 -0
  24. package/declarations/far.d.ts.map +1 -0
  25. package/declarations/film/clip.d.ts +51 -0
  26. package/declarations/film/clip.d.ts.map +1 -0
  27. package/declarations/film/clips.d.ts +155 -0
  28. package/declarations/film/clips.d.ts.map +1 -0
  29. package/declarations/film/film.d.ts +1208 -0
  30. package/declarations/film/film.d.ts.map +1 -0
  31. package/declarations/film/graph/adjust.d.ts +112 -0
  32. package/declarations/film/graph/adjust.d.ts.map +1 -0
  33. package/declarations/film/graph/compile.d.ts +140 -0
  34. package/declarations/film/graph/compile.d.ts.map +1 -0
  35. package/declarations/film/graph/host.d.ts +67 -0
  36. package/declarations/film/graph/host.d.ts.map +1 -0
  37. package/declarations/film/graph/nodes.d.ts +287 -0
  38. package/declarations/film/graph/nodes.d.ts.map +1 -0
  39. package/declarations/film/index.d.ts +24 -0
  40. package/declarations/film/index.d.ts.map +1 -0
  41. package/declarations/film/joins.d.ts +143 -0
  42. package/declarations/film/joins.d.ts.map +1 -0
  43. package/declarations/film/math.d.ts +15 -0
  44. package/declarations/film/math.d.ts.map +1 -0
  45. package/declarations/film/overlays.d.ts +54 -0
  46. package/declarations/film/overlays.d.ts.map +1 -0
  47. package/declarations/film/picture.d.ts +104 -0
  48. package/declarations/film/picture.d.ts.map +1 -0
  49. package/declarations/film/plate.d.ts +37 -0
  50. package/declarations/film/plate.d.ts.map +1 -0
  51. package/declarations/film/player.d.ts +120 -0
  52. package/declarations/film/player.d.ts.map +1 -0
  53. package/declarations/film/rail.d.ts +40 -0
  54. package/declarations/film/rail.d.ts.map +1 -0
  55. package/declarations/film/schedule.d.ts +93 -0
  56. package/declarations/film/schedule.d.ts.map +1 -0
  57. package/declarations/film/seam.d.ts +38 -0
  58. package/declarations/film/seam.d.ts.map +1 -0
  59. package/declarations/film/titles.d.ts +37 -0
  60. package/declarations/film/titles.d.ts.map +1 -0
  61. package/declarations/film/types.d.ts +544 -0
  62. package/declarations/film/types.d.ts.map +1 -0
  63. package/declarations/film.d.ts +3 -0
  64. package/declarations/film.d.ts.map +1 -0
  65. package/declarations/gesture.d.ts +33 -0
  66. package/declarations/gesture.d.ts.map +1 -0
  67. package/declarations/index.d.ts +26 -0
  68. package/declarations/index.d.ts.map +1 -0
  69. package/declarations/measure.d.ts +29 -0
  70. package/declarations/measure.d.ts.map +1 -0
  71. package/declarations/path.d.ts +73 -0
  72. package/declarations/path.d.ts.map +1 -0
  73. package/declarations/registry.d.ts +46 -0
  74. package/declarations/registry.d.ts.map +1 -0
  75. package/declarations/run.d.ts +406 -0
  76. package/declarations/run.d.ts.map +1 -0
  77. package/declarations/space.d.ts +31 -0
  78. package/declarations/space.d.ts.map +1 -0
  79. package/declarations/steps.d.ts +479 -0
  80. package/declarations/steps.d.ts.map +1 -0
  81. package/declarations/test-support/index.d.ts +30 -0
  82. package/declarations/test-support/index.d.ts.map +1 -0
  83. package/declarations/types.d.ts +761 -0
  84. package/declarations/types.d.ts.map +1 -0
  85. package/dist/anchors.js +34 -0
  86. package/dist/anchors.js.map +1 -0
  87. package/dist/arming.js +120 -0
  88. package/dist/arming.js.map +1 -0
  89. package/dist/beacon.js +25 -0
  90. package/dist/beacon.js.map +1 -0
  91. package/dist/beacons.js +77 -0
  92. package/dist/beacons.js.map +1 -0
  93. package/dist/changeset.js +129 -0
  94. package/dist/changeset.js.map +1 -0
  95. package/dist/choreo.js +1026 -0
  96. package/dist/choreo.js.map +1 -0
  97. package/dist/compile.js +1403 -0
  98. package/dist/compile.js.map +1 -0
  99. package/dist/deliver.js +326 -0
  100. package/dist/deliver.js.map +1 -0
  101. package/dist/easings.js +39 -0
  102. package/dist/easings.js.map +1 -0
  103. package/dist/far.js +147 -0
  104. package/dist/far.js.map +1 -0
  105. package/dist/film/clip.js +100 -0
  106. package/dist/film/clip.js.map +1 -0
  107. package/dist/film/clips.js +147 -0
  108. package/dist/film/clips.js.map +1 -0
  109. package/dist/film/film.js +4424 -0
  110. package/dist/film/film.js.map +1 -0
  111. package/dist/film/graph/adjust.js +160 -0
  112. package/dist/film/graph/adjust.js.map +1 -0
  113. package/dist/film/graph/compile.js +225 -0
  114. package/dist/film/graph/compile.js.map +1 -0
  115. package/dist/film/graph/host.js +77 -0
  116. package/dist/film/graph/host.js.map +1 -0
  117. package/dist/film/graph/nodes.js +500 -0
  118. package/dist/film/graph/nodes.js.map +1 -0
  119. package/dist/film/index.js +18 -0
  120. package/dist/film/index.js.map +1 -0
  121. package/dist/film/joins.js +260 -0
  122. package/dist/film/joins.js.map +1 -0
  123. package/dist/film/math.js +49 -0
  124. package/dist/film/math.js.map +1 -0
  125. package/dist/film/overlays.js +66 -0
  126. package/dist/film/overlays.js.map +1 -0
  127. package/dist/film/picture.js +58 -0
  128. package/dist/film/picture.js.map +1 -0
  129. package/dist/film/plate.js +36 -0
  130. package/dist/film/plate.js.map +1 -0
  131. package/dist/film/player.js +79 -0
  132. package/dist/film/player.js.map +1 -0
  133. package/dist/film/rail.js +38 -0
  134. package/dist/film/rail.js.map +1 -0
  135. package/dist/film/schedule.js +238 -0
  136. package/dist/film/schedule.js.map +1 -0
  137. package/dist/film/seam.js +64 -0
  138. package/dist/film/seam.js.map +1 -0
  139. package/dist/film/titles.js +45 -0
  140. package/dist/film/titles.js.map +1 -0
  141. package/dist/film/types.js +2 -0
  142. package/dist/film/types.js.map +1 -0
  143. package/dist/film.js +18 -0
  144. package/dist/film.js.map +1 -0
  145. package/dist/gesture.js +111 -0
  146. package/dist/gesture.js.map +1 -0
  147. package/dist/index.js +9 -0
  148. package/dist/index.js.map +1 -0
  149. package/dist/measure.js +99 -0
  150. package/dist/measure.js.map +1 -0
  151. package/dist/path.js +272 -0
  152. package/dist/path.js.map +1 -0
  153. package/dist/registry.js +55 -0
  154. package/dist/registry.js.map +1 -0
  155. package/dist/run.js +2423 -0
  156. package/dist/run.js.map +1 -0
  157. package/dist/space.js +57 -0
  158. package/dist/space.js.map +1 -0
  159. package/dist/steps.js +831 -0
  160. package/dist/steps.js.map +1 -0
  161. package/dist/test-support/index.js +150 -0
  162. package/dist/test-support/index.js.map +1 -0
  163. package/dist/types.js +2 -0
  164. package/dist/types.js.map +1 -0
  165. package/package.json +201 -6
package/dist/choreo.js ADDED
@@ -0,0 +1,1026 @@
1
+ import { registerDestructor } from '@ember/destroyable';
2
+ import Component from '@glimmer/component';
3
+ import { tracked } from '@glimmer/tracking';
4
+ import { consumeTag, VOLATILE_TAG } from '@glimmer/validator';
5
+ import { modifier } from 'ember-modifier';
6
+ import { registerBusyProbe, snapshotOnRender, motionSpeed, postRender, flushPendingMounts } from 'glimmer-motion';
7
+ import { measureBeacons } from './beacons.js';
8
+ import { Changeset } from './changeset.js';
9
+ import compile, { continuation, sameScore } from './compile.js';
10
+ import { leave, join, setPassScheduler, setBeforeMeasure } from './far.js';
11
+ import { trackGestures, GESTURE } from './gesture.js';
12
+ import { measure, boundsOf } from './measure.js';
13
+ import { setChoreoHost } from './registry.js';
14
+ import { execute } from './run.js';
15
+ import { collect, Wait, Tween, Tether, Spring, SlowZoom, Sequence, Scroll, Raise, Perform, Parallel, Pan, Move, Hold, Gate, Frame, Follow, Crossing, Camera3D, Camera, Attach, Aim } from './steps.js';
16
+ import { precompileTemplate } from '@ember/template-compilation';
17
+ import { setComponentTemplate } from '@ember/component';
18
+ import { g, i } from 'decorator-transforms/runtime';
19
+
20
+ const selector = type => Object.assign(role => role === undefined ? {
21
+ type
22
+ } : {
23
+ role,
24
+ type
25
+ }, {
26
+ type
27
+ });
28
+ /** what the region yields: the step components and the sprite queries */
29
+
30
+ function contextFor(region) {
31
+ return {
32
+ Aim,
33
+ Attach,
34
+ Camera,
35
+ Camera3D,
36
+ Crossing,
37
+ Follow,
38
+ Frame,
39
+ Gate,
40
+ Hold,
41
+ Move,
42
+ Pan,
43
+ Parallel,
44
+ Perform,
45
+ Raise,
46
+ Scroll,
47
+ Sequence,
48
+ SlowZoom,
49
+ Spring,
50
+ Tether,
51
+ Tween,
52
+ Wait,
53
+ advance: () => region.run?.advance(),
54
+ all: selector(),
55
+ beacon: beacon => ({
56
+ beacon
57
+ }),
58
+ gesture: GESTURE,
59
+ counterpart: selector('counterpart'),
60
+ id: id => ({
61
+ id
62
+ }),
63
+ inserted: selector('inserted'),
64
+ kept: selector('kept'),
65
+ moved: selector('moved'),
66
+ onstage: query => ({
67
+ ...query,
68
+ onstage: true
69
+ }),
70
+ received: selector('received'),
71
+ removed: selector('removed'),
72
+ role: role => ({
73
+ role
74
+ }),
75
+ get camera() {
76
+ return region.cameraState;
77
+ },
78
+ get run() {
79
+ return region.run ?? null;
80
+ },
81
+ still: selector('still')
82
+ };
83
+ }
84
+ let debugStyle;
85
+ /**
86
+ * Two rules every region shares. An orphaned skin flies with its
87
+ * backdrop-filters OFF: a backdrop-filter can never be cached — it
88
+ * re-samples and re-blurs whatever is behind it on every frame — and
89
+ * inside a scaling, fading leaver that is paid at full price for the
90
+ * whole flight (the camera demo's four glass panels, over its gradient
91
+ * washes, was the return trip's jank). It is also the wrong picture: a
92
+ * skin is a memory of the OLD scene, and its glass sampling the new
93
+ * scene behind it shows the wrong world. The panels keep their own
94
+ * translucent grounds, so they still read as glass in the crossfade.
95
+ *
96
+ * And an orphaned skin does not REPLAY ITS BIRTH: re-inserting a node
97
+ * restarts every CSS animation on it, so a leaver re-parented into the
98
+ * orphan layer re-ran its entrance choreography — rules drawing
99
+ * themselves, letters settling out of a blur — while it was supposed to
100
+ * be dying. A one-frame-old memory acting out its own arrival is the
101
+ * flash the Towers film chased for a night. The run's own exit motion
102
+ * is WAAPI and survives re-parenting untouched; only stylesheet
103
+ * animations restart, and on a memory they are all lies.
104
+ */
105
+ let orphanStyle;
106
+ function ensureOrphanStyle() {
107
+ if (orphanStyle) {
108
+ return;
109
+ }
110
+ orphanStyle = document.createElement('style');
111
+ orphanStyle.setAttribute('data-choreo-style', '');
112
+ orphanStyle.textContent = '[data-choreo-orphans] *,' + '[data-choreo-orphans] *::before,' + '[data-choreo-orphans] *::after' + '{backdrop-filter:none!important;' + '-webkit-backdrop-filter:none!important;' + 'animation:none!important;}';
113
+ document.head.appendChild(orphanStyle);
114
+ }
115
+ /**
116
+ * A fingerprint of the collected timeline tree, for telling an EDIT from
117
+ * noise while a run's own orphans are aloft. Mid-flight, compiled cues are
118
+ * value-laden (every re-measure shifts them), so sameScore cannot answer
119
+ * "did the author change the timeline?" — but the tree can: it is template
120
+ * state, identical under noise, different under any real edit. Functions
121
+ * (property sources) print by identity: a fresh function is a change we
122
+ * cannot disprove, and recompiling is the safe answer.
123
+ */
124
+ const fnIds = new WeakMap();
125
+ let nextFnId = 0;
126
+ function treePrint(tree) {
127
+ return JSON.stringify(tree, (_key, value) => {
128
+ if (typeof value === 'function') {
129
+ let id = fnIds.get(value);
130
+ if (id === undefined) {
131
+ fnIds.set(value, id = ++nextFnId);
132
+ }
133
+ return `fn#${id}`;
134
+ }
135
+ return value;
136
+ });
137
+ }
138
+ /**
139
+ * The color the page actually shows behind this region — the nearest
140
+ * ancestor (the region itself included) wearing a background with any
141
+ * alpha at all. What a crossing blends a semi-transparent skin against
142
+ * when it turns opacity into an actual color (§4.7). Undefined when the
143
+ * whole chain is transparent; the crossfade then stays a plain dissolve.
144
+ */
145
+ function groundOf(root) {
146
+ let el = root;
147
+ while (el) {
148
+ const bg = getComputedStyle(el).backgroundColor;
149
+ if (bg && bg !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(bg)) {
150
+ return bg;
151
+ }
152
+ el = el.parentElement;
153
+ }
154
+ return undefined;
155
+ }
156
+ /**
157
+ * The barrier books one post-render callback for every region taking part in a
158
+ * pass. `joinPass(this)` is where the region's PassHost contract (measurePass /
159
+ * finishPass) is type-checked — it is not on the class, because a `.gts` class
160
+ * header cannot carry two `implements` clauses through the build.
161
+ */
162
+ setPassScheduler(run => postRender(run));
163
+ setBeforeMeasure(() => flushPendingMounts());
164
+ class Choreo extends Component {
165
+ element;
166
+ orphanLayer;
167
+ raisedLayer;
168
+ tetherLayer;
169
+ /** tracked mirror of the frame's resting state — see ChoreoContext.camera */
170
+ static {
171
+ g(this.prototype, "cameraState", [tracked], function () {
172
+ return {
173
+ x: 0,
174
+ y: 0,
175
+ zoom: 1
176
+ };
177
+ });
178
+ }
179
+ #cameraState = (i(this, "cameraState"), void 0);
180
+ /**
181
+ * The same state, untracked, for the pass pipeline itself. A pass runs
182
+ * inside computations that must not CONSUME the tracked mirror — a pass
183
+ * that read it would be invalidated by the very landing it causes, and
184
+ * the region would render forever.
185
+ */ /** where a c.Camera3D left the shot, carried between runs — seeded by
186
+ * `@camera3dFrom` when the host knows its own opening pose */
187
+ resting3d = this.args.camera3dFrom ? {
188
+ ...this.args.camera3dFrom
189
+ } : {
190
+ dolly: 1,
191
+ pitch: 0,
192
+ x: 0,
193
+ y: 0,
194
+ yaw: 0
195
+ };
196
+ restingCamera = {
197
+ x: 0,
198
+ y: 0,
199
+ zoom: 1
200
+ };
201
+ participants = new Set();
202
+ /** registered since the last pass */
203
+ arrived = new Set();
204
+ /** destroyed before the pass that removed them has been processed */
205
+ claimed = new Set();
206
+ /** removed participants this region is keeping alive in its orphan layer */
207
+ orphans = new Set();
208
+ /** participants whose Presence is letting them go: removed once, not on every pass while they leave */
209
+ leaving = new Set();
210
+ snapshots = new Map();
211
+ rootSnapshot;
212
+ passPending = false;
213
+ rendered = false;
214
+ run;
215
+ context = contextFor(this);
216
+ /** the last changeset this region built — read it from a property function or a test */
217
+ changeset;
218
+ constructor(owner, args) {
219
+ super(owner, args);
220
+ trackGestures();
221
+ // what `animationsSettled()` waits for: a pass announced but not yet run,
222
+ // or a timeline still playing. Orphans are deliberately not counted — a
223
+ // leaver stranded in the layer is a bug, and a probe that reported it
224
+ // would turn every one of those into a timeout instead of a failed
225
+ // assertion with a name on it.
226
+ const stopProbe = registerBusyProbe(() => {
227
+ if (this.passPending) {
228
+ return `<Choreo${this.args.id ? ` ${this.args.id}` : ''}> pass pending`;
229
+ }
230
+ // parked is a still and STANDING is an annotation that is simply on
231
+ // (a score of nothing but open steps never ends): both are settled,
232
+ // and a probe that called either one "in flight" would turn every
233
+ // wire the page leaves up into a suite-wide timeout
234
+ if (this.run && !this.run.isDone() && !this.run.parked && !this.run.standing) {
235
+ return `<Choreo${this.args.id ? ` ${this.args.id}` : ''}> run in flight`;
236
+ }
237
+ return false;
238
+ });
239
+ registerDestructor(this, () => {
240
+ stopProbe();
241
+ leave(this);
242
+ this.run?.cancel();
243
+ this.run = undefined;
244
+ // a quiet region must not take its pause to the grave
245
+ this.unquiet();
246
+ for (const node of [...this.orphans, ...this.claimed]) {
247
+ this.drop(node);
248
+ }
249
+ });
250
+ }
251
+ /* ---- ChoreoHost ---- */ /** SPIKE (Lane): providers from outside the markup, appended to every collected tree */
252
+ contributors = new Set();
253
+ contribute(provider) {
254
+ this.contributors.add(provider);
255
+ return () => {
256
+ this.contributors.delete(provider);
257
+ };
258
+ }
259
+ /** the run an attachment drives: the one playing or holding, none between runs */
260
+ currentRun() {
261
+ return this.run ?? null;
262
+ }
263
+ /** the region's own steps in document order, then whatever was contributed from outside */
264
+ collectTree(root) {
265
+ const tree = collect(root);
266
+ for (const provider of this.contributors) {
267
+ tree.push(provider.node());
268
+ }
269
+ return tree;
270
+ }
271
+ register(node) {
272
+ this.participants.add(node);
273
+ this.arrived.add(node);
274
+ if (this.args.debug && node.element) {
275
+ node.element.style.outline = '1px dotted #16a34a';
276
+ // the seam lints (§5.3): inside a region the timeline is the only
277
+ // animation authority, and Presence double-retains participants
278
+ if (node.ownAnimation) {
279
+ console.warn(`choreo: participant '${node.id ?? node.role}' carries its own ` + 'animate/exit/initial — a second scheduler beside the timeline');
280
+ }
281
+ if (node.presenceManaged) {
282
+ console.warn(`choreo: participant '${node.id ?? node.role}' is inside a ` + '<Presence> — the region already retains its own leavers');
283
+ }
284
+ }
285
+ return () => {
286
+ // a destroyed participant may still be claimed (below); the sets that decide that survive until drop()
287
+ this.participants.delete(node);
288
+ this.arrived.delete(node);
289
+ };
290
+ }
291
+ claim(node) {
292
+ if (this.orphans.has(node)) {
293
+ return true;
294
+ }
295
+ if (this.passPending) {
296
+ // its pass has not been processed yet: decide then
297
+ this.claimed.add(node);
298
+ return true;
299
+ }
300
+ return false;
301
+ }
302
+ /* ---- the render pass ---- */ /* eslint-disable ember/no-side-effects -- this getter IS the render hook: boxel-motion's render detector,
303
+ re-evaluated on every render pass before the DOM is patched (React's getSnapshotBeforeUpdate slot) */
304
+ get renderDetector() {
305
+ consumeTag(VOLATILE_TAG);
306
+ // A region is a layout boundary as well: projecting participants
307
+ // (`layout` / `layoutId`) snapshot here too, so `{{motion layout=true}}`
308
+ // works inside a <Choreo> without a <LayoutGroup> wrapped around it.
309
+ snapshotOnRender();
310
+ if (!this.passPending) {
311
+ this.passPending = true;
312
+ this.snapshot();
313
+ // every region announces here, while Glimmer is still rendering, so the
314
+ // barrier knows the whole cast before anybody measures
315
+ join(this);
316
+ }
317
+ return undefined;
318
+ }
319
+ /* eslint-enable ember/no-side-effects */
320
+ snapshot() {
321
+ this.snapshots.clear();
322
+ this.rootSnapshot = this.element?.getBoundingClientRect();
323
+ for (const node of this.participants) {
324
+ const el = node.element;
325
+ if (el?.isConnected && !this.orphans.has(node)) {
326
+ this.snapshots.set(node, measure(el));
327
+ }
328
+ }
329
+ }
330
+ /** phase 1: what changed this pass, measured — nothing is played yet */
331
+ measurePass() {
332
+ this.passPending = false;
333
+ const root = this.element;
334
+ if (!root) {
335
+ return undefined;
336
+ }
337
+ // THE FAST PATH: a volatile re-pass that cannot change the run. On a
338
+ // busy page every app render replays every region's pass, and the full
339
+ // pipeline is destructive even when its verdict is "keep" — release
340
+ // puts the world at rest (stopping the very animations it measures
341
+ // around), and standing them back up once per frame IS the jitter the
342
+ // keep exists to prevent. So a pass with no arrivals, no new leavers
343
+ // and the same timeline tree is declined before anything is touched:
344
+ // the run plays on, undisturbed.
345
+ if (this.fastKeep(root)) {
346
+ return undefined;
347
+ }
348
+ // `initial` was captured before the DOM changed, with any in-flight Move's
349
+ // values still on the elements — that is what was on screen. `final` must
350
+ // be the layout the stylesheet actually asks for, so put everything the
351
+ // last run touched back to rest before measuring anything.
352
+ this.run?.releaseForMeasure();
353
+ // @route: scroll is part of the move (§4.7) — the window is placed where
354
+ // the arriving scene wants it after the swap renders and BEFORE final
355
+ // bounds are measured, so every landing is measured where it will live
356
+ if (this.args.route && this.arrived.size && this.snapshots.size) {
357
+ const intent = this.args.scroll ?? 'top';
358
+ const y = intent === 'top' ? 0 : intent();
359
+ window.scrollTo(0, y);
360
+ }
361
+ const before = this.rootSnapshot ?? root.getBoundingClientRect();
362
+ const after = root.getBoundingClientRect();
363
+ const inserted = [];
364
+ const removed = [];
365
+ const kept = [];
366
+ const sprite = (node, type, initial, final) => ({
367
+ delta: initial && final ? {
368
+ height: final.page.height - initial.page.height,
369
+ width: final.page.width - initial.page.width,
370
+ x: final.page.x - initial.page.x,
371
+ y: final.page.y - initial.page.y
372
+ } : undefined,
373
+ element: node.element,
374
+ final,
375
+ id: node.id,
376
+ initial,
377
+ node,
378
+ role: node.role,
379
+ type
380
+ });
381
+ const candidates = new Set([...this.participants, ...this.claimed, ...this.orphans]);
382
+ for (const node of candidates) {
383
+ const el = node.element;
384
+ if (!el) {
385
+ continue;
386
+ }
387
+ const snap = this.snapshots.get(node);
388
+ if (this.orphans.has(node)) {
389
+ // still in flight from an earlier run: addressable again as removed
390
+ const prior = this.orphanBounds.get(node);
391
+ removed.push(sprite(node, 'removed', prior));
392
+ } else if (this.arrived.has(node)) {
393
+ inserted.push(sprite(node, 'inserted', undefined, boundsOf(measure(el), after)));
394
+ } else if (!el.isConnected || this.claimed.has(node)) {
395
+ if (this.leaving.has(node)) {
396
+ // its Presence has let it go — we already played its exit
397
+ this.leaving.delete(node);
398
+ this.drop(node);
399
+ } else if (snap) {
400
+ removed.push(sprite(node, 'removed', boundsOf(snap, before)));
401
+ } else if (this.claimed.has(node)) {
402
+ this.drop(node);
403
+ }
404
+ } else if (!node.isPresent) {
405
+ if (!this.leaving.has(node) && snap) {
406
+ this.leaving.add(node);
407
+ removed.push(sprite(node, 'removed', boundsOf(snap, before)));
408
+ }
409
+ } else if (snap) {
410
+ this.leaving.delete(node);
411
+ kept.push(sprite(node, 'kept', boundsOf(snap, before), boundsOf(measure(el), after)));
412
+ }
413
+ }
414
+ // an inserted id that matches a removed one: the new element carries the old as its counterpart
415
+ for (const s of inserted) {
416
+ if (s.id === null) {
417
+ continue;
418
+ }
419
+ const old = removed.find(r => r.id === s.id && !r.counterpart);
420
+ if (old) {
421
+ old.claimed = true;
422
+ s.counterpart = old;
423
+ s.initial = old.initial;
424
+ s.type = 'kept';
425
+ s.delta = s.initial && s.final && {
426
+ height: s.final.page.height - s.initial.page.height,
427
+ width: s.final.page.width - s.initial.page.width,
428
+ x: s.final.page.x - s.initial.page.x,
429
+ y: s.final.page.y - s.initial.page.y
430
+ };
431
+ }
432
+ }
433
+ this.arrived.clear();
434
+ this.snapshots.clear();
435
+ const claimed = [...this.claimed];
436
+ this.claimed.clear();
437
+ // the barrier now pairs any id inserted here against the same id removed in
438
+ // ANOTHER region, before finishPass compiles a timeline against the result
439
+ return {
440
+ claimed,
441
+ host: this,
442
+ inserted,
443
+ kept,
444
+ removed,
445
+ root
446
+ };
447
+ }
448
+ /** phase 3: identities are settled — compile this region's timeline and play it */
449
+ finishPass(pass) {
450
+ const {
451
+ claimed,
452
+ inserted,
453
+ kept,
454
+ root
455
+ } = pass;
456
+ // a sprite whose identity was received by another region is not leaving:
457
+ // the element that carries it on is already being animated over there
458
+ const removed = pass.removed.filter(s => !s.sent);
459
+ for (const s of pass.removed) {
460
+ if (s.sent) {
461
+ this.finish(s);
462
+ }
463
+ }
464
+ const after = root.getBoundingClientRect();
465
+ // The zoom every box below was measured under (§6.3) — read from the
466
+ // PAINTED frame, never from the run's bookkeeping. While a camera cue
467
+ // flies on WAAPI the shadow value is one rAF behind the compositor,
468
+ // and inside a fast cue that one frame is the whole flight: fitted
469
+ // zooms cancel the mismatch, but translate targets keep it forever,
470
+ // and a chain of replacement passes compounds it (the reel's loop
471
+ // seams landed every shot hundreds of pixels off, differently per
472
+ // loop). getComputedStyle samples the same frozen timeline the rects
473
+ // above were measured under, so zoom and boxes are one measurement.
474
+ //
475
+ // It is the TOTAL scale between the page and this region's local
476
+ // space: the root's painted width over its LAYOUT width, not the
477
+ // region's own camera alone. Those two are the same number right up
478
+ // until something above the region is scaled, and then they are not.
479
+ // An ancestor `transform: scale(s)` multiplies every box this pass
480
+ // measures while the run still writes local pixels, so every flight
481
+ // is off by exactly s — a region inside a scale(1.5) card starts its
482
+ // flights a thousand pixels from where they belong. Reading the
483
+ // root's own rect subsumes the camera and everything above it in one
484
+ // measurement, taken in the same window as the boxes.
485
+ const layoutWidth = root.offsetWidth;
486
+ const shadow = this.run?.camera.zoom ?? this.restingCamera.zoom;
487
+ const painted = layoutWidth > 0 ? after.width / layoutWidth : 0;
488
+ // …snapped to the exact bookkeeping value whenever the two agree: the
489
+ // layout width is an integer, and letting that quantisation into the
490
+ // divide gives two passes of the same still landings a thousandth of
491
+ // a pixel apart. The painted value only OVERRULES the shadow when
492
+ // they genuinely diverge — which now means "something outside is
493
+ // scaling us" as well as "the camera is mid-cue".
494
+ const measureZoom = !painted || Math.abs(painted - shadow) < 0.004 ? shadow : painted;
495
+ const changeset = new Changeset(inserted.filter(s => s.type === 'inserted'), removed, [...kept, ...inserted.filter(s => s.type === 'kept')],
496
+ // measured in the same window as `final`, and re-measured every pass: a
497
+ // beacon can move without this region rendering at all
498
+ measureBeacons(after), measureZoom,
499
+ // the frame's own size in the same final layout, local pixels — the
500
+ // camera's centre reference, so aim terms are internally consistent
501
+ {
502
+ height: after.height / measureZoom,
503
+ width: after.width / measureZoom
504
+ }, groundOf(root));
505
+ this.changeset = changeset;
506
+ const firstRender = !this.rendered;
507
+ this.rendered = true;
508
+ // instant means instant on a crossing: no run, not a zero-length run
509
+ if (this.args.route && motionSpeed() === 0) {
510
+ for (const node of claimed) {
511
+ this.drop(node);
512
+ }
513
+ for (const s of removed) {
514
+ this.finish(s);
515
+ }
516
+ return;
517
+ }
518
+ // A pass whose changeset is all-kept still runs its timeline (§3.1):
519
+ // the share badge, the hot wire — a Hold with a lifetime fired by an
520
+ // event that inserts, removes and moves nothing. Steps that select
521
+ // change (inserted / removed / moved) produce no cues on such a pass.
522
+ const tree = firstRender ? [] : this.collectTree(root);
523
+ const compiled = tree.length ? compile(tree, changeset) : {
524
+ cues: [],
525
+ gates: []
526
+ };
527
+ const cues = compiled.cues;
528
+ // CONTINUITY (§3.1): sprites the prior run is still driving through
529
+ // space, which this new score does not name, would be dropped to
530
+ // rest in ONE FRAME — the whole-bay snap every conditional timeline
531
+ // had to hand-patch with a `c.moved` twin. The changeset already
532
+ // reads a flying sprite as kept-and-moved (its initial is the painted
533
+ // box, by design), so the score is completed rather than overridden:
534
+ // each gets a continuation move from where it is painted to its rest.
535
+ // Before the empty-score bail on purpose — a timeline that un-renders
536
+ // mid-flight compiles NOTHING, and that bail neither cancels the run
537
+ // nor reasserts the world: the release itself was the snap. A sprite
538
+ // that merely REFLOWED is untouched — it was never driven.
539
+ if (this.run && !this.run.isDone()) {
540
+ const scored = new Set(cues.map(c => c.sprite.node));
541
+ for (const [node, transition] of this.run.midflight()) {
542
+ if (scored.has(node)) {
543
+ continue;
544
+ }
545
+ const s = changeset.kept.find(k => k.node === node);
546
+ if (!s) {
547
+ continue;
548
+ }
549
+ const cue = continuation(s, changeset, transition);
550
+ if (cue) {
551
+ cues.push(cue);
552
+ }
553
+ }
554
+ }
555
+ if (!cues.length) {
556
+ // nothing to play: let every leaver go now
557
+ for (const node of claimed) {
558
+ this.drop(node);
559
+ }
560
+ for (const s of removed) {
561
+ // §5.3, from the other side of the seam: on a pass this region
562
+ // declines to animate, a <Presence>-managed leaver still mid-exit
563
+ // belongs to its Presence — calling exitComplete here would cut
564
+ // that animation short. The region stands aside; when the exit
565
+ // ends, Presence unmounts the element and it leaves for real.
566
+ if (s.node.presenceManaged && !s.node.isPresent && s.element.isConnected && !this.orphans.has(s.node)) {
567
+ this.leaving.delete(s.node);
568
+ continue;
569
+ }
570
+ this.finish(s);
571
+ }
572
+ return;
573
+ }
574
+ // An unrelated render replays every region's pass — the detector is
575
+ // volatile by design, so on a busy page (the gallery: a neighbouring
576
+ // demo writing tracked state per frame) a region re-passes on every
577
+ // app render. A finished run replays (§3.1's Hold re-fire). An
578
+ // all-still pass — no arrivals, leavers, or moved boxes — is noise
579
+ // (a parent writing `hot`, a neighbour ticking at 60fps) and must
580
+ // not restart the clock. Gates and path-moves make a freshly compiled
581
+ // cue list incomparable (zero-delta Moves drop; flights exist), so
582
+ // sameScore cannot be the only detector.
583
+ //
584
+ // A removed sprite that is one of this region's own ORPHANS is not a
585
+ // new leaver: it re-enters every changeset on purpose (addressable
586
+ // again), and while one is aloft a recompiled score is NOT
587
+ // authoritative — the pairing that produced the run's flights lived
588
+ // in the pass that removed and inserted together, and a later pass
589
+ // sees only the orphan half. Counting orphans as dirt meant a
590
+ // crossing on a busy page was cancelled into a leave-only rump by
591
+ // its own neighbours' renders, every frame.
592
+ const print = treePrint(tree);
593
+ const nothingNew = !inserted.length && pass.removed.every(s => this.orphans.has(s.node)) && (
594
+ // A sprite mid-flight reads as "moved" on every re-pass — its initial
595
+ // is the painted box, by design — so while this run's own orphans are
596
+ // aloft, kept-motion cannot justify a recompile either: the score it
597
+ // would produce is the degraded one above.
598
+ this.orphans.size > 0 || !changeset.sprites({
599
+ type: 'moved'
600
+ }).length) &&
601
+ // …but an EDITED timeline is never noise: a changed tree replays,
602
+ // exactly as it would with no orphans aloft (the build-order
603
+ // transport's delay nudge, made while a leaver is frozen mid-park)
604
+ print === this.scorePrint;
605
+ if (this.run && !this.run.isDone() && nothingNew) {
606
+ for (const node of claimed) {
607
+ this.drop(node);
608
+ }
609
+ // the measurement above put the run's world at rest; a keep must be
610
+ // invisible, so the run stands its picture back up (see reassert)
611
+ this.run.reassert();
612
+ return;
613
+ }
614
+ // dirty but the compiled score is the one already in flight (tween-only
615
+ // measurement jitter): keep that run too.
616
+ //
617
+ // The TREE has to be the one in flight as well, not just the cues it
618
+ // compiled to. Identical cues are not proof of an identical timeline:
619
+ // a score that plays a slice of itself — a chapter skip that re-cuts
620
+ // to the head it is already inside — compiles to the same cue list at
621
+ // the same offsets, and keeping the run there hands the "new" film
622
+ // the old one's CLOCK. The score restarts on paper and carries on
623
+ // playing in fact: beats land early, some never land at all, and the
624
+ // picture runs ahead of the words that name it. An author who wants a
625
+ // re-execution says so by editing the tree (a fresh sequence name is
626
+ // the usual way, which is why the name is in the print).
627
+ if (this.run && !this.run.isDone() && !inserted.length && !pass.removed.length && !compiled.gates.length && print === this.scorePrint && sameScore(this.run.cues, cues)) {
628
+ for (const node of claimed) {
629
+ this.drop(node);
630
+ }
631
+ this.run.reassert();
632
+ return;
633
+ }
634
+ // the removed participants the run names stay; the rest go now
635
+ const named = new Set(cues.map(c => c.sprite));
636
+ for (const s of [...kept, ...inserted]) {
637
+ if (s.counterpart && named.has(s)) {
638
+ named.add(s.counterpart);
639
+ }
640
+ }
641
+ const prior = this.run;
642
+ // Which camera the replacement starts from is decided by WHAT it is.
643
+ // The same score recompiled mid-run is a re-execution of the same
644
+ // timeline: it inherits the prior run's fold origin, so a seek's
645
+ // reconstruction folds the score's prefix once and only once —
646
+ // handing it the pose in force would fold every relative cue on top
647
+ // of the pose that cue already produced, and the world doubles
648
+ // (world-dock-test). A NEW score responds to the world as it stands:
649
+ // it starts from the pose actually applied, which mid-flight is the
650
+ // prior run's live shadow, not the last landed state.
651
+ const inherited = prior !== undefined && !prior.isDone() ? print === this.scorePrint ? {
652
+ aim: prior.initialAim,
653
+ camera: prior.initialCamera
654
+ } : {
655
+ aim: prior.cameraAim,
656
+ camera: prior.camera
657
+ } : {
658
+ aim: prior?.cameraAim ?? null,
659
+ camera: this.restingCamera
660
+ };
661
+ // the shot carries run to run for the same reason the frame does: a
662
+ // replacement pass must continue the move, not snap back to framing
663
+ const shot3d = prior !== undefined && !prior.isDone() ? prior.camera3d : this.resting3d;
664
+ prior?.cancel(new Set([...named].map(s => s.node)));
665
+ for (const s of removed) {
666
+ if (!named.has(s)) {
667
+ this.finish(s);
668
+ } else if (!s.element.isConnected || claimed.includes(s.node)) {
669
+ this.orphan(s, removed, s.claimed === true);
670
+ }
671
+ }
672
+ if (this.args.debug) {
673
+ // the hierarchy lint (§6.3): a removed participant no step names will
674
+ // simply vanish — the uncanny valley the deck warns about
675
+ for (const s of removed) {
676
+ if (!named.has(s)) {
677
+ console.warn(`choreo: removed participant '${s.id ?? s.role}' is named by ` + 'no step — it vanishes without a frame');
678
+ }
679
+ }
680
+ this.log(changeset, cues);
681
+ }
682
+ this.scorePrint = print;
683
+ // taken here, with the world released and measured: the resting layout
684
+ // this run is about to fly away from (see fastKeep)
685
+ this.layoutPrint = this.layoutOf();
686
+ // @quiet: pause everything running RIGHT NOW — the run's own animations
687
+ // do not exist yet, so they are exempt by construction. Accumulated,
688
+ // not replaced: an interrupted run's replacement sweeps again, and
689
+ // everything resumes together when the run that survives settles.
690
+ if (this.args.quiet) {
691
+ this.quiet();
692
+ }
693
+ this.run = execute(compiled, {
694
+ camera: {
695
+ ...inherited.camera
696
+ },
697
+ camera3d: {
698
+ ...shot3d
699
+ },
700
+ cameraFrame: this.element,
701
+ // the aim point in force carries run to run, so a re-aim lerps from
702
+ // what is actually applied rather than assuming an unaimed frame —
703
+ // except a same-score re-execution, which carries the fold origin
704
+ cameraAim: inherited.aim,
705
+ // updated at step boundaries only — a still value app logic can
706
+ // read. Guarded by equality: the landing itself renders, the render
707
+ // is an all-kept pass, and the pass replays the camera step — an
708
+ // unguarded set would revalidate forever.
709
+ onCamera3D: state => {
710
+ this.resting3d = state;
711
+ this.args.onCamera3D?.(state);
712
+ },
713
+ onCamera: state => {
714
+ const prior = this.restingCamera;
715
+ this.restingCamera = state;
716
+ if (prior.zoom !== state.zoom || prior.x !== state.x || prior.y !== state.y) {
717
+ // the library's own host hook — the Ember adapter installs the
718
+ // runloop's afterRender behind it, and this file stays host-blind
719
+ postRender(() => this.cameraState = state);
720
+ }
721
+ },
722
+ raisedLayer: this.raisedLayer,
723
+ tetherLayer: this.tetherLayer,
724
+ // ember-animated's continuity, in the terms motion-dom offers: it sums a
725
+ // corrective curve onto the one it interrupted, which transfers velocity
726
+ // implicitly; a spring takes a velocity outright, so the run that is
727
+ // being replaced hands over how fast everything was going.
728
+ inherit: prior?.velocities,
729
+ onPerform: this.args.onPerform,
730
+ onPerformReset: this.args.onPerformReset,
731
+ onSpriteDone: s => this.finish(s),
732
+ removed: removed.filter(s => named.has(s)),
733
+ // nothing in this region is staying: no participant kept, none
734
+ // arriving, and every one it can see on its way out. A region in that
735
+ // state is not watching a departure, it is inside one — see `park`.
736
+ doomed: !kept.length && !inserted.length && removed.length > 0
737
+ });
738
+ if (this.args.quiet) {
739
+ const run = this.run;
740
+ void run.finished.then(() => {
741
+ // only the run still in charge hands the page back — a cancelled
742
+ // one was replaced, and its replacement holds the pause
743
+ if (this.run === run) {
744
+ this.unquiet();
745
+ }
746
+ });
747
+ }
748
+ }
749
+ /* ---- orphans: a removed participant kept alive, locked where it was ---- */
750
+ orphanBounds = new Map();
751
+ /** the fingerprint of the tree the current run compiled from (see treePrint) */
752
+ scorePrint;
753
+ /** the layout every participant stood in when the run compiled (see layoutPrint) */
754
+ layoutPrint;
755
+ /**
756
+ * Where the stylesheet puts each participant, as a string.
757
+ *
758
+ * `offsetLeft/Top/Width/Height` and NOT a bounding rect, because the
759
+ * difference is the whole point: a rect includes the transform a flight
760
+ * is writing, so a moving element reads as different on every frame,
761
+ * while offsets are the box layout asked for — steady under a transform,
762
+ * and different the moment something actually reflows. Integers, too,
763
+ * which quietly forgives the sub-pixel noise a busy page generates.
764
+ *
765
+ * A Move that animates real width/height (`@size={{true}}`) does change
766
+ * these, so such a run never takes the fast path. That is the honest
767
+ * answer rather than a missed optimisation: a run writing layout cannot
768
+ * be told apart from a page writing layout without measuring.
769
+ *
770
+ * Offsets are accumulated up the offset chain to the region, not read
771
+ * at the participant alone, because what a render moves may be an
772
+ * ANCESTOR that is no participant of anything — Escort's seat, one
773
+ * positioned wrapper carrying three participants. Read flat, the
774
+ * fingerprint was blind to exactly the reflow a retarget makes, the
775
+ * pass was declined, and the old run played on against the moved
776
+ * layout: card, badge and shadow teleported a whole bay, together. The
777
+ * walk stops at the region's edge so the page above it stays out of the
778
+ * print — a busy neighbour reflowing above the region must not cancel
779
+ * its runs.
780
+ */
781
+ layoutOf() {
782
+ const root = this.element;
783
+ const parts = [];
784
+ for (const node of this.participants) {
785
+ const el = node.element;
786
+ if (!el || !el.isConnected) {
787
+ continue;
788
+ }
789
+ let x = 0;
790
+ let y = 0;
791
+ let walk = el;
792
+ while (walk && walk !== root && root?.contains(walk)) {
793
+ x += walk.offsetLeft;
794
+ y += walk.offsetTop;
795
+ const parent = walk.offsetParent;
796
+ walk = parent instanceof HTMLElement ? parent : null;
797
+ }
798
+ parts.push(`${node.id ?? node.role ?? ''}@${x},${y},` + `${el.offsetWidth},${el.offsetHeight}`);
799
+ }
800
+ return parts.join('|');
801
+ }
802
+ /**
803
+ * Can this pass be declined outright? Only while a run is in flight, and
804
+ * only when nothing the pass could discover would change it: no arrivals,
805
+ * no retentions pending, every participant either present or already a
806
+ * known leaver, the collected timeline tree fingerprints identical to the
807
+ * one the run compiled from (an EDIT always replays), and every
808
+ * participant standing in the same layout box it compiled in (a REFLOW
809
+ * always replays — it is what a Move is FOR). Anything else takes the
810
+ * full pipeline.
811
+ */
812
+ fastKeep(root) {
813
+ if (!this.run || this.run.isDone() || this.claimed.size || this.arrived.size) {
814
+ return false;
815
+ }
816
+ // A HELD RUN CANNOT BE MEASURED AROUND.
817
+ //
818
+ // Everything below this line decides "nothing changed" by looking at the
819
+ // page — and while a run is holding, what the page reports is what the
820
+ // RUN wrote. A Move keeps inline width and height on its sprites for as
821
+ // long as it owns them, and `layoutOf` reads exactly those. So a run
822
+ // parked at the pose it compiled TO reports a layout identical to the
823
+ // one recorded when it compiled, and a genuine change to the resting
824
+ // layout arrives on a pass that declines itself: the score is never
825
+ // recompiled and the new pose never lands.
826
+ //
827
+ // That is not hypothetical. It is what a host scrubbing `run.time` hits
828
+ // every time it swaps to a neighbouring segment — the swap happens at a
829
+ // waypoint, and a waypoint is the pose the outgoing run is parked at by
830
+ // definition.
831
+ //
832
+ // Measuring the resting layout instead would mean releasing the run, and
833
+ // releasing is the destructive thing this path exists to avoid. So the
834
+ // rule is simply: the fast path is for a run flying under its own clock.
835
+ // A run whose clock somebody else is holding takes the full pipeline —
836
+ // which costs nothing in practice, because a host that owns a clock is
837
+ // not the busy-gallery case this path was written for. It scrubs; it
838
+ // does not re-render.
839
+ if (this.run.paused) {
840
+ return false;
841
+ }
842
+ for (const node of this.participants) {
843
+ const el = node.element;
844
+ if (!el || !el.isConnected) {
845
+ return false;
846
+ }
847
+ if (!node.isPresent && !this.leaving.has(node) && !this.orphans.has(node)) {
848
+ return false;
849
+ }
850
+ }
851
+ return treePrint(this.collectTree(root)) === this.scorePrint && this.layoutOf() === this.layoutPrint;
852
+ }
853
+ /* ---- @quiet: the rest of the page, paused for the span of a run ---- */ /** what quiet() paused, accumulated across replaced runs, resumed as one */
854
+ quieted = [];
855
+ quiet() {
856
+ for (const animation of document.getAnimations()) {
857
+ if (animation.playState === 'running') {
858
+ animation.pause();
859
+ this.quieted.push(animation);
860
+ }
861
+ }
862
+ }
863
+ unquiet() {
864
+ for (const animation of this.quieted.splice(0)) {
865
+ try {
866
+ animation.play();
867
+ } catch {
868
+ // an animation whose element left with the old scene: nothing to resume
869
+ }
870
+ }
871
+ }
872
+ orphan(s, removed, extract = false) {
873
+ // Only the topmost removed element is moved; what it contains comes with
874
+ // it — EXCEPT a claimed skin. Its whole job is to cross inside the flight,
875
+ // and riding a fading ancestor instead is the View Transitions nesting
876
+ // rule (never name a container of named things) sneaking back in. A
877
+ // claimed sprite is lifted out of the doomed subtree; both end up locked
878
+ // to the same page coordinates in the same layer, so the lift is
879
+ // invisible — all it leaves behind is a hole in the ancestor, which is
880
+ // exactly where the eye expects one while the skin is elsewhere.
881
+ const inside = removed.some(o => o !== s && o.element !== s.element && o.element.contains(s.element));
882
+ if (inside && !extract || !s.initial) {
883
+ return;
884
+ }
885
+ const layer = this.orphanLayer;
886
+ const root = this.element;
887
+ if (!layer || !root) {
888
+ return;
889
+ }
890
+ // Page measurements carry every transform above the region — its own
891
+ // camera, and whatever is carrying the whole region this frame (a
892
+ // page crossing scaling the stage). The lock is written as LOCAL
893
+ // pixels inside the equally-transformed orphan layer, so the scale
894
+ // must come back out or it is applied twice and the leaver jumps the
895
+ // moment it is lifted — the raise learned this same rule.
896
+ const now = root.getBoundingClientRect();
897
+ const sx = root.offsetWidth ? now.width / root.offsetWidth : 1;
898
+ const sy = root.offsetHeight ? now.height / root.offsetHeight : 1;
899
+ if (inside) {
900
+ // hold the seat: the ancestor is itself a leaver, and without this its
901
+ // remaining children reflow into the gap mid-fade. The placeholder
902
+ // lives only inside that doomed subtree, so it leaves with it.
903
+ const seat = document.createElement('div');
904
+ seat.style.width = `${s.initial.page.width / sx}px`;
905
+ seat.style.height = `${s.initial.page.height / sy}px`;
906
+ s.element.parentElement?.insertBefore(seat, s.element);
907
+ }
908
+ // locked where it was ON THE PAGE: the region itself may have moved in the same pass
909
+ const {
910
+ page
911
+ } = s.initial;
912
+ const el = s.element;
913
+ el.style.position = 'absolute';
914
+ el.style.left = `${(page.x - now.left) / sx}px`;
915
+ el.style.top = `${(page.y - now.top) / sy}px`;
916
+ el.style.width = `${page.width / sx}px`;
917
+ el.style.height = `${page.height / sy}px`;
918
+ el.style.boxSizing = 'border-box';
919
+ el.style.margin = '0';
920
+ el.style.pointerEvents = 'none';
921
+ // one texture, transformed — not a subtree re-rastered per frame
922
+ el.style.willChange = 'transform, opacity';
923
+ layer.appendChild(el);
924
+ this.orphans.add(s.node);
925
+ this.orphanBounds.set(s.node, s.initial);
926
+ }
927
+ /** a removed sprite's row has ended */
928
+ finish(s) {
929
+ if (this.orphans.has(s.node) || this.participants.has(s.node)) {
930
+ if (!s.element.isConnected || this.orphans.has(s.node)) {
931
+ this.drop(s.node);
932
+ } else if (!s.node.isPresent) {
933
+ s.node.exitComplete();
934
+ }
935
+ } else {
936
+ this.drop(s.node);
937
+ }
938
+ }
939
+ drop(node) {
940
+ const el = node.element;
941
+ if (el && this.orphans.has(node) && el.parentElement === this.orphanLayer) {
942
+ el.remove();
943
+ }
944
+ this.orphans.delete(node);
945
+ this.orphanBounds.delete(node);
946
+ this.claimed.delete(node);
947
+ this.leaving.delete(node);
948
+ this.participants.delete(node);
949
+ node.release();
950
+ }
951
+ /* ---- debug ---- */
952
+ log(changeset, cues) {
953
+ const id = this.args.id ?? '';
954
+ const row = s => ({
955
+ choreo: id,
956
+ final: s.final ? JSON.stringify(s.final.context) : null,
957
+ id: s.id,
958
+ initial: s.initial ? JSON.stringify(s.initial.context) : null,
959
+ role: s.role,
960
+ type: s.type
961
+ });
962
+ console.table(changeset.all.map(row));
963
+ console.table(cues.map(c => ({
964
+ choreo: id,
965
+ duration: Math.round(c.duration),
966
+ sprite: c.sprite.id ?? c.sprite.role,
967
+ start: Math.round(c.start),
968
+ step: c.kind,
969
+ values: JSON.stringify(c.target ?? c.hold?.values ?? null)
970
+ })));
971
+ }
972
+ /* ---- template ---- */
973
+ host = modifier(el => {
974
+ this.element = el;
975
+ setChoreoHost(el, this);
976
+ ensureOrphanStyle();
977
+ if (getComputedStyle(el).position === 'static') {
978
+ el.style.position = 'relative';
979
+ }
980
+ if (this.args.debug) {
981
+ el.style.outline = '1px dashed #2563eb';
982
+ if (!debugStyle) {
983
+ debugStyle = document.createElement('style');
984
+ debugStyle.textContent = '[data-choreo-orphans] > * { outline: 1px dotted #dc2626 !important; }';
985
+ document.head.appendChild(debugStyle);
986
+ }
987
+ }
988
+ return () => {
989
+ setChoreoHost(el, undefined);
990
+ this.element = undefined;
991
+ };
992
+ });
993
+ layer = modifier(el => {
994
+ this.orphanLayer = el;
995
+ return () => {
996
+ this.orphanLayer = undefined;
997
+ };
998
+ });
999
+ raised = modifier(el => {
1000
+ this.raisedLayer = el;
1001
+ return () => {
1002
+ this.raisedLayer = undefined;
1003
+ };
1004
+ });
1005
+ tethers = modifier(el => {
1006
+ this.tetherLayer = el;
1007
+ return () => {
1008
+ this.tetherLayer = undefined;
1009
+ };
1010
+ });
1011
+ /**
1012
+ * The host is the scene: participants are its DIRECT children, so the
1013
+ * author's own grid/flex layout on the region element applies to them —
1014
+ * a wrapper here would silently unhook every gap and track. c.Camera
1015
+ * therefore drives the host element itself (§6.3), and the overlay
1016
+ * layers ride the frame with the content they annotate.
1017
+ */
1018
+ static {
1019
+ setComponentTemplate(precompileTemplate("{{this.renderDetector}}\n<div data-choreo={{if @id @id \"\"}} data-motion-host {{this.host}} ...attributes>\n <div data-choreo-orphans style=\"position:absolute;inset:0;pointer-events:none;overflow:visible\" {{this.layer}}></div>\n {{!-- the elevated layer: where c.Raise promotes the living \u2014 above every\n stacking context and clip in the region (\xA76.3) --}}\n <div data-choreo-raised style=\"position:absolute;inset:0;pointer-events:none;overflow:visible;z-index:2147483000\" {{this.raised}}></div>\n {{!-- the wires: geometry drawn between sprites, every frame (\xA76.1) --}}\n <svg data-choreo-tethers style=\"position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible\" {{this.tethers}}></svg>\n {{yield this.context}}\n</div>", {
1020
+ strictMode: true
1021
+ }), this);
1022
+ }
1023
+ }
1024
+
1025
+ export { Choreo, Choreo as default };
1026
+ //# sourceMappingURL=choreo.js.map