@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,400 @@
1
+ # Catalogue, orientation and release-adapter APIs
2
+
3
+ [Documentation index](../README.md) · [Core JavaScript](javascript.md) ·
4
+ [CSS/HTML](styling.md)
5
+
6
+ Baseline: **2.8.0 / `4cb6a71f60459b5579be87d7e55ac8b1426c578e`**.
7
+ Conditional globals exist only when their asset and required root have loaded.
8
+ These APIs do not create a missing EPK or register a new WordPress Page mapping.
9
+
10
+ For the separate reusable factories see [shared effects](effects.md); for the
11
+ page-selected sprite renderer see [paper planes](paper-planes.md). Their lifecycle
12
+ and quality rules differ from this file's release-specific Clay/Stars API.
13
+
14
+ ## Catalogue timing
15
+
16
+ Source: [dance-moves-catalogue-timing.js](../../assets/dance-moves-catalogue-timing.js).
17
+
18
+ Root discovery first selects `.ks-epk`. Only when that is absent does it use the
19
+ configured Page ID's fallback:
20
+
21
+ | Page ID | Fallback selector |
22
+ | --- | --- |
23
+ | 130 | `.dfad-motion-stage` |
24
+ | 137 | `.birth-stories-page` |
25
+ | 140 | `#light-will-win-top` |
26
+ | 150 | `.mog-epk` |
27
+ | 298 | `.dmt-epk` |
28
+ | 397 | `.sft-epk` |
29
+ | 399 | `.entry-content` |
30
+
31
+ Other IDs use the implementation sentinel `[data-dance-moves-no-root]`, not a
32
+ publicly configurable selector. With no core/root, the script returns without
33
+ installing an API. Root discovery happens once, not on every later DOM insertion.
34
+
35
+ ### Methods and aliases
36
+
37
+ | Method | Return | Behaviour |
38
+ | --- | --- | --- |
39
+ | `DanceMovesCatalogueTiming.apply()` | `CatalogueSnapshot` | Re-runs the conversion pass synchronously and marks readiness. |
40
+ | `DanceMovesCatalogueTiming.snapshot()` | `CatalogueSnapshot` | Reads evidence without running conversion. |
41
+ | `DanceMoves.applyCatalogueTiming()` | `CatalogueSnapshot` | Same function as `.apply()`. |
42
+ | `DanceMoves.catalogueTimingSnapshot()` | `CatalogueSnapshot` | Same function as `.snapshot()`. |
43
+ | `DanceMoves.removeCatalogueAnimationScope()` | void | Removes the one wildcard cue-reset scope registered by the catalogue adopter. |
44
+
45
+ Scope removal **does not** undo stylesheet/inline changes, remove generated CSS,
46
+ disconnect the MutationObserver, remove other scopes or uninstall the API. There
47
+ is no full catalogue teardown/undo method.
48
+
49
+ ### What gets converted
50
+
51
+ The pass scans accessible CSS rules and referenced timing custom properties,
52
+ then inline and computed declarations on the root and descendants, then computed
53
+ `::before`/`::after` timing. Properties are `animation-duration`, `animation-delay`,
54
+ `transition-duration` and `transition-delay`. Nonzero time tokens are converted
55
+ to signed, quantised tick durations. Negative delays preserve their sign. Values
56
+ already containing `--dance-moves-` are left alone.
57
+
58
+ Zero and magnitudes at most 0.02 ms are preserved. Animation timing associated
59
+ with a non-auto/non-none view timeline preserves magnitudes at most 1 ms; Page 248
60
+ also has the legacy 1 ms rule exception. Generated CSS adds a reduced-motion
61
+ clamp for the owned subtree/pseudo-elements: 0.01 ms duration, zero delay, one
62
+ iteration and automatic scrolling.
63
+
64
+ Rule ownership is determined from root tokens or selector matches within the
65
+ root; transient selector states/pseudo-elements are stripped for matching.
66
+ Referenced timing variables can be rewritten where they are defined, including
67
+ outside the owned selector. A mixed selector list or broad shared selector is not
68
+ split into owned/unowned rules. Isolate release CSS and timing variables rather
69
+ than assuming this heuristic is a strict CSS sandbox. Inaccessible cross-origin
70
+ CSSOM sheets are skipped, though computed styles within the root can still be
71
+ converted. The pass does not rewrite canvas, shaders, JS timers, easing functions,
72
+ keyframe geometry or animation names.
73
+
74
+ The adopter registers `["*"]` on the root for cue resets. A MutationObserver
75
+ watches child insertions and `style` attributes in that root and batches another
76
+ pass into one pending animation frame. Class changes, stylesheet changes outside
77
+ the root, dynamic root replacement and audio discovery are not general observer
78
+ contracts; call `.apply()` when appropriate, and use a new page load for a new root.
79
+ Repeated passes append generated rules; this is not a side-effect-free inspection
80
+ API or a promise of stylesheet compaction.
81
+
82
+ ### `CatalogueSnapshot`
83
+
84
+ ```text
85
+ {
86
+ pageId: number,
87
+ bpm: number,
88
+ bpmSource: "explicit" | "fallback",
89
+ conversionCount: number,
90
+ convertedCustomProperties: string[],
91
+ records: [{source, property, before, after, ticks: number[]}]
92
+ }
93
+ ```
94
+
95
+ Evidence is capped at **1000 records**. `conversionCount` is the retained record
96
+ count, not an unlimited total of converted tokens or elements. Custom property
97
+ names are sorted. `records` is an array copy, not a deep freeze; do not mutate
98
+ record objects. Evidence stays local to the runtime unless a consumer exports it.
99
+
100
+ ## Orientation page map
101
+
102
+ Sources: [PHP map](../../kieran-epk-device-orientation.php),
103
+ [orientation wrapper](../../assets/ks-epk-device-orientation.js),
104
+ [orientation CSS](../../assets/ks-epk-device-orientation.css).
105
+
106
+ | Page | Adapter key | Required root / additional target | Release behaviour |
107
+ | --- | --- | --- | --- |
108
+ | 130 | `dying-for-a-diagnosis` | `.dfad-motion-stage`; optional `[data-tilt]`, `[data-parallax] img` | Cover shift/rotation and artwork translation; responsive artwork scale. |
109
+ | 140 | `light-will-win` | `#light-will-win-top` | Cover translation and tilt on release cards. |
110
+ | 243 | `presents-and-chocolate` | `.ks-epk[data-release="presents-and-chocolate"]` | Cover sensor shift/rotation. |
111
+ | 252 | `clay-stars` | `.ks-epk.ks-clay-stars-v2` and matching `DanceMovesClayStars.root` | Delegates to `setMotionTarget()` or `setMotion()`; no independent duplicate Clay runtime. |
112
+ | 268 | `fully-nocturnal` | `.ks-epk.fn-live[data-release="fully-nocturnal"]` | Scene/card movement; resets while `data-fnx-motion="off"`. |
113
+ | 270 | `amnesty-honestly` | `.ks-epk` | Cover tilt/shift and heading counter-movement. |
114
+ | 276 | `walk-with-me` | `.ks-epk[data-release="walk-with-me"]` and `.wwm-cover-stage` or `.epk-cover-wrap` | Cover tilt/shift and moving glow position. |
115
+ | 298 | `dmitri-my-talisman` | `.dmt-epk` | Light/parallax/tilt; resets with the `reduce-motion` class. |
116
+ | 839 | `california-screamin` | `#cs-epk.cs-epk` (release attribute optional) | Writes `--cs-x`/`--cs-y`; resets with `motion-paused`. |
117
+
118
+ The original seven adapters are joined by Clay/Stars and California Screamin'.
119
+ The wrapper can detect several roots when `config.adapter` is absent, but PHP
120
+ still controls which Pages receive its assets. There is no public
121
+ `registerAdapter()` or Page-ID filter. Adding a new production mapping requires
122
+ changes to the PHP map and wrapper factory table plus release tests.
123
+
124
+ ### `window.ksEpkOrientationConfig`
125
+
126
+ WordPress emits `adapter`, `pageId`, `version`, `bpm`, `bpmSource`, `ticksPerBeat`
127
+ (16), and `transitionTargetTicks` (2). The wrapper uses BPM clamped to 20–400 and
128
+ computes `60000 / bpm / ticksPerBeat * transitionTargetTicks` milliseconds.
129
+
130
+ `harness: true` bypasses the mobile/support gate **only** on exact hostnames
131
+ `localhost` or `127.0.0.1`. It is not emitted in production. Merely loading the
132
+ core helper library does not start sensors.
133
+
134
+ ### Input and permission lifecycle
135
+
136
+ The production gate requires `KSEpkOrientationCore`, mobile eligibility and sensor
137
+ support. The wrapper uses a 2000 ms rolling mapper, 1.5-degree minimum span, a
138
+ latest-sample animation-frame scheduler with 32 ms smoothing time constant, then
139
+ a target scheduler with a two-tick interval and 0.02 minimum normalised delta.
140
+ The first meaningful target is immediate; further targets retain only the latest
141
+ pending value. Release adapters apply a bounded signed square-root perceptual
142
+ curve before their own mappings.
143
+
144
+ Permission-requesting browsers get one **Use phone motion** button that requests
145
+ permission from its click handler. Granted permission starts listening; denial
146
+ shows **Phone motion blocked**. A 3500 ms availability timer can mark the button
147
+ unavailable if valid input never activates the adapter. This is an input
148
+ availability timeout, not a musical effect duration.
149
+
150
+ Visibility/pagehide stops listening and resets targets; page return can recreate
151
+ the permission control or resume nonpermission input. Screen rotation resets the
152
+ mapper. A preference change to reduced motion stops listening and removes the
153
+ control. At initial startup, the permission-control creation path is not itself
154
+ gated by the reduced-motion check, so the control can exist in the DOM in an
155
+ initially reduced-motion session. The bundled stylesheet hides it, and input
156
+ application remains gated.
157
+ Root removal triggers teardown through a MutationObserver.
158
+
159
+ ### Internal lifecycle handle
160
+
161
+ `window.__ksEpkOrientationRuntime` is an internal diagnostic handle, installed
162
+ only after a valid adapter initialises. It exposes a frozen object:
163
+
164
+ | Method | Behaviour |
165
+ | --- | --- |
166
+ | `reset() -> void` | Cancels pending work, resets mapper/target state, marks supported and calls adapter reset. Does not remove listeners or permanently stop the instance. |
167
+ | `snapshot() -> object` | `{adapter, active, listening, destroyed, latest:{x,y}, reducedMotion, documentHidden}`. |
168
+ | `teardown() -> void` | Idempotently removes listeners/control/observer, cancels both schedulers, resets adapter, and deletes the global if it still points to this instance. No public restart method. |
169
+
170
+ Do not treat the leading-underscore handle as the application-level configuration
171
+ API. For reusable math or a separate test harness, use the exported core below.
172
+
173
+ ## `KSEpkOrientationCore`
174
+
175
+ Source: [ks-epk-device-orientation-core.js](../../assets/ks-epk-device-orientation-core.js).
176
+ Exposed as `window.KSEpkOrientationCore` in the browser or `module.exports` in
177
+ CommonJS. It is a pure/helper library: no permission prompts, DOM writes or sensor
178
+ listeners occur merely by importing it. Supply finite numeric inputs unless a
179
+ method explicitly checks them; not every helper validates its arguments.
180
+
181
+ ### Scalar and environment helpers
182
+
183
+ | Signature | Result / defaults |
184
+ | --- | --- |
185
+ | `clamp(value, min, max) -> number` | `Math.min(max, Math.max(min, value))`; does not repair NaN. |
186
+ | `hasMotionData(event) -> boolean` | True only when `beta` and `gamma` are finite numbers; nulls/numeric strings are not valid readings. |
187
+ | `isMobileDevice(environment) -> boolean` | Combines UA/mobile client hint, touch iPad detection, or coarse pointer + touch + screen width at most 1024. Fallback width is `innerWidth`, then 9999. This is eligibility detection, not a measured performance classification. |
188
+ | `sensorSupported(environment) -> boolean` | `isSecureContext !== false` and `DeviceOrientationEvent` exists. This alone does not prove hardware availability or permission. |
189
+ | `permissionRequired(environment) -> boolean` | Support plus a `DeviceOrientationEvent.requestPermission` function. |
190
+ | `screenAngle(environment) -> number` | Finite `ksHarnessScreenAngle` override, then `screen.orientation.angle`, then legacy `orientation`, otherwise 0. |
191
+ | `screenAligned(event, angle) -> {x,y}` | Rotates absolute gamma/beta into screen coordinates in degrees: `x = gamma*cos + beta*sin`, `y = beta*cos - gamma*sin`. |
192
+ | `normalise(event, neutral, angle, maximumDegrees?) -> {x,y}` | Wraps beta/gamma deltas into -180–180, screen-rotates, divides by the positive caller-supplied maximum (default 20), clamps axes to -1–1. Separate from the production rolling mapper. |
193
+ | `mapPointToWindow(point, windowState, minimumSpanDegrees?) -> {x,y}` | Maps a point using `minX/maxX/minY/maxY`; a span below the default 1.5 degrees produces zero on that axis; otherwise clamps to -1–1. |
194
+ | `smooth(previous, next, amount?) -> {x,y}` | Linear interpolation; falsy amount defaults to 0.18, then clamps to 0.01–1. Passing 0 therefore uses 0.18. |
195
+ | `smoothTimeBased(previous, next, elapsedMilliseconds, timeConstantMilliseconds?) -> {x,y}` | Uses alpha `1 - exp(-elapsed/tau)`; elapsed is nonnegative, default tau 32 ms with minimum 1. Zero elapsed returns the previous position. |
196
+
197
+ ### `createRollingMapper(windowMilliseconds?, minimumSpanDegrees?) -> Mapper`
198
+
199
+ Defaults are **10000 ms** and **1.5 degrees**. The production wrapper explicitly
200
+ uses 2000 ms, so do not confuse the factory default with that runtime setting.
201
+ The numeric defaults use truthy fallback rather than a strict positive-number
202
+ validator; pass valid positive values and monotonic timestamps in milliseconds.
203
+
204
+ | Mapper method | Result |
205
+ | --- | --- |
206
+ | `push({x,y}, timestamp)` | Adds one point, evicts batches older than `timestamp - windowMilliseconds`, returns a mapped result. |
207
+ | `pushBatch({count,sumX,sumY,minX,maxX,minY,maxY}, timestamp)` | Adds weighted aggregate data; `count` has a minimum of 1. Current position is this batch's mean. |
208
+ | `reset()` | Clears retained batches; no return value. |
209
+ | `size()` | Number of retained batches, not weighted sample count. |
210
+
211
+ Mapped result:
212
+
213
+ ```text
214
+ {
215
+ x, y, // current point/last batch mean mapped to -1..1
216
+ currentX, currentY,
217
+ meanX, meanY, // weighted window means, in input units
218
+ minX, maxX, minY, maxY,
219
+ sampleCount, batchCount,
220
+ windowMilliseconds
221
+ }
222
+ ```
223
+
224
+ ### `createLatestSampleRafScheduler(options) -> Scheduler`
225
+
226
+ Requires callable `requestFrame(callback)` and `commit(x, y, detail)`; otherwise
227
+ throws `TypeError`. Options:
228
+
229
+ | Option | Default / use |
230
+ | --- | --- |
231
+ | `requestFrame` | Required frame requester, e.g. bound `requestAnimationFrame`. |
232
+ | `cancelFrame` | No-op; supply a real cancellation function in a browser. |
233
+ | `now` | Function returning 0 by default; use the same time base as frame timestamps. |
234
+ | `mapper` | `createRollingMapper(2000, 1.5)`; custom object needs `push()` and `reset()`. |
235
+ | `smoothingTimeConstantMilliseconds` | 32 via numeric truthy fallback. |
236
+ | `commit` | Required consumer of the smoothed latest position. |
237
+ | `reject` | Optional callback for invalid input received while running. |
238
+
239
+ The scheduler keeps one latest finite sensor sample and one pending frame. At
240
+ flush it screen-aligns/mapping-processes the retained point and uses time-based
241
+ smoothing. The first smoothing step uses eight time constants. Commit detail is:
242
+ `{sample:{x,y}, sampleTimestamp, frameTimestamp, sampleAge, commitCount}`.
243
+ `sampleAge` is nonnegative milliseconds.
244
+
245
+ | Scheduler method | Contract |
246
+ | --- | --- |
247
+ | `receive(event, angle, timestamp?) -> boolean` | Rejects invalid beta/gamma or a torn-down instance; otherwise retains the latest screen-aligned sample and schedules one frame. Missing/nonfinite timestamp uses `now()`. |
248
+ | `reset() -> void` | Cancels the frame, clears sample/smoothing/counts and resets the mapper; does not turn a torn-down instance back on. |
249
+ | `teardown() -> void` | Resets and permanently stops the instance. |
250
+ | `pending() -> boolean` | Whether a frame ID is pending. |
251
+ | `commitCount() -> number` | Commits since reset. |
252
+
253
+ ### `createTransitionTargetScheduler(options) -> TargetScheduler`
254
+
255
+ Requires `commit(x, y, detail)`, otherwise throws `TypeError`. Defaults:
256
+ `now: () => 0`, `schedule: setTimeout` wrapper, `cancel: clearTimeout`,
257
+ `intervalMilliseconds: 0`, `minimumDelta: 0`. Interval/delta are coerced to
258
+ nonnegative numbers. Use an actual monotonic `now()` in a real integration.
259
+
260
+ `receive(x, y, detail?)` rejects nonfinite axes or a torn-down instance. Outside
261
+ the rate-limit window it immediately attempts a commit; inside it retains the
262
+ latest target and schedules a single trailing timeout. Material change is the
263
+ maximum axis difference being at least `minimumDelta`. With zero minimum delta,
264
+ identical targets can still commit. A queued receive can return true even if that
265
+ target is later superseded or suppressed as too small.
266
+
267
+ Commit detail copies the supplied detail and adds `targetTimestamp` and
268
+ `targetCommitCount`. Returned methods are `receive(x,y,detail?) -> boolean`,
269
+ `reset() -> void`, `teardown() -> void`, `pending() -> boolean` and
270
+ `commitCount() -> number`. Reset clears targets/timing/counts; teardown also
271
+ permanently stops the instance. Neither factory exposes a restart or flush method.
272
+
273
+ ## `DanceMovesClayStars`
274
+
275
+ Source: [clay-stars-effects.js](../../assets/clay-stars-effects.js) and
276
+ [clay-stars-effects.css](../../assets/clay-stars-effects.css).
277
+ Created only with `.ks-epk.ks-clay-stars-v2` and a descendant `.epk-cover-wrap`.
278
+ A live instance for the same root prevents duplicate initialisation. PHP normally
279
+ loads it only on Page 252 without the legacy effects constant.
280
+
281
+ ### Properties and parameters
282
+
283
+ `root` is the root DOM element. `defaults` is frozen; the exported API object is
284
+ also frozen. Modify runtime parameters through `setParameters()`, not `defaults`.
285
+
286
+ | Parameter | Default | Accepted range / coercion |
287
+ | --- | --- | --- |
288
+ | `enabled` | true | Boolean when the key is present. |
289
+ | `masterIntensity` | 2 | 0–2 |
290
+ | `coverTiltDegrees` | 1.15 | 0–4 |
291
+ | `coverTranslationPixels` | 4 | 0–16 |
292
+ | `bloomTravelPixels` | 8 | 0–32 |
293
+ | `flareTravelPixels` | 18 | 0–48 |
294
+ | `specularTravelPixels` | 24 | 0–64 |
295
+ | `particleReleaseTicks` | 32 | Rounded integer 1–128; its actual duration also passes through core duration quantisation. |
296
+
297
+ Numeric values are coerced and clamped; nonfinite values keep the previous
298
+ setting. `null` coerces to zero before clamping. Motion uses bounded axes times
299
+ `masterIntensity`; reduced motion or `enabled: false` sets active motion gain to
300
+ zero. The defaults therefore produce up to 2.3 degrees cover tilt and 8 px cover
301
+ translation per axis, not the single-gain parameter magnitudes alone.
302
+
303
+ ### Methods
304
+
305
+ | Signature | Return / side effects |
306
+ | --- | --- |
307
+ | `setParameters(next?)` | Returns a shallow settings copy. Applies provided/clamped fields, writes intensity/release-duration CSS variables, reapplies the last motion input. Unspecified fields remain unchanged. |
308
+ | `setMotion({x,y}?)` | Boolean; clamps/coerces input to -1–1 and coalesces into one pending animation frame. False after teardown. |
309
+ | `setMotionTarget({x,y}?)` | Boolean; cancels pending motion frame and applies the bounded target immediately. Intended for already rate-limited orientation input. False after teardown. |
310
+ | `setCueState(detail?)` | Void; reads normalised name/type or name/type, lowercases and replaces whitespace with hyphens into root cue attributes. Empty input removes cue attributes. This is state styling, not core cue dispatch or animation reset. |
311
+ | `lifecycle(state)` | Void; applies lifecycle effects below and records the state attribute. |
312
+ | `reset()` | Void; restores default visual parameters, clears cue/particle/motion state and marks lifecycle `reset`. Does **not** reset performance profile, history or counters. |
313
+ | `snapshot()` | Returns the state shape below. |
314
+ | `teardown()` | Idempotent void disposal: stops performance monitor, particle/motion tasks, listeners and cue subscription; marks runtime stopped. Does not undo all CSS/attributes, unregister the separately registered core animation scope, or recreate a live instance. |
315
+
316
+ Lifecycle strings `seeking`, `hidden`, `pagehide`, `reduced-motion` clear cues,
317
+ particles and motion; the last three also stop performance sampling and clear
318
+ consecutive health counters. `seeked` sets a `seeked`/`state` cue state.
319
+ `visible`/`pageshow` clear motion and request a settled performance window.
320
+ Other strings merely update the recorded attribute. The method does not fake
321
+ `document.hidden` or browser media preferences.
322
+
323
+ Pointer movement is active for a fine hover-capable pointer with a cached cover
324
+ bounding rectangle. Pointer enter refreshes bounds; leave/rotation resets motion;
325
+ resize invalidates bounds. Hover/focus on controls samples the particle orbit once
326
+ on an animation frame, then pointer leave/blur can play a tick-timed release from
327
+ the sampled transform/opacity. There is no permanent particle-sampling loop.
328
+
329
+ ### Snapshot shape
330
+
331
+ ```text
332
+ {
333
+ destroyed: boolean,
334
+ settings: { all visual parameters above },
335
+ motion: {x, y, pendingFrame, commits},
336
+ particles: {controls, pendingFrames, pendingTimers, computedStyleReads, samples},
337
+ performance: {
338
+ enabled, profile, reason, monitoring, poorWindows, windows, reductions,
339
+ recoveryAttempts, healthyWindows, recoveryWaitWindows, probationFrom,
340
+ lastFps, observedCadenceFps, lastMedianFrameMilliseconds,
341
+ lastP95FrameMilliseconds
342
+ },
343
+ cue: string, cueType: string, lifecycle: string
344
+ }
345
+ ```
346
+
347
+ `profile` is `full`, `constrained` or `minimal`. Metrics/cadence/probation can be
348
+ `null` before measurement/no active probation. `recoveryAttempts` counts upward
349
+ profile changes, not only confirmed recoveries. `monitoring` indicates a scheduled
350
+ timer or frame, not a promise of a measured FPS. There is no public performance
351
+ profile setter.
352
+
353
+ ## Recoverable Clay/Stars performance tiers
354
+
355
+ The monitor starts at `full`. It waits for document load completion plus a settling
356
+ period, samples visible frame intervals, and continues checking even in `minimal`.
357
+ Reduced motion/hidden state suspends it. Large frame gaps reset the current sample
358
+ window instead of counting as proof of incapacity.
359
+
360
+ `constrained` stops the ambient cloud, atmosphere-particle and gold-sparkle loops
361
+ while retaining a static treatment. `minimal` also removes blur/screen blending
362
+ from those ambient planes. Phone response, controls and musical cue infrastructure
363
+ remain separate. These tiers are implemented only for Clay/Stars, not every EPK.
364
+
365
+ The fastest stable observed cadence can rise but cannot fall. A window is eligible
366
+ for cadence learning at least at 28 FPS with p95 interval at most 1.25 times its
367
+ median; eligible observations below 35 FPS use a normalised 30 FPS reference. Without an established
368
+ reference, the monitor uses 60 FPS. A poor window is below 75% of the reference in
369
+ `full` or 80% in lower tiers. A healthy window requires at least 90% of the reference
370
+ and p95 no more than 1.5 reference intervals. These are observed frame statistics,
371
+ not authoritative physical display-refresh detection.
372
+
373
+ By default, **three** consecutive poor windows trigger one downgrade. After a
374
+ cooldown of initially three valid windows and three consecutive healthy windows,
375
+ a lower tier trials the next higher one. Two poor windows fail a trial and revert;
376
+ three healthy windows confirm it. Failed trials double the cooldown up to 24
377
+ windows; confirmed recovery restores the cooldown to three. This supersedes the
378
+ historical fixed-45/50-FPS, two-window, downward-only description.
379
+
380
+ ### Pre-load configuration: `window.danceMovesClayPerformanceConfig`
381
+
382
+ Set before the Clay script. Settings are captured/frozen at initialisation and are
383
+ not changed by `setParameters()`.
384
+
385
+ | Key | Default | Range / meaning |
386
+ | --- | --- | --- |
387
+ | `enabled` | true | Only literal false disables monitoring. Independent of visual `settings.enabled`. |
388
+ | `windowMilliseconds` | 1600 | 500–5000 |
389
+ | `settleMilliseconds` | 5000 | 0–10000 |
390
+ | `retryMilliseconds` | 2000 | 0–10000; retry after poor measurement without tier change. |
391
+ | `recheckMilliseconds` | 10000 | 1000–60000; spaced checks, including minimal tier. |
392
+ | `minimumIntervals` | 6 | Rounded integer 4–240. |
393
+ | `maximumIntervalMilliseconds` | 250 | 100–1000; larger gaps reset a sample window. |
394
+ | `poorWindowsBeforeReduction` | 3 | Rounded integer 2–5. |
395
+ | `fullMinimumFps` | 45 | Parsed/clamped 20–60 for compatibility, but **unused by the current cadence-relative decision logic**. |
396
+ | `constrainedMinimumFps` | 50 | Likewise parsed/clamped but unused. |
397
+
398
+ These delays measure capability and must not be substituted for the musical
399
+ clock. Do not infer physical-device performance from a synthetic unit harness or
400
+ from the frequency of phone sensor events.
@@ -0,0 +1,158 @@
1
+ # Signed MP3 download API
2
+
3
+ [Documentation index](../README.md) · [WordPress/PHP API](wordpress.md) ·
4
+ [User guide](../guide.md)
5
+
6
+ Source: [plugin entrypoint](../../kieran-epk-device-orientation.php), functions
7
+ `dance_moves_epk_download_*`. Baseline: **DanceMoves 2.8.0**, commit
8
+ `4cb6a71f60459b5579be87d7e55ac8b1426c578e`. Introduced in 2.5.0.
9
+
10
+ This feature gives explicit WordPress-upload MP3 downloads an attachment response
11
+ without changing the media URLs used by audio players. It is a **front-end query
12
+ endpoint**, not a REST route, shortcode, browser download API or media proxy.
13
+
14
+ ## Authoring and automatic link transformation
15
+
16
+ In main Page content, use the original uploads URL and a `download` attribute:
17
+
18
+ ```html
19
+ <a class="epk-download"
20
+ href="/wp-content/uploads/2026/09/example-track.mp3"
21
+ download>Download MP3</a>
22
+ ```
23
+
24
+ At `the_content` priority **30**, the plugin uses `WP_HTML_Tag_Processor` to inspect
25
+ anchors. A link is eligible when it has a `download` attribute and its URL resolves
26
+ to an MP3 path under the configured uploads base URL. Eligibility is not tied to
27
+ an EPK CSS class, release title or page ID: it applies to eligible anchors in the
28
+ main content of any singular Page.
29
+
30
+ The filter returns unchanged content in admin, off singular Pages, outside the
31
+ main loop/query, or if `WP_HTML_Tag_Processor` is unavailable. It does not create
32
+ anchors or add missing `download` attributes. It rewrites `href` only, preserving
33
+ other attributes. A custom value such as `download="custom-name.mp3"` remains in
34
+ the markup but the server's filename is derived from the actual stored basename.
35
+
36
+ Audio players and `<source>` elements, ordinary media links, external upload
37
+ hosts and non-MP3 downloads are not rewritten. Direct MP3 URLs remain ordinary
38
+ media URLs. Stored post content is unchanged: this is a render-time transform.
39
+ JavaScript-inserted anchors and template links that never pass through
40
+ `the_content` need a server-generated URL from the helpers below.
41
+
42
+ ## URL and HTTP contract
43
+
44
+ The generated URL uses `home_url('/')` with two registered query variables:
45
+
46
+ | Query key / constant | Value |
47
+ | --- | --- |
48
+ | `dance_moves_download` / `DANCE_MOVES_DOWNLOAD_QUERY_VAR` | Normalized path relative to the WordPress uploads directory, for example `2026/09/example-track.mp3`. |
49
+ | `dance_moves_download_signature` / `DANCE_MOVES_DOWNLOAD_SIGNATURE_QUERY_VAR` | Hex HMAC-SHA-256 over that normalized relative path using `wp_salt('auth')`. |
50
+
51
+ Generate the URL with WordPress rather than constructing a signature in browser
52
+ code. The URL is not a `wp-json` path; preserve subdirectory/site URL settings.
53
+ The handler runs at `template_redirect` priority **0**, independently of which
54
+ Page originally rendered the link.
55
+
56
+ ### Successful GET and HEAD
57
+
58
+ A valid request returns status **200** with these headers:
59
+
60
+ | Header | Value / rule |
61
+ | --- | --- |
62
+ | `Content-Description` | `File Transfer` |
63
+ | `Content-Type` | `audio/mpeg` |
64
+ | `Content-Disposition` | `attachment`; ASCII-compatible sanitized basename in `filename`, plus URL-encoded original basename in `filename*=UTF-8''...`. |
65
+ | `Content-Length` | Size of the local file in bytes. |
66
+ | `Cache-Control` | `private, no-store, max-age=0` |
67
+ | `X-Content-Type-Options` | `nosniff` |
68
+ | `X-Robots-Tag` | `noindex, nofollow` |
69
+
70
+ The handler clears active output buffers before sending a successful response.
71
+ GET streams the complete local file using `readfile()` and exits. HEAD sends the
72
+ same headers without the file body and exits. It does not implement Range/206,
73
+ conditional GET/304, ETag, remote fetching or an asynchronous job. Use the original
74
+ media URL in players that need normal seeking/range delivery, not this endpoint.
75
+
76
+ ### Failure handling
77
+
78
+ | Condition | Behaviour |
79
+ | --- | --- |
80
+ | No download path, or path normalization returns empty | Handler returns without serving a file; ordinary WordPress routing continues. This branch is **not** a guaranteed 404 response. |
81
+ | Valid-shaped path but missing/incorrect signature | Status 404, WordPress no-cache headers, exit without a JSON response. |
82
+ | Unresolvable upload base/file, path outside the real upload base, unreadable/non-file or non-MP3 target | Same 404 path. |
83
+ | Other method after path/signature/file validation | Status 405, `Allow: GET, HEAD`, no-cache headers, exit. |
84
+
85
+ These are not `WP_Error` REST responses. The server checks real paths to stop a
86
+ symlink from resolving outside the uploads directory. Extension checking does
87
+ not decode or validate the audio format itself. Delivery still depends on a
88
+ readable local file; remote-only/offloaded media with no local copy cannot be
89
+ served by this implementation.
90
+
91
+ ## PHP functions
92
+
93
+ All functions require a loaded WordPress environment. These eight functions are
94
+ part of the complete PHP inventory in [wordpress.md](wordpress.md).
95
+
96
+ | Signature | Return | Exact role |
97
+ | --- | --- | --- |
98
+ | `dance_moves_epk_download_normalize_relative_path($relative_path)` | string | Nonstring becomes `''`. Replaces literal backslashes with forward slashes, trims, then URL-decodes. Rejects empty/absolute paths, colons, control characters, non-`.mp3` suffixes and empty, `.` or `..` segments. Returns joined normalized segments. Case of the extension is not significant. |
99
+ | `dance_moves_epk_download_relative_path_from_url($url)` | string | Nonstring/blank or unusable upload-base data becomes `''`. HTML-decodes the URL, parses URL/base, requires paths, checks any supplied host/scheme/port against the uploads base, URL-decodes paths, requires the base-path prefix plus `/`, then passes the relative suffix through normalization. |
100
+ | `dance_moves_epk_download_signature($relative_path)` | string | Hex `hash_hmac('sha256', $relative_path, wp_salt('auth'))`. Does **not** normalize or validate its argument; use the URL helper for callers' input. |
101
+ | `dance_moves_epk_download_url($relative_path)` | string | Normalizes input, returns `''` if rejected, otherwise adds path/signature query args to `home_url('/')`. Does not verify file existence/readability at URL-generation time. |
102
+ | `dance_moves_epk_download_query_vars($query_vars)` | array | Adds both query-variable names and returns unique indexed values. Registered on `query_vars`. |
103
+ | `dance_moves_epk_download_filter_content($content)` | string | Eligible-anchor rewrite described above. Registered on `the_content` at 30. Returns original string if no change. |
104
+ | `dance_moves_epk_download_not_found()` | no return; exits | Sends 404 and no-cache headers, then terminates. Internal response helper, not a template renderer. |
105
+ | `dance_moves_epk_download_serve()` | void or exits | Reads registered query vars, verifies signature with `hash_equals`, checks real local path/method, emits headers and optionally body, then exits for a handled request. |
106
+
107
+ URL comparison is against the **configured upload base**, which may differ from
108
+ the site's display hostname. The helper checks supplied URL components, so a
109
+ root-relative upload URL can qualify. A different CDN scheme, hostname, port or
110
+ path can prevent rewriting. Query strings/fragments are not preserved in the
111
+ new download URL. Path decoding occurs in both the URL-to-path helper and the
112
+ normalizer; pass original URLs rather than repeatedly encoding/decoding them.
113
+
114
+ A template that does not use the content filter can generate an escaped link:
115
+
116
+ ```php
117
+ <?php
118
+ // Execute within a WordPress template, with an actual uploads-relative path.
119
+ if (function_exists('dance_moves_epk_download_url')) {
120
+ $download = dance_moves_epk_download_url('2026/09/example-track.mp3');
121
+ if ($download !== '') {
122
+ printf('<a href="%s" download>Download MP3</a>', esc_url($download));
123
+ }
124
+ }
125
+ ```
126
+
127
+ For an attachment, resolve its URL then convert it to an uploads-relative path
128
+ with `dance_moves_epk_download_relative_path_from_url()`. The feature has no
129
+ special attachment-ID download helper. Never emit `wp_salt('auth')` to clients or
130
+ reuse the independent motion-capture token to sign downloads.
131
+
132
+ ## Security and operational boundaries
133
+
134
+ A signature proves that the server generated a URL for this path. It is **not** a
135
+ per-user access check: there is no login, capability, per-link expiry, attachment
136
+ privacy check, nonce or rate limit in this endpoint. Anyone holding a valid URL
137
+ can reuse/share it while the file and signing salt remain valid. The original
138
+ media URL is not made private. Use a separate reviewed access-control design for
139
+ restricted media; do not treat this as one.
140
+
141
+ Changing the signing salt invalidates earlier URLs. A long-lived HTML cache can
142
+ therefore serve stale links after a salt change. That is an operational detail,
143
+ not a reason to expose salts or weaken signature validation. Successful signed
144
+ responses themselves request no-store caching.
145
+
146
+ For troubleshooting, distinguish failure to **rewrite** (attribute, content
147
+ filter, tag processor or URL-base mismatch) from failure to **serve** (signature,
148
+ local realpath/readability or method). Confirm that the player still uses the
149
+ original media URL and that a real download returns the attachment header.
150
+
151
+ ## Tests
152
+
153
+ [epk-download-contract.test.cjs](../../tests/epk-download-contract.test.cjs) checks
154
+ source contracts. [validate-epk-download-paths.php](../../tests/validate-epk-download-paths.php)
155
+ exercises normalization, upload URL restrictions and stable URL generation in a
156
+ stubbed WordPress environment. They do not replace a staging HTTP test of GET,
157
+ HEAD, 404/405, filenames, cache behaviour, main-loop link rewriting and player
158
+ seekability with real WordPress, server headers and local uploads.