@kieransimkin/dancemoves 3.0.2

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 (50) hide show
  1. package/LICENSE +338 -0
  2. package/README.md +893 -0
  3. package/RUDIMENTS-API.md +394 -0
  4. package/docs/README.md +73 -0
  5. package/docs/api/adapters.md +400 -0
  6. package/docs/api/downloads.md +158 -0
  7. package/docs/api/effects.md +463 -0
  8. package/docs/api/javascript.md +500 -0
  9. package/docs/api/paper-planes.md +167 -0
  10. package/docs/api/styling.md +292 -0
  11. package/docs/api/surface.json +446 -0
  12. package/docs/api/wordpress.md +365 -0
  13. package/docs/clay-transform-validation.md +84 -0
  14. package/docs/development.md +254 -0
  15. package/docs/guide.md +288 -0
  16. package/docs/javascript-api.md +292 -0
  17. package/docs/javascript-server.md +150 -0
  18. package/docs/react-next.md +185 -0
  19. package/docs/releasing-shared.md +162 -0
  20. package/docs/rudiments-source-integrity.md +80 -0
  21. package/docs/shared-feature-coverage.md +34 -0
  22. package/docs/shared-runtime-migration.md +111 -0
  23. package/docs/source-audit.md +120 -0
  24. package/docs/validation-setup.md +102 -0
  25. package/docs/wordpress-examples.md +206 -0
  26. package/docs/wordpress-shared-runtime.md +133 -0
  27. package/lib/admin.min.js +1 -0
  28. package/lib/assets/DanceRudiments-LICENSE +21 -0
  29. package/lib/assets/DanceRudiments-build-manifest.json +21 -0
  30. package/lib/assets/paper-dreams-plane-atlas.png +0 -0
  31. package/lib/build-manifest.json +117 -0
  32. package/lib/dancemoves.css +9 -0
  33. package/lib/dancemoves.min.js +5 -0
  34. package/lib/index.cjs +4802 -0
  35. package/lib/index.d.ts +71 -0
  36. package/lib/index.mjs +4780 -0
  37. package/lib/react.cjs +154 -0
  38. package/lib/react.d.ts +12 -0
  39. package/lib/react.mjs +135 -0
  40. package/lib/rudiments.d.ts +124 -0
  41. package/lib/server.cjs +411 -0
  42. package/lib/server.d.ts +11 -0
  43. package/lib/server.mjs +367 -0
  44. package/lib/styles/clay-stars-effects.css +1 -0
  45. package/lib/styles/clay-stars-rudiments.css +1 -0
  46. package/lib/styles/dance-moves-core.css +1 -0
  47. package/lib/styles/ks-epk-device-orientation.css +1 -0
  48. package/lib/styles/paper-dreams-flight.css +1 -0
  49. package/lib/wordpress.js +5 -0
  50. package/package.json +101 -0
