@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
@@ -0,0 +1,1403 @@
1
+ import { spring, calcGeneratorDuration, maxGeneratorDuration } from 'motion-dom';
2
+ import { isGestureRef, gestureBounds, gestureVelocity } from './gesture.js';
3
+
4
+ /**
5
+ * boxel-motion's OrchestrationMatrix, as a cue list: the timeline tree becomes
6
+ * one (sprite, start, duration) per step per sprite. Sequences add, parallels
7
+ * overlay; a spring's length is computed with the engine's own generator so a
8
+ * sequence can follow one — the thing the legacy could not do.
9
+ */
10
+ const isBlock = node => node.kind === 'sequence' || node.kind === 'parallel';
11
+ const isGate = node => node.kind === 'gate';
12
+ const DEFAULT_SPRING = {
13
+ damping: 30,
14
+ stiffness: 300
15
+ };
16
+
17
+ /**
18
+ * What `c.Follow` is allowed to drive: paint, never layout. See the throw
19
+ * in the 'follow' case for why the line is drawn exactly here.
20
+ */
21
+ const DERIVABLE = new Set(['filter', 'opacity', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'skewX', 'skewY', 'x', 'y', 'z']);
22
+
23
+ /** the shared offstage <svg> that path geometry is sampled inside */
24
+ let pathHost;
25
+ function samplePath(d, samples = 64) {
26
+ if (!pathHost) {
27
+ pathHost = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
28
+ pathHost.setAttribute('aria-hidden', 'true');
29
+ pathHost.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
30
+ document.body.appendChild(pathHost);
31
+ }
32
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
33
+ el.setAttribute('d', d);
34
+ pathHost.appendChild(el);
35
+ try {
36
+ const length = el.getTotalLength();
37
+ return Array.from({
38
+ length: samples + 1
39
+ }, (_, i) => {
40
+ const pt = el.getPointAtLength(length * i / samples);
41
+ return {
42
+ x: pt.x,
43
+ y: pt.y
44
+ };
45
+ });
46
+ } finally {
47
+ el.remove();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Similarity-map a drawn path so its start is the sprite's start and its end
53
+ * is the measured landing (§4.4): the path bends the journey, never the
54
+ * destination. Complex multiplication: m = D / v rotates and scales the
55
+ * author's stroke onto the real displacement.
56
+ */
57
+ function mapFlight(raw, dx, dy) {
58
+ const s0 = raw[0];
59
+ const e = raw[raw.length - 1];
60
+ const vx = e.x - s0.x;
61
+ const vy = e.y - s0.y;
62
+ const mag = vx * vx + vy * vy;
63
+ if (mag < 1e-6) {
64
+ // a degenerate stroke: fall back to the straight line
65
+ return raw.map((_, i) => ({
66
+ x: dx * i / (raw.length - 1),
67
+ y: dy * i / (raw.length - 1)
68
+ }));
69
+ }
70
+ // m = (dx + i·dy) / (vx + i·vy)
71
+ const mr = (dx * vx + dy * vy) / mag;
72
+ const mi = (dy * vx - dx * vy) / mag;
73
+ return raw.map(pt => {
74
+ const px = pt.x - s0.x;
75
+ const py = pt.y - s0.y;
76
+ return {
77
+ x: px * mr - py * mi,
78
+ y: px * mi + py * mr
79
+ };
80
+ });
81
+ }
82
+ const resolve = (v, sprite, cs) => typeof v === 'function' ? v(sprite, cs) : v;
83
+ const single = v => Array.isArray(v) ? v[0] : v;
84
+ const num = v => {
85
+ if (typeof v === 'number') {
86
+ return v;
87
+ }
88
+ const n = parseFloat(String(v));
89
+ return Number.isNaN(n) ? 0 : n;
90
+ };
91
+
92
+ /** the value a spring starts from when the step does not say: the engine's, else the computed style's */
93
+ function currentValue(sprite, key) {
94
+ const ve = sprite.node.visualElement;
95
+ if (ve?.hasValue(key)) {
96
+ return num(ve.getValue(key).get());
97
+ }
98
+ const latest = ve?.latestValues?.[key];
99
+ if (latest !== undefined) {
100
+ return num(latest);
101
+ }
102
+ const computed = getComputedStyle(sprite.element).getPropertyValue(key.replace(/[A-Z]/g, m => '-' + m.toLowerCase()));
103
+ return num(computed);
104
+ }
105
+ function springDuration(from, to, spec) {
106
+ if (from === to) {
107
+ return 0;
108
+ }
109
+ const generator = spring({
110
+ keyframes: [from, to],
111
+ ...spec
112
+ });
113
+ return Math.min(calcGeneratorDuration(generator), maxGeneratorDuration);
114
+ }
115
+
116
+ /** a cue before it is placed on the run's clock; `offset` is its rung of the stagger ladder */
117
+
118
+ /**
119
+ * The rung each index takes on the stagger ladder, under a delivery order.
120
+ * 'center' delivers from the middle outward; 'random' shuffles per run —
121
+ * seeded input is the native driver's requirement, not this one's.
122
+ */
123
+ function ladder(n, order) {
124
+ const ranks = Array.from({
125
+ length: n
126
+ }, (_, i) => i);
127
+ switch (order) {
128
+ case 'reverse':
129
+ return ranks.map(i => n - 1 - i);
130
+ case 'center':
131
+ {
132
+ const mid = (n - 1) / 2;
133
+ const byDistance = [...ranks].sort((a, b) => Math.abs(a - mid) - Math.abs(b - mid));
134
+ const out = new Array(n);
135
+ byDistance.forEach((index, rank) => out[index] = rank);
136
+ return out;
137
+ }
138
+ case 'random':
139
+ {
140
+ const shuffled = [...ranks];
141
+ for (let i = n - 1; i > 0; i--) {
142
+ const j = Math.floor(Math.random() * (i + 1));
143
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
144
+ }
145
+ const out = new Array(n);
146
+ shuffled.forEach((index, rank) => out[index] = rank);
147
+ return out;
148
+ }
149
+ default:
150
+ return ranks;
151
+ }
152
+ }
153
+ function resolveTarget(step, sprite, cs) {
154
+ const target = {};
155
+ let longest = 0;
156
+ for (const key in step.props) {
157
+ const to = resolve(step.props[key], sprite, cs);
158
+ // A keyframe array is the from-and-to (and any waypoints) in one value —
159
+ // `@opacity={{array 0 1}}` — which is why there is no property-hash @from.
160
+ if (Array.isArray(to)) {
161
+ if (step.kind === 'spring' && to.length > 2) {
162
+ throw new Error('choreo: a spring animates between two keyframes; ' + `\`${key}\` was given ${to.length}`);
163
+ }
164
+ target[key] = to;
165
+ if (step.kind === 'spring') {
166
+ longest = Math.max(longest, springDuration(num(to[0]), num(to[to.length - 1]), step.spring ?? DEFAULT_SPRING));
167
+ }
168
+ } else {
169
+ target[key] = to;
170
+ if (step.kind === 'spring') {
171
+ longest = Math.max(longest, springDuration(currentValue(sprite, key), num(to), step.spring ?? DEFAULT_SPRING));
172
+ }
173
+ }
174
+ }
175
+ return {
176
+ longest,
177
+ target
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The two boxes a shape-matched flight aligns: the declared subjects
183
+ * ([data-choreo-substance], or pack="content" ink) when either end has
184
+ * one — Keynote matches OBJECTS, not slide frames — with the undeclared
185
+ * end derived by fraction (content laid out proportionally keeps one
186
+ * fraction at both scales; the return trip's card, whose demo has not
187
+ * boarded yet, has nothing to measure). Neither end declared: the frames
188
+ * themselves.
189
+ */
190
+ function matchBoxes(initial, final, from, to, z) {
191
+ const subI = initial.substance && descale(initial.substance, z);
192
+ const subF = final.substance && descale(final.substance, z);
193
+ if (!subI && !subF) {
194
+ return {
195
+ from,
196
+ to
197
+ };
198
+ }
199
+ const map = (s, a, b) => ({
200
+ height: s.height / (a.height || 1) * b.height,
201
+ width: s.width / (a.width || 1) * b.width,
202
+ x: b.x + (s.x - a.x) / (a.width || 1) * b.width,
203
+ y: b.y + (s.y - a.y) / (a.height || 1) * b.height
204
+ });
205
+ return {
206
+ from: subI ?? map(subF, to, from),
207
+ to: subF ?? map(subI, from, to)
208
+ };
209
+ }
210
+ const centreOf = r => ({
211
+ x: r.x + r.width / 2,
212
+ y: r.y + r.height / 2
213
+ });
214
+
215
+ /** a page-space box, divided back into local space by the measure-time zoom */
216
+ function descale(r, z) {
217
+ return z === 1 ? r : {
218
+ height: r.height / z,
219
+ width: r.width / z,
220
+ x: r.x / z,
221
+ y: r.y / z
222
+ };
223
+ }
224
+ function resolveMove(step, sprite, cs) {
225
+ // A beacon — or the live gesture — rewrites one end of the flight. `@from`
226
+ // gives an inserted sprite a start it never had (out of the compose button,
227
+ // or from wherever the finger let go); `@to` gives a removed one an end it
228
+ // never reaches. Everything after this is the ordinary FLIP — rewriting the
229
+ // bounds IS the whole implementation. A name nothing claimed leaves that
230
+ // end alone.
231
+ const size = (sprite.final ?? sprite.initial)?.page ?? {
232
+ height: 0,
233
+ width: 0};
234
+ const hot = step.from && isGestureRef(step.from);
235
+ const borrowedFrom = step.from ? hot ? gestureBounds(size) : cs.beacon(step.from.beacon) : null;
236
+ const borrowedTo = step.to ? cs.beacon(step.to.beacon) : null;
237
+ const initial = borrowedFrom ?? sprite.initial;
238
+ const final = borrowedTo ?? sprite.final;
239
+ if (!initial || !final) {
240
+ return null;
241
+ }
242
+ // Page space by default: the one space two regions agree on, and the region
243
+ // itself can move in the very pass that moves its children. 'parent'
244
+ // resolves against the sprite's own container instead (§6.1). Either way,
245
+ // the measurement was taken through the camera's transform and the values
246
+ // will be written as local inline pixels — so both boxes are divided back
247
+ // by the zoom the world was measured under (§6.3).
248
+ const z = cs.measureZoom ?? 1;
249
+ const parentSpace = step.space === 'parent' && !borrowedFrom && !borrowedTo;
250
+ const from = descale(parentSpace ? initial.parent : initial.page, z);
251
+ const to = descale(parentSpace ? final.parent : final.page, z);
252
+ const target = {};
253
+ const pairs = [];
254
+ // 'scale' matches shape by transform (MoveStep.size): position runs
255
+ // centre-to-centre over the MATCH boxes — the declared subjects when
256
+ // either end has one, the frames otherwise. Layout modes run
257
+ // corner-to-corner with the real size animating alongside.
258
+ const scaleMode = step.size === 'scale' || step.size === 'crop';
259
+ const cropMode = step.size === 'crop';
260
+ const m = scaleMode ? matchBoxes(initial, final, from, to, z) : null;
261
+ const dx = m ? centreOf(m.to).x - centreOf(m.from).x : to.x - from.x;
262
+ // 'crop' pins the TOPS of the two subjects, not their centres — the
263
+ // matching-snapshot rule (§4.7): the entering subject stands with its
264
+ // top on the exiting subject's top, and overflow pays at the BOTTOM
265
+ const dy = m ? cropMode ? m.to.y - m.from.y : centreOf(m.to).y - centreOf(m.from).y : to.y - from.y;
266
+ /**
267
+ * Which end the element is actually sitting on decides the sign.
268
+ *
269
+ * The usual FLIP case is a kept sprite: the pass has already put it at its
270
+ * destination, so it is pulled back by the delta and animated to zero. A
271
+ * REMOVED sprite is the other way round — the region has locked it in the
272
+ * orphan layer at the place it was, so it starts at zero and travels the
273
+ * delta. That is the only case that can have a `final` at all, and it only
274
+ * has one because a beacon lent it one.
275
+ */
276
+ const holdsStart = sprite.type === 'removed';
277
+ const leg = d => holdsStart ? [0, d] : [-d, 0];
278
+ let flight;
279
+ if (step.path && (dx !== 0 || dy !== 0)) {
280
+ // the path owns position; FLIP still owns size
281
+ flight = {
282
+ points: mapFlight(samplePath(step.path), dx, dy),
283
+ rest: holdsStart ? {
284
+ x: 0,
285
+ y: 0
286
+ } : {
287
+ x: dx,
288
+ y: dy
289
+ },
290
+ rotate: step.rotate
291
+ };
292
+ pairs.push(leg(Math.hypot(dx, dy)));
293
+ } else if (m) {
294
+ // The transform scales about the element's own centre, so an
295
+ // off-centre match box needs a correction on the translate: the
296
+ // flight pins the MATCH box — the subject — through the move, and
297
+ // the frame simply comes along. With frame-matching the correction
298
+ // is zero and this is the plain centre-to-centre leg.
299
+ //
300
+ // 'crop' (the matching-snapshot rule, §4.7): the scale is UNIFORM
301
+ // and derived from WIDTH alone — the entering subject is stretched
302
+ // or shrunk to the exiting subject's width, filling it, never
303
+ // matched by cover — with the tops pinned and any vertical overflow
304
+ // carried by an animated crop window that pays at the BOTTOM only.
305
+ // Nothing ever stretches; 'scale' stretches per axis instead, and
306
+ // the crossfade hides it.
307
+ const crop = cropMode;
308
+ const rx = m.from.width / (m.to.width || 1) || 1;
309
+ const ry = m.from.height / (m.to.height || 1) || 1;
310
+ const su = rx;
311
+ const sx = crop ? su : rx;
312
+ const sy = crop ? su : ry;
313
+ const fx = holdsStart ? 1 / sx : sx;
314
+ const fy = holdsStart ? 1 / sy : sy;
315
+ const anchor = centreOf(holdsStart ? from : to);
316
+ const matched = holdsStart ? m.from : m.to;
317
+ // the pinned point: top-centre under 'crop', centre under 'scale'
318
+ // (an exact per-axis map makes the two identical there)
319
+ const pinned = crop ? {
320
+ x: centreOf(matched).x,
321
+ y: matched.y
322
+ } : centreOf(matched);
323
+ const cx = (1 - fx) * (pinned.x - anchor.x);
324
+ const cy = (1 - fy) * (pinned.y - anchor.y);
325
+ const x = holdsStart ? dx + cx : -dx + cx;
326
+ const y = holdsStart ? dy + cy : -dy + cy;
327
+ if (x !== 0) {
328
+ target['x'] = holdsStart ? [0, x] : [x, 0];
329
+ pairs.push(holdsStart ? [0, x] : [x, 0]);
330
+ }
331
+ if (y !== 0) {
332
+ target['y'] = holdsStart ? [0, y] : [y, 0];
333
+ pairs.push(holdsStart ? [0, y] : [y, 0]);
334
+ }
335
+ if (crop) {
336
+ if (fx !== 1) {
337
+ target['scale'] = holdsStart ? [1, fx] : [fx, 1];
338
+ }
339
+ // the crop window at the FAR pose: the other end's SUBJECT sets
340
+ // the floor — anything of this element that runs past that
341
+ // subject's bottom is cropped, and nothing else is. Pulled back
342
+ // through the transform into this element's own space.
343
+ const frame = holdsStart ? from : to;
344
+ const window = holdsStart ? m.to : m.from;
345
+ const f = holdsStart ? fx : sx;
346
+ const d = {
347
+ y
348
+ };
349
+ const pre = edge => anchor.y + (edge - anchor.y - d.y) / f;
350
+ const clamp = v => Math.max(0, v);
351
+ const b = clamp(frame.y + frame.height - pre(window.y + window.height));
352
+ if (b > 0.5) {
353
+ const far = `inset(0px 0px ${b.toFixed(2)}px 0px)`;
354
+ const rest = 'inset(0px 0px 0px 0px)';
355
+ target['clipPath'] = holdsStart ? [rest, far] : [far, rest];
356
+ }
357
+ } else {
358
+ if (fx !== 1) {
359
+ target['scaleX'] = holdsStart ? [1, fx] : [fx, 1];
360
+ }
361
+ if (fy !== 1) {
362
+ target['scaleY'] = holdsStart ? [1, fy] : [fy, 1];
363
+ }
364
+ }
365
+ } else {
366
+ if (dx !== 0) {
367
+ target['x'] = leg(dx);
368
+ pairs.push(leg(dx));
369
+ }
370
+ if (dy !== 0) {
371
+ target['y'] = leg(dy);
372
+ pairs.push(leg(dy));
373
+ }
374
+ }
375
+ if (scaleMode) ; else if (step.size !== false) {
376
+ if (from.width !== to.width) {
377
+ target['width'] = [from.width, to.width];
378
+ pairs.push([from.width, to.width]);
379
+ }
380
+ if (from.height !== to.height) {
381
+ target['height'] = [from.height, to.height];
382
+ pairs.push([from.height, to.height]);
383
+ }
384
+ }
385
+ if (!pairs.length) {
386
+ return null;
387
+ }
388
+ let longest = step.ms ?? 0;
389
+ if (step.ms === undefined) {
390
+ const spec = step.spring ?? DEFAULT_SPRING;
391
+ for (const [a, b] of pairs) {
392
+ longest = Math.max(longest, springDuration(a, b, spec));
393
+ }
394
+ }
395
+ return {
396
+ flight,
397
+ longest,
398
+ target,
399
+ // a hot start leaves at the speed it was thrown (§6.1)
400
+ velocity: hot ? gestureVelocity() : undefined
401
+ };
402
+ }
403
+
404
+ /** parse a computed rgb()/rgba() color into channels, or null */
405
+ function rgbOf(css) {
406
+ if (!css) {
407
+ return null;
408
+ }
409
+ const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec(css);
410
+ if (!m) {
411
+ return null;
412
+ }
413
+ return {
414
+ a: m[4] === undefined ? 1 : parseFloat(m[4]),
415
+ b: parseFloat(m[3]),
416
+ g: parseFloat(m[2]),
417
+ r: parseFloat(m[1])
418
+ };
419
+ }
420
+
421
+ /**
422
+ * A skin's EFFECTIVE color: its background alpha-blended against the ground
423
+ * it stood on — opacity turned into an actual color (§4.7). Null when the
424
+ * skin has no visible background (type over transparency crossfades as
425
+ * opacity, there is nothing to solidify) or wears a color this parser does
426
+ * not speak, in which case the crossfade stays a plain dissolve.
427
+ */
428
+ function effective(paint, ground) {
429
+ const src = rgbOf(paint);
430
+ if (!src || src.a === 0) {
431
+ return null;
432
+ }
433
+ if (src.a === 1) {
434
+ return `rgb(${src.r}, ${src.g}, ${src.b})`;
435
+ }
436
+ const dst = rgbOf(ground);
437
+ if (!dst) {
438
+ return null;
439
+ }
440
+ const mix = (s, d) => Math.round(s * src.a + d * (1 - src.a));
441
+ return `rgb(${mix(src.r, dst.r)}, ${mix(src.g, dst.g)}, ${mix(src.b, dst.b)})`;
442
+ }
443
+ const tweenTransition = (step, ms) => ({
444
+ duration: ms / 1000,
445
+ ease: step.ease ?? 'easeInOut',
446
+ type: 'tween',
447
+ ...('times' in step && step.times ? {
448
+ times: step.times
449
+ } : {})
450
+ });
451
+ const springTransition = spec => ({
452
+ ...(spec ?? DEFAULT_SPRING),
453
+ type: 'spring'
454
+ });
455
+ function resolveStep(step, cs, exclude) {
456
+ // a step with no `of` (a wait, a tether) still needs A sprite to hang its
457
+ // cue on — the run keys its bookkeeping by sprite — so it takes the
458
+ // first the changeset offers and produces exactly one cue
459
+ const sprites = cs.sprites(step.of ?? {}).filter(s => !exclude || !exclude.has(s));
460
+ const delay = step.delay ?? 0;
461
+ const cues = [];
462
+ const stagger = step.stagger ?? 0;
463
+ /** text delivery: the sprite is split and the ladder happens INSIDE the
464
+ * step's span, so the sprites themselves are not laddered */
465
+ const splitting = (step.kind === 'tween' || step.kind === 'spring') && step.by !== undefined && step.by !== 'item';
466
+ const order = step.kind === 'tween' || step.kind === 'spring' ? step.order ?? 'forward' : 'forward';
467
+ const ranks = splitting ? [] : ladder(sprites.length, order);
468
+ let longest = 0;
469
+ let open = false;
470
+ for (const [index, sprite] of sprites.entries()) {
471
+ // a camera or tether is a statement about the scene, not a sprite:
472
+ // one cue regardless of what `of` matched — as is any step that named
473
+ // no subject at all
474
+ if ((step.kind === 'camera' || step.kind === 'camera3d' || step.kind === 'tether' || step.of === undefined) && index > 0) {
475
+ break;
476
+ }
477
+ // each sprite starts one rung later than the one before it — in the
478
+ // order the query returned them (document order), reordered by @order
479
+ const offset = splitting ? 0 : stagger * (ranks[index] ?? index);
480
+ switch (step.kind) {
481
+ case 'tween':
482
+ {
483
+ const {
484
+ target
485
+ } = resolveTarget(step, sprite, cs);
486
+ if (step.times !== undefined) {
487
+ const times = step.times;
488
+ if (times.length < 2 || times[0] !== 0 || times[times.length - 1] !== 1 || times.some((time, i) => !Number.isFinite(time) || time < 0 || time > 1 || i > 0 && time <= times[i - 1]) || Object.values(target).some(value => Array.isArray(value) && value.length !== times.length)) {
489
+ throw new Error('choreo: Tween times must increase from 0 to 1 and match every keyframe array');
490
+ }
491
+ }
492
+ if (Object.keys(target).length) {
493
+ // an infinite repeat is an ambient loop: it occupies one cycle of
494
+ // the schedule and keeps playing past the run's end
495
+ const loop = step.repeat === Infinity;
496
+ const plays = loop ? 1 : 1 + (step.repeat ?? 0);
497
+ const length = step.ms * plays;
498
+ const transition = tweenTransition(step, step.ms);
499
+ if (step.repeat) {
500
+ transition['repeat'] = step.repeat;
501
+ transition['repeatType'] = step.repeatType ?? 'loop';
502
+ }
503
+ cues.push({
504
+ delivery: splitting ? {
505
+ by: step.by,
506
+ order,
507
+ stagger
508
+ } : undefined,
509
+ duration: length,
510
+ kind: 'tween',
511
+ loop: loop || undefined,
512
+ offset,
513
+ sprite,
514
+ target,
515
+ transition
516
+ });
517
+ longest = Math.max(longest, offset + (loop ? step.ms : length));
518
+ }
519
+ break;
520
+ }
521
+ case 'spring':
522
+ {
523
+ const {
524
+ target,
525
+ longest: d
526
+ } = resolveTarget(step, sprite, cs);
527
+ if (Object.keys(target).length) {
528
+ cues.push({
529
+ delivery: splitting ? {
530
+ by: step.by,
531
+ order,
532
+ stagger
533
+ } : undefined,
534
+ duration: d,
535
+ kind: 'spring',
536
+ offset,
537
+ sprite,
538
+ target,
539
+ transition: springTransition(step.spring)
540
+ });
541
+ longest = Math.max(longest, offset + d);
542
+ }
543
+ break;
544
+ }
545
+ case 'move':
546
+ {
547
+ const r = resolveMove(step, sprite, cs);
548
+ if (r) {
549
+ const transition = step.ms === undefined ? springTransition(step.spring) : tweenTransition(step, step.ms);
550
+ if (r.velocity && step.ms === undefined) {
551
+ // the engine reads transition[key] in preference to the whole
552
+ if (r.target['x']) {
553
+ transition['x'] = {
554
+ ...transition,
555
+ velocity: r.velocity.x
556
+ };
557
+ }
558
+ if (r.target['y']) {
559
+ transition['y'] = {
560
+ ...transition,
561
+ velocity: r.velocity.y
562
+ };
563
+ }
564
+ }
565
+ cues.push({
566
+ duration: r.longest,
567
+ flight: r.flight,
568
+ kind: 'move',
569
+ offset,
570
+ sprite,
571
+ target: r.target,
572
+ transition
573
+ });
574
+ longest = Math.max(longest, offset + r.longest);
575
+ // the counterpart-skin policy (§6.3): the claimed leaver rides the
576
+ // same flight, and the two skins swap during it or at its landing
577
+ const cp = sprite.counterpart;
578
+ const swap = step.swap ?? 'during';
579
+ // a far match's sender is released to its own region, not carried
580
+ // here — there is no second skin to fly (§3.2)
581
+ if (cp && !cp.sent && swap !== 'none' && sprite.final && cp.initial) {
582
+ const cpz = cs.measureZoom ?? 1;
583
+ const from = descale(cp.initial.page, cpz);
584
+ const to = descale(sprite.final.page, cpz);
585
+ const cpTarget = {};
586
+ if (step.size === 'scale' || step.size === 'crop') {
587
+ // the old skin rides the same match-box flight as its
588
+ // receiver, mirrored: identity at its own seat, landing with
589
+ // its SUBJECT on the receiver's — transform only, layout
590
+ // untouched, and the same off-centre correction (see the
591
+ // receiver's match block). Under 'crop' the scale is
592
+ // uniform and its own crop window closes to the receiver's
593
+ // frame as it lands.
594
+ const crop = step.size === 'crop';
595
+ const cpm = matchBoxes(cp.initial, sprite.final, from, to, cpz);
596
+ const mf = centreOf(cpm.from);
597
+ const mt = centreOf(cpm.to);
598
+ const anchor = centreOf(from);
599
+ const rx = cpm.to.width / (cpm.from.width || 1) || 1;
600
+ const ry = cpm.to.height / (cpm.from.height || 1) || 1;
601
+ // width-derived and top-pinned under 'crop', as on the
602
+ // receiver: the exiting subject lands at the ENTERING
603
+ // subject's width, tops together (the rule is symmetric)
604
+ const eu = rx;
605
+ const ex = crop ? eu : rx;
606
+ const ey = crop ? eu : ry;
607
+ const dxe = mt.x - anchor.x - ex * (mf.x - anchor.x);
608
+ const dye = crop ? cpm.to.y - anchor.y - ey * (cpm.from.y - anchor.y) : mt.y - anchor.y - ey * (mf.y - anchor.y);
609
+ if (dxe !== 0) {
610
+ cpTarget['x'] = [0, dxe];
611
+ }
612
+ if (dye !== 0) {
613
+ cpTarget['y'] = [0, dye];
614
+ }
615
+ if (crop) {
616
+ if (ex !== 1) {
617
+ cpTarget['scale'] = [1, ex];
618
+ }
619
+ // the receiver's SUBJECT sets the floor here too
620
+ const pre = edge => anchor.y + (edge - anchor.y - dye) / ex;
621
+ const clamp = v => Math.max(0, v);
622
+ const cb = clamp(from.y + from.height - pre(cpm.to.y + cpm.to.height));
623
+ if (cb > 0.5) {
624
+ cpTarget['clipPath'] = ['inset(0px 0px 0px 0px)', `inset(0px 0px ${cb.toFixed(2)}px 0px)`];
625
+ }
626
+ } else {
627
+ if (ex !== 1) {
628
+ cpTarget['scaleX'] = [1, ex];
629
+ }
630
+ if (ey !== 1) {
631
+ cpTarget['scaleY'] = [1, ey];
632
+ }
633
+ }
634
+ } else {
635
+ if (to.x !== from.x) {
636
+ cpTarget['x'] = [0, to.x - from.x];
637
+ }
638
+ if (to.y !== from.y) {
639
+ cpTarget['y'] = [0, to.y - from.y];
640
+ }
641
+ if (step.size !== false) {
642
+ if (from.width !== to.width) {
643
+ cpTarget['width'] = [from.width, to.width];
644
+ }
645
+ if (from.height !== to.height) {
646
+ cpTarget['height'] = [from.height, to.height];
647
+ }
648
+ }
649
+ }
650
+ if (Object.keys(cpTarget).length) {
651
+ cues.push({
652
+ duration: r.longest,
653
+ kind: 'move',
654
+ offset,
655
+ sprite: cp,
656
+ target: cpTarget,
657
+ transition
658
+ });
659
+ }
660
+ if (swap === 'during') {
661
+ // Both skins cross over the one flying box. A symmetric
662
+ // opacity crossfade composites each against whatever stands
663
+ // behind it, and mid-fade the pair sums short of solid — the
664
+ // ground leaks through and the box visibly dips. So when both
665
+ // skins wore a real background, alpha is turned into an
666
+ // actual color (§4.7): the receiver holds opacity 1 wearing a
667
+ // SOLID that tweens between the two skins' effective colors,
668
+ // the old skin dissolves ABOVE it, and on landing the solid
669
+ // is handed back to the stylesheet's own alpha (Cue.borrow).
670
+ const oldSkin = effective(cp.initial?.paint, cs.ground);
671
+ const newSkin = effective(sprite.final?.paint, cs.ground);
672
+ const fade = {
673
+ duration: r.longest / 1000,
674
+ ease: 'easeInOut'
675
+ };
676
+ if (oldSkin && newSkin) {
677
+ cues.push({
678
+ borrow: true,
679
+ duration: r.longest,
680
+ kind: 'tween',
681
+ offset,
682
+ sprite,
683
+ target: {
684
+ backgroundColor: [oldSkin, newSkin]
685
+ },
686
+ transition: fade
687
+ });
688
+ cues.push({
689
+ duration: r.longest,
690
+ hold: {
691
+ fill: false,
692
+ values: {
693
+ zIndex: 3
694
+ }
695
+ },
696
+ kind: 'hold',
697
+ offset,
698
+ sprite: cp
699
+ });
700
+ } else {
701
+ cues.push({
702
+ duration: r.longest,
703
+ kind: 'tween',
704
+ offset,
705
+ sprite,
706
+ target: {
707
+ opacity: [0, 1]
708
+ },
709
+ transition: fade
710
+ });
711
+ }
712
+ cues.push({
713
+ duration: r.longest,
714
+ kind: 'tween',
715
+ offset,
716
+ sprite: cp,
717
+ target: {
718
+ opacity: [1, 0]
719
+ },
720
+ transition: fade
721
+ });
722
+ } else {
723
+ // 'settle': the old rendering is carried whole; the swap is
724
+ // the landing itself — the receiver hides for the flight and
725
+ // the released hold reveals it as the leaver is dropped
726
+ cues.push({
727
+ duration: r.longest,
728
+ hold: {
729
+ fill: false,
730
+ values: {
731
+ opacity: 0
732
+ }
733
+ },
734
+ kind: 'hold',
735
+ offset,
736
+ sprite
737
+ });
738
+ }
739
+ }
740
+ }
741
+ break;
742
+ }
743
+ case 'hold':
744
+ {
745
+ const values = {};
746
+ for (const key in step.props) {
747
+ values[key] = single(resolve(step.props[key], sprite, cs));
748
+ }
749
+ cues.push({
750
+ duration: step.ms ?? 0,
751
+ hold: {
752
+ fill: step.fill ?? false,
753
+ values
754
+ },
755
+ kind: 'hold',
756
+ offset,
757
+ sprite
758
+ });
759
+ if (step.ms === undefined) {
760
+ open = true;
761
+ } else {
762
+ longest = Math.max(longest, offset + step.ms);
763
+ }
764
+ break;
765
+ }
766
+ case 'camera':
767
+ {
768
+ const ms = step.ms ?? 600;
769
+ // fit mode is DECLARED, not inferred: `fit: null` (fit nothing)
770
+ // still means the step owns the whole pose and returns it to rest
771
+ const fitting = step.fit !== undefined;
772
+ const aiming = step.aim !== undefined;
773
+ const aimQuery = fitting ? step.fit : step.aim ?? step.origin;
774
+ const originSprite = aimQuery ? cs.sprite(aimQuery) : null;
775
+ const box = originSprite?.final?.context ?? originSprite?.initial?.context;
776
+ // context space carries the frame's transform (context = zoom ×
777
+ // local), and transform-origin is written in LOCAL pixels — divide
778
+ // back by the zoom the world was measured under (§6.3)
779
+ const oz = cs.measureZoom ?? 1;
780
+ const origin = box ? {
781
+ x: (box.x + box.width / 2) / oz,
782
+ y: (box.y + box.height / 2) / oz
783
+ } : undefined;
784
+ let to = {
785
+ x: step.x,
786
+ y: step.y,
787
+ zoom: step.zoom
788
+ };
789
+ // relative direction (Pan / SlowZoom): nothing absolute compiles —
790
+ // the run resolves the target against the pose in force at start
791
+ const by = step.panBy || step.zoomBy !== undefined ? {
792
+ x: step.panBy?.x,
793
+ y: step.panBy?.y,
794
+ zoom: step.zoomBy
795
+ } : undefined;
796
+ if (aiming && !fitting) {
797
+ // Aim: the fit centring WITHOUT the fit zoom — x/y land the
798
+ // target on the frame's centre, zoom stays whatever it was
799
+ to = box && origin && cs.frame ? {
800
+ x: cs.frame.width / 2 - origin.x,
801
+ y: cs.frame.height / 2 - origin.y,
802
+ zoom: undefined
803
+ } : {
804
+ x: 0,
805
+ y: 0,
806
+ zoom: 1
807
+ };
808
+ }
809
+ if (fitting) {
810
+ if (box && origin && cs.frame) {
811
+ // Fit-and-centre, from the same rest-layout measurement FLIP
812
+ // uses — never the painted box, so a click that lands mid-zoom
813
+ // on a DIFFERENT tile still computes against rest geometry.
814
+ // The margin is the sprite's share of the frame on whichever
815
+ // axis fits first; the pan solves x + P = centre, since the
816
+ // applied transform holds the aim point P at x + P (§6.3).
817
+ const zoom = step.zoom ?? (step.margin ?? 0.72) * Math.min(cs.frame.width * oz / box.width, cs.frame.height * oz / box.height);
818
+ to = {
819
+ x: cs.frame.width / 2 - origin.x,
820
+ y: cs.frame.height / 2 - origin.y,
821
+ zoom
822
+ };
823
+ } else {
824
+ // fit nothing (null, or the sprite has left): the resting frame
825
+ to = {
826
+ x: 0,
827
+ y: 0,
828
+ zoom: 1
829
+ };
830
+ }
831
+ }
832
+ cues.push({
833
+ camera: {
834
+ by,
835
+ // centre and origin from the SAME final layout: the aim term
836
+ // (origin − centre) is frozen numbers the run lerps — a board
837
+ // that reflows mid-cue cannot move the camera
838
+ centre: cs.frame ? {
839
+ x: cs.frame.width / 2,
840
+ y: cs.frame.height / 2
841
+ } : undefined,
842
+ origin,
843
+ steady: step.steady ? cs.sprites(step.steady) : [],
844
+ to
845
+ },
846
+ duration: ms,
847
+ kind: 'camera',
848
+ offset,
849
+ sprite,
850
+ transition: step.spring ? springTransition(step.spring) : {
851
+ duration: ms / 1000,
852
+ ease: step.ease ?? 'easeInOut'
853
+ }
854
+ });
855
+ longest = Math.max(longest, offset + ms);
856
+ break;
857
+ }
858
+ case 'camera3d':
859
+ {
860
+ const ms = step.ms ?? 600;
861
+ cues.push({
862
+ camera3d: {
863
+ by: step.by,
864
+ settle: step.settle,
865
+ tension: step.tension,
866
+ through: step.through,
867
+ to: {
868
+ dolly: step.dolly,
869
+ look: step.look,
870
+ pitch: step.pitch,
871
+ x: step.x,
872
+ y: step.y,
873
+ yaw: step.yaw
874
+ }
875
+ },
876
+ duration: ms,
877
+ kind: 'camera3d',
878
+ offset,
879
+ sprite,
880
+ transition: step.spring ? springTransition(step.spring) : {
881
+ duration: ms / 1000,
882
+ ease: step.ease ?? 'easeInOut'
883
+ }
884
+ });
885
+ longest = Math.max(longest, offset + ms);
886
+ break;
887
+ }
888
+ case 'tether':
889
+ {
890
+ cues.push({
891
+ duration: step.ms ?? 0,
892
+ kind: 'tether',
893
+ offset,
894
+ sprite,
895
+ tether: {
896
+ from: cs.sprites(step.from)[0] ?? null,
897
+ name: step.name,
898
+ path: step.path,
899
+ to: cs.sprites(step.to)[0] ?? null
900
+ }
901
+ });
902
+ if (step.ms === undefined) {
903
+ open = true;
904
+ } else {
905
+ longest = Math.max(longest, offset + step.ms);
906
+ }
907
+ break;
908
+ }
909
+ case 'follow':
910
+ {
911
+ // A derived write may only touch what the compositor treats as
912
+ // paint, never layout: the region's fast keep declines a pass by
913
+ // fingerprinting every participant's offsetLeft/Top/Width/Height,
914
+ // so a follower writing real width would fail that fingerprint on
915
+ // every frame and put the whole region back into release-and-
916
+ // reassert once per frame — the jitter the fast keep exists to
917
+ // prevent. Caught here, where the author can still read the name.
918
+ for (const key of Object.keys(step.rest)) {
919
+ if (!DERIVABLE.has(key)) {
920
+ throw new Error(`choreo: c.Follow cannot drive '${key}' — a derived value is ` + 'computed every frame, so it may only write transform, ' + 'opacity and filter properties, never layout');
921
+ }
922
+ }
923
+ // The geometry a follower computes from is the PASS's, not the
924
+ // page's: resting boxes measured while every moved value stood
925
+ // released (releaseForMeasure), taken here into region space. The
926
+ // run never measures for a follower again — see drive(). A sprite
927
+ // the pass could not measure simply compiles no follow.
928
+ const fz = cs.measureZoom ?? 1;
929
+ const inZ = r => ({
930
+ height: r.height / fz,
931
+ width: r.width / fz,
932
+ x: r.x / fz,
933
+ y: r.y / fz
934
+ });
935
+ const restBounds = sprite.final ?? sprite.initial;
936
+ if (!restBounds) {
937
+ break;
938
+ }
939
+ const followed = [];
940
+ for (const source of cs.sprites(step.to)) {
941
+ const toB = source.final ?? source.initial;
942
+ const fromB = source.initial ?? source.final;
943
+ if (toB && fromB) {
944
+ followed.push({
945
+ from: inZ(fromB.context),
946
+ sprite: source,
947
+ to: inZ(toB.context)
948
+ });
949
+ }
950
+ }
951
+ const ms = step.ms;
952
+ cues.push({
953
+ derive: {
954
+ read: step.read,
955
+ rest: step.rest,
956
+ restBox: inZ(restBounds.context),
957
+ sources: followed
958
+ },
959
+ duration: ms ?? 0,
960
+ kind: 'follow',
961
+ offset,
962
+ sprite
963
+ });
964
+ if (ms === undefined) {
965
+ open = true;
966
+ } else {
967
+ longest = Math.max(longest, offset + ms);
968
+ }
969
+ break;
970
+ }
971
+ case 'scroll':
972
+ {
973
+ const ms = step.ms ?? 420;
974
+ cues.push({
975
+ duration: ms,
976
+ kind: 'scroll',
977
+ offset,
978
+ scroll: {
979
+ align: step.align ?? 'center'
980
+ },
981
+ sprite
982
+ });
983
+ longest = Math.max(longest, offset + ms);
984
+ break;
985
+ }
986
+ case 'raise':
987
+ {
988
+ cues.push({
989
+ duration: step.ms ?? 0,
990
+ kind: 'raise',
991
+ offset,
992
+ raise: {
993
+ shadow: step.shadow ?? false
994
+ },
995
+ sprite
996
+ });
997
+ if (step.ms === undefined) {
998
+ open = true;
999
+ } else {
1000
+ longest = Math.max(longest, offset + step.ms);
1001
+ }
1002
+ break;
1003
+ }
1004
+ case 'attach':
1005
+ cues.push({
1006
+ attach: {
1007
+ end: step.end ?? 'hold',
1008
+ exact: step.exact ?? false,
1009
+ in: step.in ?? 0,
1010
+ rate: step.rate ?? 1,
1011
+ region: step.region
1012
+ },
1013
+ duration: step.ms,
1014
+ kind: 'attach',
1015
+ offset,
1016
+ sprite
1017
+ });
1018
+ longest = Math.max(longest, offset + step.ms);
1019
+ break;
1020
+ case 'wait':
1021
+ cues.push({
1022
+ duration: step.ms,
1023
+ kind: 'wait',
1024
+ offset,
1025
+ sprite
1026
+ });
1027
+ longest = Math.max(longest, offset + step.ms);
1028
+ break;
1029
+ case 'perform':
1030
+ cues.push({
1031
+ duration: 0,
1032
+ kind: 'perform',
1033
+ offset,
1034
+ perform: {
1035
+ action: step.action,
1036
+ payload: step.payload,
1037
+ target: step.target
1038
+ },
1039
+ sprite
1040
+ });
1041
+ longest = Math.max(longest, offset);
1042
+ break;
1043
+ }
1044
+ }
1045
+ return {
1046
+ cues,
1047
+ duration: cues.length ? delay + longest : 0,
1048
+ open
1049
+ };
1050
+ }
1051
+ function compile(tree, cs) {
1052
+ // The yield rule (§4.7): a sprite a SPECIFIC step names belongs to that
1053
+ // step — the canned crossing's generic children surrender it, so "a
1054
+ // special exit that is not just a dissolve" is one sibling step, not a
1055
+ // query-exclusion syntax. Only property steps with a selective query
1056
+ // claim: a camera or a scroll is a statement about the scene, and an
1057
+ // unqualified `of` (match everything) is not a choice of sprite.
1058
+ const explicit = new Set();
1059
+ const claim = node => {
1060
+ if (isGate(node)) {
1061
+ return;
1062
+ }
1063
+ if (isBlock(node)) {
1064
+ node.children.forEach(claim);
1065
+ return;
1066
+ }
1067
+ if (node.generic || node.kind !== 'tween' && node.kind !== 'spring' && node.kind !== 'move' && node.kind !== 'hold') {
1068
+ return;
1069
+ }
1070
+ const q = node.of;
1071
+ const selective = Array.isArray(q) ? q.length > 0 : q.id !== undefined || q.role !== undefined || q.type !== undefined;
1072
+ if (!selective) {
1073
+ return;
1074
+ }
1075
+ for (const s of cs.sprites(q)) {
1076
+ explicit.add(s);
1077
+ }
1078
+ };
1079
+ tree.forEach(claim);
1080
+ const resolved = new Map();
1081
+ const of = step => {
1082
+ let r = resolved.get(step);
1083
+ if (!r) {
1084
+ r = resolveStep(step, cs, step.generic ? explicit : undefined);
1085
+ resolved.set(step, r);
1086
+ }
1087
+ return r;
1088
+ };
1089
+ /**
1090
+ * A node's own length, anchoring aside — for a block, the span its
1091
+ * children occupy, which is what a `@name` on it promises to anyone who
1092
+ * anchors against it.
1093
+ */
1094
+ const extent = node => {
1095
+ if (isGate(node)) {
1096
+ return 0;
1097
+ }
1098
+ if (!isBlock(node)) {
1099
+ return of(node).duration;
1100
+ }
1101
+ return node.kind === 'sequence' ? node.children.reduce((sum, c) => sum + measure(c), 0) : node.children.reduce((max, c) => Math.max(max, measure(c)), 0);
1102
+ };
1103
+ /** what a node contributes to its parent's flow */
1104
+ const measure = node => {
1105
+ if (isGate(node)) {
1106
+ return 0;
1107
+ }
1108
+ // an anchored step or BLOCK is lifted out of its parent's flow: it
1109
+ // neither pushes a sequence forward nor stretches a block's span (§4.2)
1110
+ if (node.at) {
1111
+ return 0;
1112
+ }
1113
+ // a step's own duration already counts its delay; a block's does not,
1114
+ // because the delay is spent before its children begin
1115
+ return isBlock(node) ? (node.delay ?? 0) + extent(node) : of(node).duration;
1116
+ };
1117
+ const out = [];
1118
+ // duplicate names are an authoring error, caught before anything is placed
1119
+ const seen = new Set();
1120
+ const checkNames = node => {
1121
+ if (isGate(node)) {
1122
+ return;
1123
+ }
1124
+ // one namespace: a block and a step cannot share a name, or `@at` would
1125
+ // have two answers
1126
+ if (node.name) {
1127
+ if (seen.has(node.name)) {
1128
+ throw new Error(`choreo: two steps named '${node.name}'`);
1129
+ }
1130
+ seen.add(node.name);
1131
+ }
1132
+ if (isBlock(node)) {
1133
+ node.children.forEach(checkNames);
1134
+ }
1135
+ };
1136
+ tree.forEach(checkNames);
1137
+ /** where each named step landed: when it starts (delay spent) and how long it plays */
1138
+ const names = new Map();
1139
+ const gates = [];
1140
+ /**
1141
+ * A pause is a total order (§4.1): a gate may only stand in a sequence
1142
+ * that no parallel contains — including the implicit parallel that is the
1143
+ * region's root when it has more than one top-level node.
1144
+ */
1145
+ const forbidGates = node => {
1146
+ if (isGate(node)) {
1147
+ throw new Error('choreo: a gate inside a parallel has no meaning — a pause is a ' + 'total order; only an uncontained sequence can hold one');
1148
+ }
1149
+ if (isBlock(node)) {
1150
+ node.children.forEach(forbidGates);
1151
+ }
1152
+ };
1153
+ /**
1154
+ * The whole score's span — computed BEFORE anything is placed, because an
1155
+ * open step's window depends on it.
1156
+ *
1157
+ * A score of nothing but open steps measures zero, and an open step's
1158
+ * window is "the enclosing block's span" — so the annotation the author
1159
+ * asked to be simply ON compiled to a zero-length run that ended on the
1160
+ * frame it was born. That is not a short wire, it is no wire; the app
1161
+ * that hit it wrote `ms: 3_600_000` to get an hour of clock out of a
1162
+ * step whose real lifetime is the scene's. So a score with no length of
1163
+ * its own does not borrow zero: its open cues STAND (§4.6), and the run
1164
+ * holds them until it is cancelled or replaced.
1165
+ */
1166
+ const root = measure({
1167
+ children: tree,
1168
+ kind: 'parallel'
1169
+ });
1170
+ const standingScore = root === 0;
1171
+ /** did any cue actually take the standing window? */
1172
+ let stood = false;
1173
+ /** span: the time a hold without @duration is allowed to last from `start` */
1174
+ const place = (node, start, span) => {
1175
+ if (isGate(node)) {
1176
+ gates.push({
1177
+ at: start,
1178
+ auto: node.ms
1179
+ });
1180
+ return;
1181
+ }
1182
+ /** where an anchored node begins — the same arithmetic for a step and a block */
1183
+ const anchored = ref => {
1184
+ const target = names.get(ref.anchor);
1185
+ if (!target) {
1186
+ throw new Error(`choreo: @at names '${ref.anchor}', which is not a step ` + 'above this one — anchors point up the score');
1187
+ }
1188
+ return ref.edge === 'end' ? target.start + target.duration + (ref.delay ?? 0) * 1000 : target.start + (ref.progress ?? 0) * target.duration;
1189
+ };
1190
+ if (!isBlock(node)) {
1191
+ const r = of(node);
1192
+ const delay = node.delay ?? 0;
1193
+ let base = start;
1194
+ if (node.at) {
1195
+ if (r.open) {
1196
+ throw new Error('choreo: an anchored hold needs its own @duration — lifted out ' + 'of its block, it has no span to borrow');
1197
+ }
1198
+ base = anchored(node.at);
1199
+ }
1200
+ if (node.name) {
1201
+ names.set(node.name, {
1202
+ duration: Math.max(0, r.duration - delay),
1203
+ start: base + delay
1204
+ });
1205
+ }
1206
+ for (const cue of r.cues) {
1207
+ const {
1208
+ offset,
1209
+ ...rest
1210
+ } = cue;
1211
+ // `follow` belongs in this list for the same reason the other three
1212
+ // do — FollowStep.ms is documented as "the window; without it, the
1213
+ // enclosing block's span" — and was simply missed.
1214
+ const borrows = r.open && (cue.kind === 'hold' || cue.kind === 'raise' || cue.kind === 'tether' || cue.kind === 'follow');
1215
+ if (borrows && standingScore) {
1216
+ stood = true;
1217
+ }
1218
+ out.push({
1219
+ ...rest,
1220
+ duration: borrows ? standingScore ? Infinity : Math.max(0, span - delay - offset) : cue.duration,
1221
+ standing: borrows && standingScore ? true : undefined,
1222
+ start: base + delay + offset
1223
+ });
1224
+ }
1225
+ return;
1226
+ }
1227
+ // a block answers to the anchor system on a step's terms: it can be
1228
+ // pointed at (`@name`), it can point (`@at`), and its delay is spent
1229
+ // inside its own slot before its children begin
1230
+ const total = extent(node);
1231
+ const from = (node.at ? anchored(node.at) : start) + (node.delay ?? 0);
1232
+ if (node.name) {
1233
+ // recorded BEFORE the children are placed, so a child may anchor
1234
+ // against the block it is in — `@at={{at 'intro' 0.5}}` reads the
1235
+ // same from inside as from outside
1236
+ names.set(node.name, {
1237
+ duration: total,
1238
+ start: from
1239
+ });
1240
+ }
1241
+ if (node.kind === 'sequence') {
1242
+ let offset = 0;
1243
+ for (const child of node.children) {
1244
+ place(child, from + offset, total - offset);
1245
+ offset += measure(child);
1246
+ }
1247
+ return;
1248
+ }
1249
+ // a parallel: every child at once — and no gate anywhere under it
1250
+ node.children.forEach(forbidGates);
1251
+ for (const child of node.children) {
1252
+ place(child, from, total);
1253
+ }
1254
+ };
1255
+ if (tree.length > 1) {
1256
+ tree.forEach(forbidGates);
1257
+ }
1258
+ for (const node of tree) {
1259
+ place(node, 0, root);
1260
+ }
1261
+ gates.sort((a, b) => a.at - b.at);
1262
+ const derived = out.filter(cue => cue.derive);
1263
+ if (!derived.length) {
1264
+ return {
1265
+ cues: out,
1266
+ gates,
1267
+ open: stood
1268
+ };
1269
+ }
1270
+ // A follower's `now` is composed from whatever is driving its source
1271
+ // THIS frame, so it has to be evaluated after that driver has written
1272
+ // its frame. Cues are walked in order, so derived ones sort last —
1273
+ // stably, which keeps two followers in the order they were written.
1274
+ const nameOf = sprite => sprite.id ?? sprite.role ?? 'a sprite';
1275
+ const drivenBy = new Map();
1276
+ for (const cue of derived) {
1277
+ drivenBy.set(cue.sprite, cue);
1278
+ }
1279
+ for (const cue of derived) {
1280
+ for (const source of cue.derive.sources) {
1281
+ if (drivenBy.has(source.sprite)) {
1282
+ // Chains are resolvable in principle — a topological sort would do
1283
+ // it — but they are refused for now rather than half-supported: a
1284
+ // cycle inside one would be a frame loop with no honest answer,
1285
+ // and nothing yet needs the depth.
1286
+ throw new Error(`choreo: c.Follow on '${nameOf(cue.sprite)}' reads ` + `'${nameOf(source.sprite)}', which is itself derived — a follower may ` + 'not follow a follower');
1287
+ }
1288
+ }
1289
+ }
1290
+ return {
1291
+ cues: [...out.filter(cue => !cue.derive), ...derived],
1292
+ gates,
1293
+ open: stood
1294
+ };
1295
+ }
1296
+
1297
+ /**
1298
+ * A replacement pass's continuity cue (§3.1): the prior run was driving
1299
+ * this sprite through space and the new score does not name it. Unnamed
1300
+ * it would be released to its rest in one frame — the whole-bay snap —
1301
+ * so the region completes the score: one shape-matched move from the
1302
+ * painted box (which is what a mid-flight sprite's `initial` IS, by
1303
+ * design) to its rest. Transform-only, so a continuation can never
1304
+ * reflow the scene it is tidying; on the interrupted cue's own spring
1305
+ * when it had one, so the carry-on keeps the flight's character.
1306
+ */
1307
+ function continuation(sprite, cs, inherited) {
1308
+ const step = {
1309
+ size: 'scale'
1310
+ };
1311
+ const r = resolveMove(step, sprite, cs);
1312
+ if (!r) {
1313
+ return null;
1314
+ }
1315
+ // inherit only a spring's character: a tween's fixed duration was cut
1316
+ // for a different distance and would land wrong on this one
1317
+ const springy = inherited && (inherited['type'] === 'spring' || 'stiffness' in inherited || 'visualDuration' in inherited || 'bounce' in inherited);
1318
+ return {
1319
+ duration: r.longest,
1320
+ flight: r.flight,
1321
+ kind: 'move',
1322
+ sprite,
1323
+ start: 0,
1324
+ target: r.target,
1325
+ transition: springy ? inherited : springTransition(undefined)
1326
+ };
1327
+ }
1328
+
1329
+ /* ---- score identity: is this compile the one already in flight? ---- */
1330
+
1331
+ /**
1332
+ * Whether two compiled scores are the SAME statement — used by the region
1333
+ * to keep an in-flight run when an unrelated render replays the pass (the
1334
+ * render detector is volatile by design, so on a busy page every region
1335
+ * re-passes on every app render; a neighbouring demo writing tracked state
1336
+ * per frame must not restart this region's clock).
1337
+ *
1338
+ * The comparison is deliberately conservative. A FLIGHT is measured off the
1339
+ * page and is not comparable by value: any cue carrying one makes the scores
1340
+ * different, and the pass replays exactly as it always did. Values that ARE
1341
+ * comparable (targets, transitions, delivery plans, holds, camera aims)
1342
+ * compare structurally, so an edit that retimes or re-aims anything replays.
1343
+ *
1344
+ * Tethers, raises and scrolls used to be lumped in with flights, and that was
1345
+ * wrong in a way that only showed up on standing annotations: a wire that is
1346
+ * simply ON was recompiled to the identical statement on every pass and, being
1347
+ * declared different every time, had its run torn down and rebuilt — the path
1348
+ * element removed from the layer and a new one appended, sixty times a second
1349
+ * on a busy page. None of the three carries measured geometry in its cue: a
1350
+ * tether is two sprites and a function, a raise is a sprite and a flag, a
1351
+ * scroll is a sprite and an alignment. They compare by IDENTITY, which is
1352
+ * exactly what "the same statement" means for them — and it matters most for
1353
+ * the scroll, where replacing the cue is a visible restart of the scroll.
1354
+ */
1355
+ function sameScore(a, b) {
1356
+ return a.length === b.length && a.every((cue, i) => sameCue(cue, b[i]));
1357
+ }
1358
+ function sameCue(a, b) {
1359
+ if (a.kind !== b.kind || a.sprite.node !== b.sprite.node || a.start !== b.start || a.duration !== b.duration || (a.loop ?? false) !== (b.loop ?? false)) {
1360
+ return false;
1361
+ }
1362
+ if (a.flight || b.flight) {
1363
+ return false;
1364
+ }
1365
+ if (a.tether || b.tether) {
1366
+ // the same two ends, drawn by the same function, under the same name
1367
+ if (!a.tether || !b.tether || a.tether.from?.node !== b.tether.from?.node || a.tether.to?.node !== b.tether.to?.node || a.tether.path !== b.tether.path || a.tether.name !== b.tether.name) {
1368
+ return false;
1369
+ }
1370
+ }
1371
+ if (a.raise || b.raise) {
1372
+ if (!a.raise || !b.raise || a.raise.shadow !== b.raise.shadow) {
1373
+ return false;
1374
+ }
1375
+ }
1376
+ if (a.attach || b.attach) {
1377
+ if (!a.attach || !b.attach || a.attach.region !== b.attach.region || a.attach.in !== b.attach.in || a.attach.rate !== b.attach.rate || a.attach.end !== b.attach.end) {
1378
+ return false;
1379
+ }
1380
+ }
1381
+ if (a.scroll || b.scroll) {
1382
+ if (!a.scroll || !b.scroll || a.scroll.align !== b.scroll.align) {
1383
+ return false;
1384
+ }
1385
+ }
1386
+ const plain = v => JSON.stringify(v ?? null);
1387
+ // steady rides Sprite objects (cyclic); its cast size stands in for it
1388
+ const cam = c => c ? {
1389
+ centre: c.centre ?? null,
1390
+ origin: c.origin ?? null,
1391
+ steady: c.steady.length,
1392
+ to: c.to
1393
+ } : null;
1394
+ return plain(a.target) === plain(b.target) && plain(a.transition) === plain(b.transition) && plain(a.delivery) === plain(b.delivery) && plain(a.hold) === plain(b.hold) && plain(cam(a.camera)) === plain(cam(b.camera)) &&
1395
+ // the 3D shot is part of the score, not decoration on it: two cues
1396
+ // that agree on everything else and disagree about where the lens
1397
+ // goes are different cues, and a run kept across that difference
1398
+ // plays the old path under the new film
1399
+ plain(a.camera3d) === plain(b.camera3d);
1400
+ }
1401
+
1402
+ export { continuation, compile as default, ladder, sameScore };
1403
+ //# sourceMappingURL=compile.js.map