@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,292 @@
1
+ # Shared JavaScript API
2
+
3
+ Package: `@kieransimkin/dancemoves`, source candidate 3.0.0.
4
+ This guide describes the public shared module; [WordPress](wordpress-shared-runtime.md)
5
+ loads the same runtime through its adapter.
6
+
7
+ ## Installation and first mount
8
+
9
+ After publication:
10
+
11
+ ```sh
12
+ npm install @kieransimkin/dancemoves
13
+ ```
14
+
15
+ From this repository before publication, run `npm install --ignore-scripts`,
16
+ commit the generated lockfile, and run `npm run build`. The included demos resolve
17
+ the built package through the repository's own package exports, not a registry
18
+ copy or a sibling checkout.
19
+
20
+ ```js
21
+ import {createDanceMoves} from '@kieransimkin/dancemoves';
22
+ import '@kieransimkin/dancemoves/styles.css';
23
+
24
+ const root = document.querySelector('#arcadians');
25
+ const runtime = createDanceMoves({
26
+ root,
27
+ bpm: 145,
28
+ lyricTimingUrl: '/media/canonical-lyric-timing.lrc',
29
+ cueTimingUrl: '/media/sections.cue',
30
+ masterDurationMilliseconds: 273604.558
31
+ });
32
+ await runtime.ready;
33
+ const stopBeat = runtime.onEveryBeat(({boundaryTick}) => {
34
+ root.dataset.lastBeat = String(boundaryTick / 16);
35
+ }, {id: 'arcadians:beat'});
36
+
37
+ // Route/component cleanup:
38
+ stopBeat();
39
+ runtime.destroy();
40
+ ```
41
+
42
+ The root must exist in a browser document. It can contain normal HTML `<audio>`
43
+ elements. The library does not autoplay audio. The canonical discovery rules,
44
+ including explicit `data-dance-moves-master` and configured master-length
45
+ matching, are retained. Mount before starting playback; call `discoverAudio()`
46
+ after adding players dynamically when immediate discovery is required.
47
+
48
+ Imports are safe during SSR. Only `createDanceMoves` requires the browser.
49
+ CJS is available with `require('@kieransimkin/dancemoves')`. The independent
50
+ `/server` entry requires Node; `/react` requires React; importing the main module
51
+ does not import either dependency.
52
+
53
+ ## Mount configuration
54
+
55
+ | Option | Default and contract |
56
+ |---|---|
57
+ | `root` | Required Element. One mount per non-overlapping subtree. |
58
+ | `bpm` | Missing/null/empty selects 120 fallback. Otherwise a finite **number** from 20 to 400, rounded to three decimals. Changing tempo requires a new mount. |
59
+ | `pageId` | 0. Optional non-negative safe integer for diagnostics/legacy catalogue settings, not a requirement to have WordPress. |
60
+ | `bpmSource` | Computed; a supplied `fallback` preserves fallback provenance when converting host config. |
61
+ | `lyricTimingUrl`, `cueTimingUrl` | Empty. HTTP(S) or relative URLs; no embedded credentials or executable schemes. Served media must satisfy normal browser CORS rules. |
62
+ | `lyricPopupsEnabled` | false. Boolean, explicit opt-in to the canonical neutral renderer. Use `onLyric` or the React hook for custom treatments. |
63
+ | `masterDurationMilliseconds` | 0 (no length reference); finite number up to one day, rounded to three decimals. |
64
+ | `sharedControlTicks` | 0; enables the inherited control-duration treatment when positive. |
65
+ | `lyricDisclosureTicks` | 6; positive duration ticks. |
66
+ | `effect` | Empty or `paper-planes`. |
67
+ | `catalogue` | false; opt-in CSS timing adoption. |
68
+ | `clay` | false; initialize Clay and its native background adapter when the required Clay markup exists. |
69
+ | `clayPerformance` | Per-mount overrides for the existing Clay monitor. Fields retain their canonical validation and defaults. |
70
+ | `orientation` | Optional `{adapter, transitionTargetTicks, harness}`. Adapter is one of `ORIENTATION_ADAPTERS`. Harness remains loopback-only. |
71
+ | `paperPlanes` | `{atlasUrl, planeCount, compactPlaneCount}`. The atlas URL is required with `effect:'paper-planes'`; use the shipped asset, not a fabricated sprite. |
72
+ | `diagnostics` | false; explicitly enables diagnostic sinks. |
73
+ | `legacyGlobals` | false. WordPress compatibility mode; one per document, not for ordinary React roots. |
74
+ | `nonce` | Optional nonce attached to library-created style elements for an application's CSP. |
75
+ | `onError` | Optional callback for asynchronous readiness and cleanup errors. Handle individual native-controller `ready` failures as well. |
76
+
77
+ A bad root, overlapping mount or unknown adapter throws. Invalid config values
78
+ throw `TypeError`/`RangeError`; the public API does not silently turn bad BPM into
79
+ another tempo. `readWordPressConfig` is an internal wire adapter because
80
+ WordPress localization stringifies scalar values; generic JS should use typed
81
+ values directly.
82
+
83
+ `runtime.ready` completes canonical cue/lyric-file setup. Existing nonfatal file
84
+ fetch behaviour is preserved: an empty/unavailable timing file is not proof of a
85
+ working lyric track. Read native readiness separately with
86
+ `await runtime.rudiments.ready()`. `destroy()` is idempotent, aborts internal work
87
+ and removes resources; it does not stop or delete your audio element.
88
+
89
+ ## Core methods: complete inherited surface
90
+
91
+ The core has **16 ticks per beat**. A tick lasts `3750 / bpm` milliseconds.
92
+ The native library uses **64 pips per beat**, so one core tick is four pips.
93
+ A bar in the convenience methods is four beats.
94
+
95
+ | Method | Meaning |
96
+ |---|---|
97
+ | `quantizeTicks(value)` | Round half up, minimum one; durations above 16 ticks round to multiples of 16. |
98
+ | `durationMilliseconds(ticks, bpmOverride?)` | Quantized duration at configured/override BPM. Not a raw interval-spacing conversion. |
99
+ | `currentTick(options?)` | `{clock, audio, bpm}`. Uses a supplied/active playing audio clock; otherwise page elapsed time. A paused audio element does **not** give a paused clock through this legacy helper. |
100
+ | `nextIntervalTick(interval, fromTick, strictlyFuture?)` | Next integer-spaced interval boundary; interval spacing is not the long-duration quantizer. |
101
+ | `scheduleAtInterval(interval, callback, options?)` | Schedule a page/audio boundary and return a cancellation function. |
102
+ | `deferStart(element, interval, options?)` | Defer an element's visual start; returns cancellation. The canonical start attributes/options remain available. |
103
+ | `onNextInterval(callback, interval, metadata?)` | Next available audio boundary; returns unsubscribe. |
104
+ | `onEveryInterval(callback, interval, metadata?)` | Repeating audio interval handler; unsubscribe on cleanup. |
105
+ | `onNextBeat(callback, metadata?)` | `onNextInterval` at 16 ticks. |
106
+ | `onEveryBeat(callback, metadata?)` | Repeating 16-tick audio boundaries. |
107
+ | `onNextBar(callback, metadata?)` | Next 64-tick boundary. |
108
+ | `onEveryBar(callback, metadata?)` | Repeating 64-tick boundaries. |
109
+ | `normaliseCueName(value)` | Canonical uppercase normalized matching name. |
110
+ | `parseTimingFile(text)` | Parse bracketed timestamps and named/type cues, sorted by time. |
111
+ | `parseLyricTimingFile(text)` | Parse lyrics including empty clear entries, sorted by time. |
112
+ | `onCue(name, handler, metadata?)` | Subscribe to a name or `*`; returns unsubscribe. |
113
+ | `onLyric(handler, metadata?)` | Subscribe to canonical lyric details; returns unsubscribe. |
114
+ | `fireCue(cue)` | Dispatch through the same named-cue/diagnostic/event path. Supply `{name,type,time,audio?}`. |
115
+ | `registerAnimationScope(root, selectors)` | Explicit ownership of animations that may be reset/speed adjusted by cues. Returns removal function. |
116
+ | `resetRunningAnimations(audio?)` | Reset owned running/pending animations; returns reset count. Not a global animation reset. |
117
+ | `discoverAudio()` | Discover eligible audio in this mount. |
118
+ | `setDiagnosticsSink(callbackOrNull)` | Returns false unless diagnostics was enabled at mount. Sink exceptions are contained. |
119
+ | `diagnosticsEnabled()` | Current sink status. |
120
+
121
+ Interval callbacks receive `{intervalTicks,boundaryTick,audio,clock}`. Metadata
122
+ can be a handler ID string or `{id,handlerId}` for attribution. Repeating
123
+ intervals preserve the core's seek/deduplication behaviour; use `cueTimeline`
124
+ for deterministic state restoration instead of treating interval callbacks as
125
+ an event-history replay mechanism.
126
+
127
+ Additional mount properties/methods are `version`, `bpm`, `bpmSource`,
128
+ `ticksPerBeat`, `root`, `config`, `ready`, `destroyed`, `destroy()`, `resources()`
129
+ and `on(eventName, listener)`. `on` returns unsubscribe. Resources reports owned
130
+ listener/timer/frame/observer/generated-node counts, not the browser's entire
131
+ resource inventory.
132
+
133
+ ## Timed files and lyrics
134
+
135
+ Both cue and lyric files use `[mm:ss.cc]`-style timestamps, optionally three-digit
136
+ fractions. Here `.cue` means the plugin's bracketed cue format, **not a CD
137
+ cuesheet**. Example:
138
+
139
+ ```text
140
+ [01:25.00][BUILD: Build 1]
141
+ [01:44.01][DROP: Drop 1]
142
+ [03:07.54][DROP: Final Drop]
143
+ ```
144
+
145
+ Lyric example with an explicit gap:
146
+
147
+ ```text
148
+ [00:02.89]No crown, no concrete
149
+ [00:07.76]
150
+ [00:07.81]Just mountain breath and moonlit stone
151
+ ```
152
+
153
+ The lyric callback contains `pageId`, `time`, `text`, `normalisedText`, `index`,
154
+ `nextTime`, `nextText`, `nextNormalisedText`, `nextIndex`, `nextVisibleTime`,
155
+ `nextVisibleText`, `nextVisibleNormalisedText`, `nextVisibleIndex`, and `audio`.
156
+ Immediate-next fields can describe a blank clear; next-visible fields skip
157
+ blanks. Absent times are null, text empty and indices -1.
158
+
159
+ Pause/end do not manufacture empty core lyric events. A custom renderer should
160
+ hide on audio pause/end; `useLyric` already clears its state for pause, end and
161
+ seeking. Render text with `textContent`/React text, not HTML injection. Styling the
162
+ neutral popup differs in scoped mode (inside the root) and WordPress legacy mode
163
+ (under body). The built-in neutral style is not song-specific art direction.
164
+
165
+ ## Shared effect primitives
166
+
167
+ `runtime.effects` is the canonical registry for this mount. `get(id)` returns a
168
+ controller or null, `snapshot()` returns controller snapshots, `teardown(id)`
169
+ removes one, and `teardownAll()` removes all. Reusing an ID replaces that local
170
+ registry entry. Each factory returns `id`, `type`, `snapshot()` and `teardown()`.
171
+ Pass Elements or scoped selector strings for roots/targets.
172
+
173
+ | Factory | Configuration and extra methods |
174
+ |---|---|
175
+ | `pointer(options)` | `id`, `root`, `bounds` and event `target`; `finePointer` defaults true, `render({x,y,root,bounds,reason})` optional. Publishes `--dance-moves-x/y`. Controller `set(x,y,reason?)`, `reset(reason?)`. |
176
+ | `playbackPulse(options)` | `id`, `root`, `audio`, `ticks` (16), `className` (`dance-moves-playing`), `propertyPrefix` (`--dance-moves-pulse`), optional render. Publishes duration/delay in seconds. Controller `sync()`. |
177
+ | `cueClass(options)` | `id`, `root`, `cue` (`*`), `className`, **`durationTicks`** (16), optional render. Class is applied to root, not a separate `target`. Controller `fire(detail)`, `clear(reason?)`. Its finite duration is wall-clock based. |
178
+ | `cueTimeline(options)` | `id`, `root`, `audio`, `cues:[{id,time,end?,...}]`, optional `render` and `onCue`. Controller `restore(reason?)`, `start(reason?)`, `stop(reason?)`. |
179
+ | `quality(options)` | `id`, `root`, `tiers`, `initial`, sample/window ratios and cooldowns, optional render. Controller `setTier(index,reason?)`. |
180
+
181
+ Pulse render receives `{root,audio,duration,delay,ticks}`. Cue-class render
182
+ receives `{root,detail,count,durationMilliseconds}`. Timeline transient crossings
183
+ are separate from restored active intervals: a seek reconstructs state rather
184
+ than firing every cue crossed. Backward seeks start another traversal. Pause,
185
+ visibility and accessibility changes rebuild/stop according to the canonical
186
+ transport implementation. Inspect the existing timeline demo's live snapshot.
187
+
188
+ Quality defaults are tiers `full/constrained/minimal`, initial 0, sample 1600ms
189
+ (minimum 800), poor windows 2, healthy windows 5, downgrade ratio .72, recovery
190
+ ratio .9, downgrade cooldown 5000ms and recovery cooldown 12000ms. It is not the
191
+ same monitor as Clay's adaptive performance system. Preserve reduced-motion and
192
+ forced-colour CSS; a quality tier is not permission to ignore accessibility.
193
+
194
+ ## Native rudiments and custom renderers
195
+
196
+ ```js
197
+ await runtime.rudiments.ready();
198
+ const controller = runtime.rudiments.animate({
199
+ id: 'arcadians:drift', root: runtime.root,
200
+ target: runtime.root.querySelector('.drift'),
201
+ rudiment: 'clay_background', clock: 'audio', audio: 'audio',
202
+ rate: 0.5, amplitude: {x: 14, y: 10, z: 0}
203
+ });
204
+ await controller.ready;
205
+ // Later: controller.pause(), resume(), setEnabled(false), reset(), refresh(),
206
+ // snapshot(), destroy() / teardown().
207
+ ```
208
+
209
+ ```css
210
+ .drift {
211
+ translate: var(--dance-moves-rudiment-x, 0px)
212
+ var(--dance-moves-rudiment-y, 0px);
213
+ }
214
+ ```
215
+
216
+ The native service exposes `ready()`, `catalogue()`, `describe(name)`,
217
+ `sample(name, integerPip)`, `pipsFromTicks`, `pipsFromSeconds`, `animate`, `get`,
218
+ `snapshot` and `destroyAll`. All 15 pinned motions, negative wrapping, CSS output,
219
+ custom Canvas callbacks, page/audio/auto clocks, cue resets and accessibility
220
+ suspension remain available. Controllers share the native service's scheduler
221
+ within a mount. Do not add another timer to advance them.
222
+
223
+ The complete options, callback fields and limitations are in
224
+ [the native reference](../RUDIMENTS-API.md); `lib/rudiments.d.ts` ships with npm.
225
+ The four-beat Clay pattern at rate .5 preserves the old eight-beat background
226
+ cycle. Native offsets intentionally do not include opacity, rotation or scale;
227
+ the Clay adapter preserves those accents separately at the same phase.
228
+
229
+ ## Catalogue and original adapters
230
+
231
+ With `catalogue:true`, `applyCatalogueTiming()`, `catalogueTimingSnapshot()` and
232
+ `removeCatalogueAnimationScope()` are attached to the runtime. The latter removes
233
+ animation ownership only; use runtime destruction to dispose the entire mount.
234
+
235
+ `getClay()` returns the existing Clay controller when initialized. Its methods
236
+ are `setParameters`, `setMotion`, `setMotionTarget`, `setCueState`, `lifecycle`,
237
+ `reset`, `snapshot`, `teardown`. Visual parameters remain `enabled`,
238
+ `masterIntensity`, `coverTiltDegrees`, `coverTranslationPixels`,
239
+ `bloomTravelPixels`, `flareTravelPixels`, `specularTravelPixels`, and
240
+ `particleReleaseTicks`. `getClayRudiment()` exposes its native background
241
+ controller snapshot/teardown. Required markup and all original mapping examples
242
+ remain in the 23-page WordPress-style gallery, now loading the shared package.
243
+
244
+ `getPaperPlanes()` returns the canonical paper-plane controller; its stage is
245
+ bounded by `.epk-hero`, with `.epk-cover-wrap` and `.epk-heading` for stacking.
246
+ Use `effect:'paper-planes'` and a valid atlas URL. Reduced motion, forced colours,
247
+ compact mode, visibility and resize behaviour remain in that implementation.
248
+
249
+ `orientation.adapter` accepts all nine names exported by `ORIENTATION_ADAPTERS`.
250
+ Their original DOM/CSS selectors and mobile/permission policy remain unchanged;
251
+ selecting another adapter does not fabricate its required DOM. `getOrientation()`
252
+ returns its existing snapshot/reset/teardown API. The unmodified orientation
253
+ helper surface is at `orientationCore` (14 exports, including both schedulers).
254
+
255
+ For an arbitrary application layout, prefer the new generic controller:
256
+
257
+ ```js
258
+ const phone = runtime.createOrientation({
259
+ render: ({x, y}) => {
260
+ subject.style.transform = `translate(${x * 20}px, ${y * 16}px)`;
261
+ }
262
+ });
263
+ button.addEventListener('click', () => phone.enable().catch(console.error));
264
+ // phone.disable(); phone.reset(); phone.snapshot(); phone.destroy();
265
+ ```
266
+
267
+ This uses the existing rolling mapper and frame scheduler, with a 2000ms window,
268
+ 1.5-degree minimum span and 32ms smoothing constant by default. Permission must
269
+ be requested from a real user gesture. The API does not bypass browser permission
270
+ or produce trusted sensor events from synthetic data.
271
+
272
+ ## Captures, metadata and events
273
+
274
+ `createRecorder({durationMilliseconds:30000,maxSamples:500})` returns `start()`
275
+ (Promise of the completed capture), `stop(reason?)`, `snapshot()`, and
276
+ `upload(sameOriginUrl,{headers?,signal?})`. Capture is explicit, bounded and never
277
+ automatically uploaded. Its schema matches the WordPress capture schema but
278
+ physical parity is not claimed. See [Node endpoints](javascript-server.md).
279
+
280
+ `validatePageConfig`, `PAGE_META_KEYS`, `fromWordPressMeta(meta,resolver)` and
281
+ `createMemoryPageStore` are importable without a DOM. The attachment resolver is
282
+ required for nonzero WordPress attachment IDs. The memory store supports `read`,
283
+ `save`, `revisions`, `restore`, `delete`; `expectedRevision` detects concurrent
284
+ updates, and configured page/revision limits bound memory. It is not durable
285
+ storage and not an authorization mechanism.
286
+
287
+ The core's `dance-moves-ready`, `dance-moves-cue`, `kieran-epk-cue` and
288
+ `dance-moves-lyric` events, plus effects/native ready/error events, are emitted
289
+ on the scoped root. The WordPress adapter preserves document dispatch. Register
290
+ lifecycle subscriptions with `runtime.on`; consumers needing initial native
291
+ readiness should await the Promise instead of hoping to subscribe before an
292
+ initialization event already fired.
@@ -0,0 +1,150 @@
1
+ # JavaScript host services (Node)
2
+
3
+ Import from `@kieransimkin/dancemoves/server` only on the server. Node 22.14+
4
+ is the package baseline; Next route handlers must use `runtime = 'nodejs'`, not
5
+ Edge. The main browser entry contains no filesystem/signing/capture secret.
6
+
7
+ WordPress retains its existing PHP endpoints and native storage. These services
8
+ provide the corresponding operations to another host; there is no attempt to
9
+ turn a browser into WordPress or to reuse its public fixed capture token as an
10
+ application authentication scheme.
11
+
12
+ ## Page properties and revisions
13
+
14
+ `validatePageConfig(input)` and `fromWordPressMeta(meta, attachmentResolver)` have
15
+ the same contracts as the main entry. Validation must run on the server before
16
+ persisting untrusted updates even when the React form already validated them.
17
+ Only persist fields the authenticated caller is allowed to change.
18
+
19
+ `createMemoryPageStore({maxPages:100,maxRevisions:20})` provides:
20
+
21
+ ```js
22
+ const record = pages.save('arcadians', {bpm:145}, {expectedRevision:0});
23
+ const current = pages.read('arcadians');
24
+ const history = pages.revisions('arcadians');
25
+ const restored = pages.restore('arcadians', record.revision,
26
+ {expectedRevision:current.revision});
27
+ pages.delete('arcadians');
28
+ ```
29
+
30
+ Records are `{revision,config,savedAt}`. Read operations return clones; restore
31
+ creates a new revision. A revision mismatch or exceeded page limit fails. This
32
+ bounded store demonstrates the contract, not a durable production database. Use
33
+ an application transaction/optimistic lock for durable multi-process storage.
34
+
35
+ ## Timing upload validation
36
+
37
+ `validateTimingFile(textOrUint8Array, {kind:'cue',filename?,maxBytes:1048576})`
38
+ returns frozen `{text,kind,bytes,timestamps}`. It accepts lyric `.lrc` or cue
39
+ `.lrc`/`.cue` extensions, strict UTF-8, no null/replacement characters, at least
40
+ one bracketed timestamp and monotonically ordered timestamps. Equal times and
41
+ lyric clear lines are retained. File parsing into browser events is provided by
42
+ the canonical runtime's parser methods.
43
+
44
+ Failure uses `ValidationError` with `code`, `message` and `status`. Relevant codes
45
+ are `timing_extension`, `timing_size` (413), `timing_utf8`, `timing_timestamps` and
46
+ `timing_order` (400). Invalid option types throw `TypeError`.
47
+
48
+ Validate uploaded bytes and server-owned filenames. This API does not fetch
49
+ arbitrary remote timing URLs; adding such a feature to your host requires SSRF,
50
+ redirect, size and content-type controls. The `.cue` convention is bracketed
51
+ musical cues, not the unrelated CD INDEX syntax.
52
+
53
+ ## Signed MP3 attachment delivery
54
+
55
+ ```js
56
+ import {createDownloadService} from '@kieransimkin/dancemoves/server';
57
+ const downloads = createDownloadService({
58
+ rootDirectory: '/srv/my-app/media',
59
+ secret: process.env.DANCEMOVES_DOWNLOAD_SECRET,
60
+ origin: 'https://music.example',
61
+ route: '/api/download'
62
+ });
63
+ const attachmentUrl = downloads.sign('arcadians.mp3');
64
+ const response = await downloads.handle(request);
65
+ ```
66
+
67
+ `rootDirectory` must be absolute. `secret` is a server-only string/Uint8Array of at
68
+ least 32 bytes. `origin` must be HTTP(S), and route a same-origin absolute path
69
+ without query/fragment. `sign` returns a URL with normalized relative MP3 `path`
70
+ and HMAC-SHA256 `signature` query fields. Unsafe input throws `ValidationError`.
71
+
72
+ `normalizeDownloadPath(value)` returns the safe decoded relative path or an
73
+ empty string. It rejects traversal, absolute/drive paths, controls, empty path
74
+ segments and non-MP3 suffixes. Signature comparison is timing-safe. The handler
75
+ resolves the real filesystem path and verifies containment below the allowed
76
+ root; symlink escapes are rejected. Do not give an untrusted writer control over
77
+ that directory while serving it.
78
+
79
+ `handle(Request)` returns a Web `Response`: 200 for valid GET/HEAD, 404 for an
80
+ invalid signature/path/missing file, and 405 (`Allow: GET, HEAD`) for other methods
81
+ with otherwise valid routing. It sends `Content-Type: audio/mpeg`, exact content
82
+ length, UTF-8 attachment filename, `nosniff`, `noindex` and private/no-store cache
83
+ headers. HEAD closes the file without a response body. GET streams the file and
84
+ closes its handle.
85
+
86
+ Playback and download are deliberately separate. Use the ordinary static media
87
+ URL for `<audio>` so the host can provide range requests. The signed attachment
88
+ handler does not implement playback ranges or rewrite audio/source elements.
89
+ Render the signed URL on an `<a download>` element. A valid signature demonstrates
90
+ a server-produced path, not that the visitor bought access; implement additional
91
+ authorization where needed. Rotating the secret invalidates old links.
92
+
93
+ ## Private motion capture
94
+
95
+ `validateMotionCapture(payload)` validates and sanitizes the inherited
96
+ `ks-epk-motion-recording/v1` schema. It requires 1–500 samples; finite timestamp
97
+ 0–120000ms, beta -180..180 and gamma -90..90; alpha may be null/0..360.
98
+ Optional sample/capture flags and bounded descriptive fields are preserved.
99
+ Invalid nullable optional numeric fields become null, following the existing
100
+ schema's permissive optional-field policy. It caps failure records/counters and
101
+ the sanitized JSON size. The new HTTP boundary also bounds the incoming body
102
+ before JSON parsing, including chunked requests without Content-Length.
103
+
104
+ ```js
105
+ import {createCaptureHandler} from '@kieransimkin/dancemoves/server';
106
+ const POST = createCaptureHandler({
107
+ allowedOrigin: 'https://music.example',
108
+ authorize: request => sessions.canCapture(request),
109
+ rateLimit: request => limits.consumeVerifiedUser(request),
110
+ store: async (capture, {visibility, request}) => {
111
+ const id = await database.storePrivateCapture(capture, request);
112
+ return {captureId:id, storedPrivately:true};
113
+ }
114
+ });
115
+ ```
116
+
117
+ All three callbacks are required. `authorize` and `rateLimit` return booleans or
118
+ Promises. `store` receives `{visibility:'private',request}` and must return a
119
+ truthy `captureId` and **`storedPrivately:true`**. The flag is a contract with the
120
+ host, not a substitute for real storage permissions. The library cannot verify
121
+ how a custom database adapter publishes its rows.
122
+
123
+ Responses are JSON, no-store:
124
+
125
+ | Condition | Status/code |
126
+ |---|---|
127
+ | Not POST | 405 `method_not_allowed`, Allow POST |
128
+ | Wrong or malformed supplied Origin | 403 `ks_motion_origin` |
129
+ | Authorization denied | 403 `ks_motion_forbidden` |
130
+ | Rate limit denied | 429 `ks_motion_rate`, Retry-After 600 |
131
+ | Not application/json | 415 `ks_motion_content_type` |
132
+ | Invalid schema/body/samples | 400 `ks_motion_schema`, `ks_motion_samples`, `ks_motion_invalid_samples` |
133
+ | Body/sanitized payload too large | 413 `ks_motion_size` |
134
+ | Storage/callback failed or did not confirm privacy | 500 `ks_motion_server`, with no internal error details |
135
+ | Success | 200 `{captureId,sampleCount,storedPrivately:true}` |
136
+
137
+ A missing Origin is not automatically authorization; the mandatory authorization
138
+ callback must still succeed. With cookie/session authentication, enforce your
139
+ host's CSRF policy. Do not authorize based on an untrusted payload flag or
140
+ forwarded IP header. Native/simulated/parity flags remain **client-reported**.
141
+
142
+ `createMemoryRateLimiter({limit:8,windowMilliseconds:600000,maxKeys:10000,now})`
143
+ returns `{consume(key)}`. It expires old keys, bounds key count and fails closed
144
+ when capacity is reached. This is for local demos/single-process applications;
145
+ replicas or serverless hosts need a shared atomic limiter.
146
+
147
+ The Next demo's private storage path is checked both lexically and through
148
+ realpath against `public/`. New files use exclusive creation and private POSIX
149
+ modes where supported. Production ACLs, backups, retention, user access and
150
+ data-protection policy remain the host's responsibility.
@@ -0,0 +1,185 @@
1
+ # React and Next.js
2
+
3
+ The React entry is optional and starts with `"use client"`. Neither the main
4
+ entry nor the hooks create a browser runtime during module import/server render.
5
+ The hooks call the same shared `createDanceMoves` factory and dispose it on
6
+ unmount. There is no React-only animation engine.
7
+
8
+ ## A React component
9
+
10
+ ```jsx
11
+ 'use client';
12
+ import {useMemo, useRef} from 'react';
13
+ import {useDanceMoves, useRudiment, useLyric} from '@kieransimkin/dancemoves/react';
14
+ import '@kieransimkin/dancemoves/styles.css';
15
+
16
+ export default function ArcadiansPlayer() {
17
+ const root = useRef(null);
18
+ const target = useRef(null);
19
+ const config = useMemo(() => ({
20
+ bpm: 145,
21
+ lyricTimingUrl: '/media/canonical-lyric-timing.lrc',
22
+ cueTimingUrl: '/media/sections.cue',
23
+ masterDurationMilliseconds: 273604.558
24
+ }), []);
25
+ const motion = useMemo(() => ({
26
+ id: 'arcadians:sway', rudiment: 'sway', clock: 'audio', audio: 'audio',
27
+ amplitude: {x: 24, y: 12, z: 0}
28
+ }), []);
29
+ const {runtime, error} = useDanceMoves(root, config);
30
+ const native = useRudiment(runtime, target, motion);
31
+ const lyric = useLyric(runtime);
32
+ return <section ref={root} className="ks-epk">
33
+ <audio controls preload="metadata" src="/media/arcadians.mp3" />
34
+ <div ref={target} className="drifting-art">Arcadians</div>
35
+ <p>{lyric?.text || ''}</p>
36
+ {(error || native.error) && <p role="alert">{(error || native.error).message}</p>}
37
+ </section>;
38
+ }
39
+ ```
40
+
41
+ ```css
42
+ .drifting-art {
43
+ translate: var(--dance-moves-rudiment-x, 0px)
44
+ var(--dance-moves-rudiment-y, 0px);
45
+ }
46
+ ```
47
+
48
+ Keep `config` and controller options stable with `useMemo`. New options deliberately
49
+ cause a destroy/remount so page clocks, media files and tempo are never partially
50
+ reconfigured. A ref must be attached when the effect runs. Remount the component
51
+ when its root changes; do not mount nested providers over the same subtree.
52
+
53
+ ## Hook and component reference
54
+
55
+ | Export | Contract |
56
+ |---|---|
57
+ | `useDanceMoves(rootRef, options?)` | `{runtime, error}`; runtime is initially null during SSR/first render. Automatically creates and destroys the shared runtime. |
58
+ | `DanceMovesProvider({options, children, className, ...divProps})` | Owns one `div.ks-epk` and exposes the hook result through context. |
59
+ | `useDanceMovesContext()` | Returns that context; throws outside a provider. |
60
+ | `useCue(runtime, name, callback)` | Subscribes/unsubscribes canonical cues; callback ref keeps the latest closure without recreating subscriptions. |
61
+ | `useLyric(runtime)` | Current lyric payload or null. Clears on player pause/end/seeking as well as canonical blank cues. |
62
+ | `useRudiment(runtime, targetRef, options)` | `{controller,error}`; owns one native animation and destroys it on dependency changes/unmount. |
63
+ | `Rudiment({runtime, options, children, ...divProps})` | Div wrapper using `useRudiment`. CSS consumes its scoped native output. |
64
+ | `PageMetadataEditor({value,onSave})` | Controlled host-neutral BPM, timing URLs, duration, popup and effect editor. Validates before calling `onSave`; persistence/auth are not supplied by a browser form. |
65
+
66
+ The provider does not inject audio, attach an automatic upload endpoint or
67
+ replace your application's router. Keep event subscriptions and custom renderer
68
+ writes inside effects with cleanup. Development Strict Mode is supported, not
69
+ disabled to hide duplicate initialization.
70
+
71
+ ## Next.js App Router
72
+
73
+ Import global DanceMoves CSS in `app/layout.jsx`. Put the player/hooks inside a
74
+ Client Component. The shared `/server` entry belongs only in Server Components,
75
+ route handlers or other server-only modules. The download secret must never be
76
+ named `NEXT_PUBLIC_*` or passed to a Client Component.
77
+
78
+ The included `examples/next` app imports `examples/react/App.jsx`; there is one
79
+ React demonstration implementation, not a copy maintained in both apps. The
80
+ repository root supplies the local package exports and a single React install.
81
+ `tools/run-next.mjs` runs Next in that app directory. Do not `npm install` a
82
+ second copy of the library inside the demo app.
83
+
84
+ The production download/capture examples are Node route handlers:
85
+
86
+ ```js
87
+ // app/api/download/route.js
88
+ import path from 'node:path';
89
+ import {createDownloadService} from '@kieransimkin/dancemoves/server';
90
+ export const runtime = 'nodejs';
91
+ const downloads = createDownloadService({
92
+ rootDirectory: path.resolve(process.cwd(), 'public/media'),
93
+ origin: process.env.DANCEMOVES_ORIGIN,
94
+ secret: process.env.DANCEMOVES_DOWNLOAD_SECRET
95
+ });
96
+ export const GET = request => downloads.handle(request);
97
+ export const HEAD = request => downloads.handle(request);
98
+ ```
99
+
100
+ Sign the known, allowed filename in a Server Component and pass only the signed
101
+ URL to the player. Do not expose an unauthenticated public endpoint that signs
102
+ arbitrary filenames. The capture example requires a separate key and private
103
+ folder outside `public/`; its single-process limiter is for demonstration.
104
+ Replace it with application sessions, capabilities and a shared limiter for
105
+ production replicas. Filesystem persistence is unsuitable for read-only or
106
+ stateless hosting; provide a durable private database/object-store callback.
107
+
108
+ ## Run the Arcadians demos
109
+
110
+ From the repository root after applying the patch:
111
+
112
+ ```sh
113
+ npm install --ignore-scripts
114
+ npm run build
115
+ npm run demo:prepare
116
+ npm run demo:react
117
+ ```
118
+
119
+ Open `http://127.0.0.1:4173/`. The media preparer downloads the hash-pinned Arcadians
120
+ MP3 and artwork from StemLab; it never generates a substitute. Existing canonical
121
+ LRC and section data are reused. A local StemLab checkout can be supplied through
122
+ `python tools/prepare-app-demos.py --stemlab-root ../stemlab`.
123
+
124
+ The React gallery has panels for all native motions, pointer mapping, playback
125
+ pulse, named cues, cue timeline, quality, generic phone orientation, Clay, planes,
126
+ metadata/revisions and private capture. Every panel uses the same Arcadians
127
+ player. Chapter buttons, playback rate, canonical lyrics, lifetime inspection,
128
+ and runtime unmount/remount controls are shared. Camera/sensor parity and
129
+ physical performance are not simulated as proof of real-device behaviour.
130
+
131
+ For Next.js:
132
+
133
+ ```sh
134
+ npm run demo:next
135
+ ```
136
+
137
+ Open `http://localhost:3000/`. Copy `examples/next/.env.example` to
138
+ `examples/next/.env.local` and set your own server-only secrets to enable signed
139
+ downloads/captures. Use at least 32 bytes of unpredictable secret material;
140
+ example test values in the automated tests are not production secrets.
141
+ Without configuration those server features are disabled/reject requests, not
142
+ silently backed by a public token. The song still plays via its ordinary media URL.
143
+
144
+ The existing 23-page WordPress-style gallery also imports `/lib/index.mjs` now:
145
+
146
+ ```sh
147
+ python tools/serve-wordpress-examples.py
148
+ ```
149
+
150
+ Open `http://127.0.0.1:8765/examples/wordpress/`. It retains all nine legacy
151
+ orientation mappings, all 23 core methods, the catalogue and diagnostic workbench,
152
+ all 15 native motions, HTTP simulations and page metadata inspectors. Those
153
+ simulated PHP/HTTP behaviours are labelled; the frontend module is real.
154
+
155
+ ## Tests and production builds
156
+
157
+ ```sh
158
+ npm test
159
+ npm run test:legacy
160
+ npx playwright-core install --with-deps chromium
161
+ npm run test:browser
162
+ npm run test:next
163
+ npm run test:next:smoke
164
+ ```
165
+
166
+ `npm test` includes real ESM/CJS imports and React server rendering after a build.
167
+ Browser tests use the real React bundle, actual WASM and canonical MP3, check
168
+ chapter seeking, feature mounts, cue effects, Strict Mode disposal/remount and
169
+ capture a reduced-motion screenshot. The Next smoke test starts the actual
170
+ production build and exercises server rendering, signed HEAD downloads,
171
+ unauthenticated rejection and private capture persistence. Missing build/media
172
+ or browser prerequisites fail; they are not reported as passing skipped tests.
173
+
174
+ Desktop Chromium automation does not certify iOS permission prompts, Safari
175
+ pseudo-element animation behaviour, screen orientation or real device cadence.
176
+ Keep attended mobile/browser checks before deployment.
177
+
178
+ ## CSP and accessibility
179
+
180
+ Serve the atlas and media as same-origin public assets unless CORS is deliberately
181
+ configured. Dynamic style elements can use the `nonce` mount option. The native
182
+ backend needs browser WebAssembly permission under the application's CSP; do not
183
+ turn on arbitrary JavaScript eval. Test actual CSP headers and fallback behaviour.
184
+ Avoid full-frame flashes or brightness/tint modulation. Honour reduced motion
185
+ and forced colours in custom callbacks as well as in the shipped CSS.