@@ -0,0 +1,463 @@
1
+ # Shared effect API — DanceMovesEffects
2
+
3
+ [Documentation index](../README.md) · [Core timing API](javascript.md) ·
4
+ [Release adapters](adapters.md) · [CSS/HTML](styling.md)
5
+
6
+ Source: [assets/dance-moves-effects.js](../../assets/dance-moves-effects.js).
7
+ Baseline: **2.8.0 / `4cb6a71f60459b5579be87d7e55ac8b1426c578e`**.
8
+
9
+ This is the reusable, page-independent layer introduced in 2.7 and extended with
10
+ `cueTimeline()` in 2.8. It supplies mechanisms rather than artwork. It does not
11
+ replace the core's LRC parser, audio discovery, named cue subscriptions or clock.
12
+ It contains five factories and four registry methods. Every factory returns a
13
+ frozen handle with `id`, `type`, `snapshot()` and `teardown()` plus the methods
14
+ specified below. The returned object is frozen; its internal state is mutable.
15
+
16
+ ## Loading and ready event
17
+
18
+ WordPress enqueues `dance-moves-effects` after `dance-moves-core`, before the
19
+ catalogue adopter. The module installs only if `window.DanceMoves` already exists
20
+ and `window.DanceMovesEffects` does not. It does not retry a missing dependency.
21
+ Its `version` property copies `DanceMoves.version`, normally `"2.8.0"`.
22
+
23
+ After installation it dispatches **`dance-moves-effects-ready`** on **`document`**
24
+ with `detail: { api: window.DanceMovesEffects, version: string }`. It is a native
25
+ `CustomEvent` with default `bubbles: false` and `cancelable: false`. It fires once
26
+ per successful installation; a late listener does not receive a replay.
27
+
28
+ ```js
29
+ function mountReleaseEffects() {
30
+ const effects = window.DanceMovesEffects;
31
+ const root = document.querySelector('.ks-epk');
32
+ if (!effects || !root || effects.get('release:hero-pointer')) return;
33
+ effects.pointer({
34
+ id: 'release:hero-pointer',
35
+ root,
36
+ bounds: root.querySelector('.epk-hero') || root
37
+ });
38
+ }
39
+ if (window.DanceMovesEffects) mountReleaseEffects();
40
+ else document.addEventListener('dance-moves-effects-ready', mountReleaseEffects, { once: true });
41
+ ```
42
+
43
+ A theme/plugin can instead enqueue its adapter with
44
+ `array('dance-moves-effects')`. Factory calls require their DOM roots to exist;
45
+ the ready event means the API is installed, not that all page content or audio
46
+ metadata has finished loading.
47
+
48
+ ## Shared options, errors and instance registry
49
+
50
+ | Item | Contract |
51
+ | --- | --- |
52
+ | `options` | Optional object; defaults to `{}`. Use an actual object, not a serialized JSON string. |
53
+ | `id` | Supplied truthy value converted to string, otherwise generated as `dance-moves:<factory-type>:<serial>`. Serial is shared by factories. Stable, unique IDs are recommended. |
54
+ | `root` | Element (`nodeType === 1`) or CSS selector. Omitted/nonstring/non-element falls back to the first `.ks-epk`. A supplied selector uses `document.querySelector`, not a root-scoped lookup. |
55
+ | Missing root | Throws `Error`. An unmatched explicit selector does not fall back to `.ks-epk`. Invalid selectors can throw the browser's selector exception. |
56
+ | `render` | Optional synchronous callback, with a factory-specific payload. Many factories invoke it during construction, before the returned handle exists. |
57
+ | Numeric options | Generally coerced with `Number()`; nonfinite values use the documented fallback. Explicit `null` becomes zero before bounds are applied. Supply finite numbers deliberately. |
58
+ | Callback failure | Unlike core `onCue`/`onLyric` dispatch, the shared layer generally does not catch `render`/`onCue` exceptions. A thrown callback can interrupt construction or frame scheduling. Keep callbacks bounded and nonthrowing. |
59
+
60
+ All registry operations are synchronous:
61
+
62
+ | Method | Return and behaviour |
63
+ | --- | --- |
64
+ | `get(id)` | Handle for `String(id)`, or `null`. |
65
+ | `snapshot()` | Array of all registered instances' current snapshot objects, in Map insertion order. No historical samples. |
66
+ | `teardown(id)` | Calls the registered instance's teardown, if present; returns `undefined`. Missing IDs are a no-op. |
67
+ | `teardownAll()` | Tears down a snapshot of all current instances; returns `undefined`. Does not remove the `DanceMovesEffects` global. |
68
+
69
+ A factory registering an existing ID tears down the old instance and replaces it.
70
+ This occurs **after** the new factory has set up its listeners/initial state, so
71
+ old cleanup can affect CSS/classes shared with the replacement. Prefer an
72
+ explicit `effects.teardown(id)` before mounting a replacement on the same target.
73
+ Retained old handles are not safe to reuse: teardown deletes by ID, not identity,
74
+ and most instance methods do not have a destroyed guard. Dispose old references.
75
+
76
+ Teardown cancels the factory's own timers/frames/listeners but is not a general
77
+ DOM rollback. CSS variables, state attributes, and page-owned callback changes
78
+ can remain. There is no automatic root-removal observer for these primitives.
79
+
80
+ ## pointer(options)
81
+
82
+ Returns `{ id, type: "pointer", set, reset, snapshot, teardown }`.
83
+
84
+ | Option | Default | Meaning |
85
+ | --- | --- | --- |
86
+ | `root`, `id`, `render` | Shared options | CSS state is written on `root`, even when bounds/target differ. |
87
+ | `bounds` | Root | Element/selector whose cached rectangle defines input normalization. |
88
+ | `target` | Bounds element | Element/selector receiving `pointermove` and `pointerleave`. |
89
+ | `finePointer` | `true` | Only literal `false` disables the `(hover: hover) and (pointer: fine)` requirement. |
90
+
91
+ The factory caches `getBoundingClientRect()` at construction, on ResizeObserver
92
+ notifications, and on `orientationchange`. It maps the pointer against that
93
+ rectangle to normalized x/y, coalesces movement to one pending animation frame,
94
+ and clamps committed values to `[-1, 1]`. It writes unitless four-decimal strings
95
+ to `--dance-moves-x` and `--dance-moves-y` and a reason to
96
+ `data-dance-moves-pointer`.
97
+
98
+ Native pointer handling is inactive when the page is hidden, reduced motion or
99
+ forced colours is active, or the default fine-pointer condition fails. Inactive
100
+ state resets coordinates to zero. Pointer leave resets to zero without disabling
101
+ subsequent movement; orientation changes recache geometry and reset. There is no
102
+ scroll listener: a bounding box that moves without resizing can be stale. Mount
103
+ on stable geometry or remount after layout changes that move it.
104
+
105
+ `render` receives:
106
+
107
+ ```text
108
+ { x: number, y: number, root: Element, bounds: DOMRect | null, reason: string }
109
+ ```
110
+
111
+ | Instance method | Contract |
112
+ | --- | --- |
113
+ | `set(x, y, reason?)` | Immediately coerces/clamps values, writes CSS, sets reason (default `"active"`), calls render; returns `undefined`. This is the raw apply function: it bypasses native-input accessibility/visibility gates and does not cancel an already queued pointer frame. |
114
+ | `reset(reason?)` | Cancels the pending frame, commits zero coordinates with default reason `"reset"`; returns `undefined`. Does not change the `active` input-gate flag. |
115
+ | `snapshot()` | `{id, type:"pointer", active:boolean, x:number, y:number, bounds:{width,height}\|null}`. Pending normalized values can be reflected before the frame commits/clamps them. |
116
+ | `teardown()` | Calls reset with `"teardown"`, disconnects ResizeObserver, removes listeners and unregisters the ID. Leaves zeroed CSS/attributes. |
117
+
118
+ Native reasons include `active`, `leave`, `orientation`, `inactive`; an eligible
119
+ reconcile writes `ready` without calling render. Manual reasons are unrestricted.
120
+ `set()` is useful for a deterministic harness but is not permission-aware phone
121
+ input; use the [orientation API](adapters.md) for device sensors.
122
+
123
+ ## playbackPulse(options)
124
+
125
+ Returns `{ id, type: "playback-pulse", sync, snapshot, teardown }`.
126
+
127
+ | Option | Default | Meaning |
128
+ | --- | --- | --- |
129
+ | `root`, `id`, `render` | Shared options | Root owns the class and custom properties. |
130
+ | `audio` | First `audio` inside root | Element/selector. Missing element throws `Error`; no master-duration matching is required. A selector is document-scoped. |
131
+ | `ticks` | `16` | Rounded integer, minimum 1; passed through `DanceMoves.durationMilliseconds()`, so long-duration quantisation also applies. |
132
+ | `className` | `dance-moves-playing` | Single CSS class toggled for eligible playback. |
133
+ | `propertyPrefix` | `--dance-moves-pulse` | Trailing hyphens removed; `-duration` and `-delay` appended. Supply a valid custom-property prefix. |
134
+
135
+ For effective playback rate `r = max(0.01, finite(audio.playbackRate, 1))`:
136
+
137
+ ```text
138
+ duration = DanceMoves.durationMilliseconds(ticks) / 1000 / r
139
+ delay = -((audio.currentTime / r) % duration)
140
+ ```
141
+
142
+ Both properties are written as six-decimal **seconds** strings. `ticks` in the
143
+ callback/snapshot is the input's rounded integer, not necessarily its later
144
+ quantized duration. The negative delay aligns a CSS cycle to current media time.
145
+ There is no continuous JavaScript frame loop: native audio events trigger updates.
146
+
147
+ The factory reconciles at creation and on `loadedmetadata`, `timeupdate`,
148
+ `seeking`, `seeked`, `ratechange`, `play`, `playing`, `pause`, `ended` and reduced
149
+ motion/forced-colour changes. It toggles the playing class only when not paused,
150
+ not ended and neither accessibility preference is active. There is no visibility
151
+ listener; hidden-tab CSS/render behaviour remains the consumer's responsibility.
152
+
153
+ `render` receives `{root, audio, duration, delay, ticks}`; duration/delay are
154
+ numbers in seconds, not strings. The callback also runs for pause and inactive
155
+ updates. It does not receive a `playing` boolean: inspect the audio/preferences
156
+ or the configured class.
157
+
158
+ | Instance method | Contract |
159
+ | --- | --- |
160
+ | `sync()` | Recomputes properties, data attribute and callback; returns `undefined`. Does **not** itself toggle the playing class. Native event reconciliation does both. |
161
+ | `snapshot()` | `{id, type:"playback-pulse", ticks, playing:boolean, duration:string}`; playing reads the configured class, duration reads the CSS property. |
162
+ | `teardown()` | Removes the playing class and listeners and unregisters. Leaves pulse properties and data attribute. |
163
+
164
+ `data-dance-moves-pulse` is `inactive` under reduced motion/forced colours,
165
+ otherwise `paused` or `playing` based on `audio.paused`. Unlike the playing class,
166
+ this marker does not separately check `audio.ended`.
167
+
168
+ An unobtrusive CSS consumer can use the standard prefix and playing class:
169
+
170
+ ```css
171
+ .release-status-dot { animation: none; }
172
+ .ks-epk.dance-moves-playing .release-status-dot {
173
+ animation: release-breathe var(--dance-moves-pulse-duration)
174
+ ease-in-out var(--dance-moves-pulse-delay) infinite;
175
+ }
176
+ @keyframes release-breathe {
177
+ 0%, 100% { transform: scale(1); }
178
+ 50% { transform: scale(1.04); }
179
+ }
180
+ @media (prefers-reduced-motion: reduce), (forced-colors: active) {
181
+ .ks-epk .release-status-dot { animation: none !important; }
182
+ }
183
+ ```
184
+
185
+ Do not turn a pulse into full-frame flashing or use repeated colour changes as
186
+ an accessibility fallback. Page CSS remains subject to catalogue ownership rules.
187
+
188
+ ## cueClass(options)
189
+
190
+ Returns `{ id, type: "cue-class", fire, clear, snapshot, teardown }`.
191
+
192
+ | Option | Default | Meaning |
193
+ | --- | --- | --- |
194
+ | `root`, `id`, `render` | Shared options | ID is also passed to the core cue subscription for diagnostic attribution. |
195
+ | `cue` | `"*"` | Passed to `DanceMoves.onCue()`; named and wildcard matching follow the core normalisation rules. |
196
+ | `className` | `dance-moves-cue-active` | Single class added for each active transient. |
197
+ | `durationTicks` | `16` | Rounded integer, at least 1, then duration-quantized by the core. |
198
+
199
+ A cue clears the previous timer/class, increments a lifetime firing count, adds
200
+ the class and sets `data-dance-moves-cue-effect="active"`. It calls render, then
201
+ schedules removal after `DanceMoves.durationMilliseconds(durationTicks)`.
202
+ Retriggering restarts this lifetime. Reduced motion/forced colours prevent firing
203
+ and clear an existing transient when the preference changes.
204
+
205
+ This is a **wall-clock timeout**, not an audio interval: pausing, changing playback
206
+ rate, seeking, ending audio or hiding the page does not automatically pause/clear
207
+ it. Use `cueTimeline()` for reconstructable song-relative state, or explicitly
208
+ clear a transient on relevant native events. Do not run both mechanisms for the
209
+ same visual unless duplicate actions are intentionally handled.
210
+
211
+ `render` receives `{root, detail, count, durationMilliseconds}`. `detail` is the
212
+ core `CueDetail` for subscriptions, or any argument passed to `fire`. Render runs
213
+ on fire, **not** on clear/completion; the class and data attribute carry clearing.
214
+
215
+ | Instance method | Contract |
216
+ | --- | --- |
217
+ | `fire(detail)` | Runs the same local transient path, respecting reduced-motion/forced-colour gates; returns `undefined`. Does not dispatch a core cue or globally reset owned animations. |
218
+ | `clear(reason?)` | Cancels timeout, removes class, writes default reason `idle`; returns `undefined`. Does not reset count. |
219
+ | `snapshot()` | `{id, type:"cue-class", cue:string, active:boolean, count:number}`. |
220
+ | `teardown()` | Clears with `teardown`, removes core subscription and preference listeners, unregisters. |
221
+
222
+ Other reason markers: `restart`, `complete`, `inactive`, `reduced-motion`,
223
+ `forced-colours`. `restart` is transient before `active`. Initial creation does
224
+ not write an idle marker or remove a pre-existing class.
225
+
226
+ ## cueTimeline(options)
227
+
228
+ Returns `{ id, type: "cue-timeline", restore, start, stop, snapshot, teardown }`.
229
+ This is the **2.8.0** transport-recovery primitive. It follows a supplied audio
230
+ player's actual current time, calls transient cue callbacks for crossings, and
231
+ rebuilds ongoing interval state after discontinuities. It does not call
232
+ `audio.play()`, `audio.pause()`, seek the player or change playback rate.
233
+
234
+ ### Options and cue schema
235
+
236
+ | Option | Default | Meaning |
237
+ | --- | --- | --- |
238
+ | `root`, `id`, `render` | Shared options | Root receives `data-dance-moves-cue-timeline` state. |
239
+ | `audio` | First root `audio` | Element/selector; required to resolve, otherwise throws `Error`. No master-length check here. |
240
+ | `cues` | `[]` | Array of `{id?, time, end?, data?}`. Non-arrays become an empty list. Items are mapped to frozen records and sorted by start time. |
241
+ | `onCue` | Unset | Synchronous callback for a not-yet-fired crossing or enabled seek landing. |
242
+ | `seekLandingTolerance` | `0.05` seconds | Coerced finite number, clamped to minimum 0. Used both for rebuild cutoff and landing matching. |
243
+ | `fireOnSeekLanding` | `false` | Only literal `true` allows transient cue firing near a completed seek, and only while audio is not paused. |
244
+ | `maxCrossingGap` | `1.25` seconds | Minimum 0.05. A larger observed forward media-time jump is treated as a discontinuity, not a chain of missed crossings. |
245
+
246
+ Each normalized cue is `{id:string, time:number, end:number|null, data:any}`.
247
+ `time` must coerce to a finite nonnegative number; invalid time throws `TypeError`.
248
+ Supply real numbers: `null` and an empty string coerce to zero. A truthy ID is
249
+ stringified, otherwise `cue-<original-array-index>` is generated before sorting.
250
+ Use **unique IDs**: firing deduplication is by ID, so duplicate IDs suppress each
251
+ other even at different timestamps.
252
+
253
+ Omitting `end` produces `null` (a transient point). A finite coercible `end` defines
254
+ an interval; the implementation does not validate `end > time`. Explicit
255
+ `end: null` coerces to zero, so **omit** it for point cues. Invalid/nonfinite ends
256
+ become null. Active intervals use **`time <= audio.currentTime < end`**; overlapping
257
+ intervals are allowed. `data` is passed through by reference, not deeply frozen,
258
+ validated, fetched, parsed or executed. Treat it as application data.
259
+
260
+ A timeline does **not** automatically read `cueTimingUrl`, register `onCue()` with
261
+ the core, reset animations or emit `dance-moves-cue`. Supply declarative cues in
262
+ code, or explicitly pass the data returned by a reviewed parser integration. Do
263
+ not introduce a second timebase or claim cue-file-to-timeline wiring is automatic.
264
+
265
+ ### Callback payloads
266
+
267
+ ```text
268
+ onCue({
269
+ root: Element,
270
+ audio: HTMLAudioElement,
271
+ cue: { id, time, end, data },
272
+ time: number, // current audio seconds
273
+ reason: "crossing" | "seek-landing",
274
+ generation: number
275
+ })
276
+
277
+ render({
278
+ root: Element,
279
+ audio: HTMLAudioElement,
280
+ time: number, // current audio seconds
281
+ previousTime: number,
282
+ reason: string,
283
+ playing: boolean, // audio playing and not blocked
284
+ active: Cue[], // complete current interval set
285
+ generation: number,
286
+ detail: null | { deltaSeconds: number }
287
+ })
288
+ ```
289
+
290
+ `detail.deltaSeconds` is a capped wall-frame delta (`0..0.25` seconds, initially
291
+ zero), **not** the audio-time delta and not a beat duration. Rebuild/frame code
292
+ updates `previousTime` before rendering, so it often equals `time`; it is not a
293
+ reliable old-position delta source. `active` contains full cue records, whereas
294
+ the snapshot's `active` contains IDs. The callback's `playing` does not incorporate
295
+ an effect-only `stop()` or the audio's `seeking` property; obey `reason` too when
296
+ suppressing motion. Render callbacks are still invoked for inactive states and
297
+ must implement a static fallback.
298
+
299
+ ### Crossing, restoration and transport lifecycle
300
+
301
+ An ordinary frame fires cues for `previous < cue.time <= now`, at most once per
302
+ ID in the current traversal. A backward change beyond 1 ms, or a forward change
303
+ larger than `maxCrossingGap`, calls rebuild without emitting skipped transients.
304
+ Persistent intervals are reconstructed immediately from the new current time.
305
+ A high playback rate or long delayed frame can therefore exceed the crossing gap
306
+ and intentionally suppress otherwise crossed transient cues.
307
+
308
+ Every rebuild increments `generation`, clears the fired-ID set, then marks cues
309
+ strictly earlier than `time - seekLandingTolerance` as consumed. A backward seek
310
+ allows future recrossings to fire again. The `fired` snapshot therefore includes
311
+ both actually invoked cues and past cues suppressed during reconstruction; it is
312
+ **not an event history**. A cue at time zero does not fire just because play starts
313
+ at zero: initialize appearance through render rather than a synthetic crossing.
314
+
315
+ | Trigger | Behaviour and render reasons |
316
+ | --- | --- |
317
+ | Construction | Rebuilds with `initial`; starts with `initial-playing` if already unpaused and eligible. |
318
+ | `play`, `playing` | Starts one frame loop, publishes `play` / `playing`; does not rebuild the fired set. Duplicate start calls while a frame exists do not add a second loop, but currently publish `paused` even if the audio is playing. The next frame publishes `frame`. |
319
+ | Ordinary frame | Fires crossings, renders `frame`, schedules next frame. |
320
+ | `pause` | Cancels frame, publishes `paused`; no fired-set reset. |
321
+ | `seeking` | Cancels frame, publishes `seeking`. |
322
+ | `seeked` | Rebuilds with `seeked`; optional playing landing calls `onCue` for **every** cue within tolerance. Starts with `resume-after-seek` when unpaused. Paused seek landings are not queued for later playback. |
323
+ | `ratechange`, `loadedmetadata` | Rebuilds with `ratechange` / `metadata`, no landing callbacks. |
324
+ | `ended` | Stops with `ended`; does not clear the fired set by itself. Normal restart/seek restoration is needed for a fresh traversal. |
325
+ | Hidden document | Stops with `hidden`. |
326
+ | Visible document | Rebuilds with `visible`, then starts with `resume-after-visible` when eligible; no skipped-cue replay. |
327
+ | Reduced-motion change | Rebuilds with `reduced-motion` / `motion-restored`; restoration requests `resume-after-motion`. A pending frame stops at its next blocked check. |
328
+ | Forced-colour change | Rebuilds with `forced-colours` / `colours-restored`; restoration requests `resume-after-colours`. |
329
+ | Frame detects pause/end/blocking | Stops with `paused`, `ended` or `inactive`. |
330
+
331
+ ### Instance methods and snapshot
332
+
333
+ | Method | Return and semantics |
334
+ | --- | --- |
335
+ | `restore(reason?)` | `undefined`. Rebuilds from current audio time, default `manual-restore`, never fires landings. Does not start a stopped loop or stop a running one. |
336
+ | `start(reason?)` | `undefined`. Starts effect frames when eligible, default `playing`. Does not start audio. If already scheduled, paused, ended or blocked, publishes the corresponding fallback state rather than scheduling another loop. |
337
+ | `stop(reason?)` | `undefined`. Cancels effect frame, changes status, publishes default `paused`. Does not pause audio; later native events can restart it. |
338
+ | `snapshot()` | Shape below. Status is stored controller status, not necessarily the last render reason or DOM marker. |
339
+ | `teardown()` | Stops with `teardown`, removes all registered listeners and unregisters the ID. Leaves attributes/page-rendered state. Do not reuse the handle after disposal. |
340
+
341
+ ```text
342
+ {
343
+ id: string, type: "cue-timeline", status: string,
344
+ time: number, previousTime: number, generation: number,
345
+ fired: string[], active: string[]
346
+ }
347
+ ```
348
+
349
+ The distinction from core cue landings is intentional in this reference: core
350
+ LRC playback arms **one** nearby cue by default and queues paused landings; the
351
+ shared timeline defaults to **no** landing callbacks and, when explicitly enabled,
352
+ fires all matching IDs only for an unpaused seek.
353
+
354
+ ### Complete interval-state example
355
+
356
+ The page owns the cues and visual class. DanceMoves owns seeking and time tracking.
357
+ The render callback re-establishes the complete state, so a seek into a chorus
358
+ does not require a fabricated transient cue:
359
+
360
+ ```js
361
+ function mountTimeline() {
362
+ const effects = window.DanceMovesEffects;
363
+ const root = document.querySelector('.ks-epk');
364
+ const audio = root?.querySelector('audio');
365
+ if (!effects || !root || !audio || effects.get('release:sections')) return;
366
+ effects.cueTimeline({
367
+ id: 'release:sections', root, audio,
368
+ cues: [
369
+ { id: 'verse-1', time: 0, end: 20, data: { section: 'verse' } },
370
+ { id: 'chorus-1', time: 20, end: 40, data: { section: 'chorus' } },
371
+ { id: 'verse-2', time: 40, end: 60, data: { section: 'verse' } }
372
+ ],
373
+ render(state) {
374
+ const chorus = state.active.some(cue => cue.data?.section === 'chorus');
375
+ root.classList.toggle('release-chorus', chorus);
376
+ const stopped = ['paused', 'ended', 'hidden', 'inactive', 'seeking', 'teardown'].includes(state.reason);
377
+ root.classList.toggle('release-motion-running', state.playing && !stopped);
378
+ }
379
+ });
380
+ }
381
+ if (window.DanceMovesEffects) mountTimeline();
382
+ else document.addEventListener('dance-moves-effects-ready', mountTimeline, { once: true });
383
+
384
+ // When permanently disposing this page integration (not merely pausing audio):
385
+ function disposeTimeline() {
386
+ document.removeEventListener('dance-moves-effects-ready', mountTimeline);
387
+ window.DanceMovesEffects?.teardown('release:sections');
388
+ document.querySelector('.ks-epk')?.classList.remove('release-chorus', 'release-motion-running');
389
+ }
390
+ ```
391
+
392
+ The example's timestamps are illustrative, not actual song data. Keep meaningful
393
+ section styling readable when motion is stopped, and add explicit reduced-motion
394
+ and forced-colour CSS for any animated treatment.
395
+
396
+ ## quality(options)
397
+
398
+ Returns `{ id, type: "quality", setTier, snapshot, teardown }`. This reusable
399
+ controller is **not the Clay/Stars performance monitor**. Do not copy settings or
400
+ probation/backoff assumptions between them.
401
+
402
+ | Option | Default | Normalisation/meaning |
403
+ | --- | --- | --- |
404
+ | `root`, `id`, `render` | Shared | Controller publishes state; the page must implement cheaper tiers in CSS/rendering. |
405
+ | `tiers` | `["full","constrained","minimal"]` | Nonempty array mapped to strings; lowest index is highest quality. Other input falls back to defaults. |
406
+ | `initial` | `0` | Rounded and clamped to valid tier index. |
407
+ | `sampleMilliseconds` | `1600` | Finite, minimum 800. |
408
+ | `poorWindows` | `2` | Rounded, minimum 2. |
409
+ | `healthyWindows` | `5` | Rounded, minimum 3. |
410
+ | `downgradeRatio` | `0.72` | Clamped to `0.2..0.95`. A lower measured ratio increments poor count. |
411
+ | `recoveryRatio` | `0.9` | Clamped between downgradeRatio and 1. A higher ratio increments healthy count. |
412
+ | `downgradeCooldownMilliseconds` | `5000` | Finite fallback; no nonnegative clamp in source. |
413
+ | `recoveryCooldownMilliseconds` | `12000` | Finite fallback; no nonnegative clamp in source. |
414
+
415
+ A window estimates `fps = frameCount * 1000 / elapsedMilliseconds`. The reference
416
+ is `max(previousReference, min(60, fps))`, so it never decreases and is capped at
417
+ 60. This is **frame-count/elapsed-window** sampling, not Clay's median/p95 or a
418
+ physical refresh-rate measurement. There is no startup load/settle gate, explicit
419
+ long-gap rejection, recovery probation or exponential cooldown. The first slow
420
+ window can establish a low reference; do not describe it as independent device
421
+ capacity detection.
422
+
423
+ After enough poor windows and cooldown, the index increases by one; after enough
424
+ healthy windows and recovery cooldown, it decreases by one. Mid-band samples reset
425
+ both counters. There is one shared last-shift clock, initially zero relative to
426
+ animation-frame time. The minimum tier continues to sample for recovery.
427
+
428
+ When hidden or under either accessibility preference, frame callbacks continue
429
+ but reset the measurement window and skip sampling. Visibility change resets the
430
+ window, not explicitly the poor/healthy counters. A **preference change** saves
431
+ the prior tier once, publishes the last tier, and restores the saved tier once
432
+ both preferences are off. The constructor publishes `initial` but does **not**
433
+ call that preference-transition handler initially. If a preference is already
434
+ active on mount, CSS/rendering must provide the static fallback immediately; the
435
+ tier attribute alone is not proof that accessibility handling has run.
436
+
437
+ | Instance method | Contract |
438
+ | --- | --- |
439
+ | `setTier(index, reason?)` | Rounded/clamped numeric index; default reason `manual`; returns `undefined`. Does not reset counters/cooldowns or disable automatic changes. It takes an index, not a tier name. |
440
+ | `snapshot()` | `{id, type:"quality", tier:string, index:number, referenceFps:number, poorWindows:number, healthyWindows:number}`. Counters are accumulated evidence, not the configured thresholds. |
441
+ | `teardown()` | Sets running false, cancels frame, removes listeners and unregisters. Leaves the selected tier/CSS markers. |
442
+
443
+ Render payload:
444
+ `{root, tier:string, index:number, reason:string, sample:null|{fps:number}}`.
445
+ Reasons include `initial`, `measuring`, `sustained-low-fps`, `sustained-recovery`,
446
+ `prefers-reduced-motion`, `forced-colours`, `preference-restored` and a manual
447
+ reason. Render fires on every measured window, **not only tier changes**.
448
+
449
+ The root receives `data-dance-moves-quality`, `data-dance-moves-quality-reason`,
450
+ and, for sampled updates, one-decimal `data-dance-moves-fps` and
451
+ `data-dance-moves-reference-fps`. No automatic CSS reduction is installed by this
452
+ primitive. Keep cheaper tiers visually acceptable and benchmark the whole page.
453
+
454
+ ## Testing and limits
455
+
456
+ The shipped [cue-timeline contract](../../tests/cue-timeline.test.cjs) exercises
457
+ crossing deduplication, pause/resume, forward/backward seeks, active-interval and
458
+ visibility reconstruction, and registry removal. The
459
+ [primitive contract](../../tests/effects-primitives.test.cjs) is mostly static
460
+ source assertions. Neither establishes physical-device frame performance or
461
+ complete callback-error, duplicate-ID, accessibility or post-teardown coverage.
462
+ Use [development gates](../development.md) for those checks. Do not represent a
463
+ documentation correction as a change in runtime behaviour.