use-scroll-animate 5.5.0 → 5.7.0

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.
@@ -1115,6 +1115,714 @@ const GENERATIVE_FX = [
1115
1115
  }),
1116
1116
  ];
1117
1117
 
1118
+ let actx = null;
1119
+ let current = null;
1120
+ const mediaSources = new WeakMap();
1121
+ /** The running analyser, if `enableAudio()` was called. */
1122
+ const getAudio = () => current;
1123
+ function audioContext() {
1124
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
1125
+ if (!AC)
1126
+ throw new Error('[use-scroll-animate] Web Audio is not available');
1127
+ if (!actx || actx.state === 'closed')
1128
+ actx = new AC();
1129
+ return actx;
1130
+ }
1131
+ /**
1132
+ * Start analysing `input` and make it the source of every sound-reactive
1133
+ * effect. Call from a user gesture. Replaces a previous source.
1134
+ */
1135
+ async function enableAudio(input = 'mic', opts = {}) {
1136
+ current?.stop();
1137
+ const ac = audioContext();
1138
+ if (ac.state === 'suspended')
1139
+ await ac.resume().catch(() => undefined);
1140
+ const analyser = ac.createAnalyser();
1141
+ analyser.fftSize = opts.fftSize || 512;
1142
+ analyser.smoothingTimeConstant = opts.smoothing ?? 0.8;
1143
+ let src;
1144
+ let release = () => { };
1145
+ const media = typeof input === 'string' && input !== 'mic' ? document.querySelector(input) : input;
1146
+ if (media === 'mic') {
1147
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1148
+ src = ac.createMediaStreamSource(stream);
1149
+ src.connect(analyser); // never to the speakers: no feedback
1150
+ release = () => stream.getTracks().forEach((t) => t.stop());
1151
+ }
1152
+ else if (media && typeof media.play === 'function') {
1153
+ const m = media;
1154
+ let node = mediaSources.get(m);
1155
+ if (!node)
1156
+ mediaSources.set(m, (node = ac.createMediaElementSource(m)));
1157
+ try {
1158
+ node.disconnect();
1159
+ }
1160
+ catch {
1161
+ /* not connected */
1162
+ }
1163
+ src = node;
1164
+ src.connect(analyser);
1165
+ analyser.connect(ac.destination);
1166
+ release = () => {
1167
+ try {
1168
+ node.disconnect();
1169
+ }
1170
+ catch {
1171
+ /* already */
1172
+ }
1173
+ node.connect(ac.destination); // keep the media audible
1174
+ };
1175
+ if (m.paused)
1176
+ await m.play()?.catch?.(() => undefined);
1177
+ }
1178
+ else if (media && typeof media.getTracks === 'function') {
1179
+ src = ac.createMediaStreamSource(media);
1180
+ src.connect(analyser);
1181
+ }
1182
+ else
1183
+ throw new Error(`[use-scroll-animate] enableAudio: no audio source for ${String(input)}`);
1184
+ const freq = new Uint8Array(analyser.frequencyBinCount);
1185
+ const wave = new Uint8Array(analyser.fftSize);
1186
+ const self = {
1187
+ context: ac,
1188
+ analyser,
1189
+ sample: () => {
1190
+ analyser.getByteFrequencyData(freq);
1191
+ analyser.getByteTimeDomainData(wave);
1192
+ let sq = 0;
1193
+ for (let i = 0; i < wave.length; i++)
1194
+ sq += ((wave[i] - 128) / 128) ** 2;
1195
+ const n = Math.max(1, Math.round(freq.length * 0.08));
1196
+ let b = 0;
1197
+ for (let i = 0; i < n; i++)
1198
+ b += freq[i];
1199
+ return { level: Math.min(1, Math.sqrt(sq / wave.length) * 2), bass: b / n / 255, freq, wave };
1200
+ },
1201
+ stop: () => {
1202
+ if (current === self) {
1203
+ current = null;
1204
+ if (raf && typeof cancelAnimationFrame === 'function')
1205
+ cancelAnimationFrame(raf);
1206
+ raf = 0;
1207
+ }
1208
+ try {
1209
+ src.disconnect();
1210
+ analyser.disconnect();
1211
+ }
1212
+ catch {
1213
+ /* already */
1214
+ }
1215
+ release();
1216
+ setVars(0, 0);
1217
+ },
1218
+ };
1219
+ current = self;
1220
+ kick();
1221
+ return self;
1222
+ }
1223
+ /** Stop the current audio source (if any). */
1224
+ function disableAudio() {
1225
+ current?.stop();
1226
+ }
1227
+ /** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
1228
+ function createBeatDetector(o = {}) {
1229
+ const { threshold = 1.35, cooldown = 250, history = 43, floor = 0.08 } = o;
1230
+ const hist = [];
1231
+ let last = -Infinity;
1232
+ return (energy, now) => {
1233
+ const avg = hist.length ? hist.reduce((a, b) => a + b, 0) / hist.length : energy;
1234
+ const ready = hist.length >= 8;
1235
+ hist.push(energy);
1236
+ if (hist.length > history)
1237
+ hist.shift();
1238
+ if (ready && energy > floor && energy > avg * threshold && now - last >= cooldown) {
1239
+ last = now;
1240
+ return true;
1241
+ }
1242
+ return false;
1243
+ };
1244
+ }
1245
+ const listeners = new Set();
1246
+ let raf = 0;
1247
+ function setVars(level, bass) {
1248
+ if (typeof document === 'undefined')
1249
+ return;
1250
+ const s = document.documentElement.style;
1251
+ s.setProperty('--usa-audio-level', level.toFixed(3));
1252
+ s.setProperty('--usa-audio-bass', bass.toFixed(3));
1253
+ }
1254
+ function loop(now) {
1255
+ raf = 0;
1256
+ if (!current)
1257
+ return;
1258
+ const { level, bass } = current.sample();
1259
+ const reduced = base.prefersReducedMotion();
1260
+ setVars(reduced ? 0 : level, reduced ? 0 : bass);
1261
+ for (const l of Array.from(listeners))
1262
+ if (l.detect(bass, now))
1263
+ l.cb({ energy: bass, time: now });
1264
+ kick();
1265
+ }
1266
+ function kick() {
1267
+ if (!raf && current && typeof requestAnimationFrame === 'function')
1268
+ raf = requestAnimationFrame(loop);
1269
+ }
1270
+ /** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
1271
+ function onBeat(cb, o = {}) {
1272
+ const l = { detect: createBeatDetector(o), cb };
1273
+ listeners.add(l);
1274
+ kick();
1275
+ return () => void listeners.delete(l);
1276
+ }
1277
+ /** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
1278
+ function bindBeat(el, name, options = {}) {
1279
+ const { threshold, cooldown, history, floor, ...fx } = options;
1280
+ return onBeat(() => {
1281
+ if (!base.prefersReducedMotion())
1282
+ components_fx.playEffect(el, name, fx).catch(() => undefined);
1283
+ }, { threshold, cooldown, history, floor });
1284
+ }
1285
+ // --- visual effects ----------------------------------------------------------
1286
+ /** The current sample, or a gentle synthetic one while no audio is enabled. */
1287
+ function read(t, bins) {
1288
+ if (current)
1289
+ return { ...current.sample(), idle: false };
1290
+ const freq = new Uint8Array(bins);
1291
+ const wave = new Uint8Array(bins * 2);
1292
+ for (let i = 0; i < bins; i++)
1293
+ freq[i] = 70 + 50 * Math.sin(t * 2 + i * 0.35) * Math.cos(t * 0.7 + i * 0.11) * (1 - i / bins);
1294
+ for (let i = 0; i < wave.length; i++)
1295
+ wave[i] = 128 + 22 * Math.sin(t * 3 + i * 0.12);
1296
+ return { level: 0.25 + 0.1 * Math.sin(t * 2), bass: 0.35 + 0.2 * Math.sin(t * 2.4), freq, wave, idle: true };
1297
+ }
1298
+ const audioFx = (name, description, defaults, spec) => ({
1299
+ name,
1300
+ kind: 'background',
1301
+ description,
1302
+ reduced: 'skip',
1303
+ defaults: { colors: PALETTE, background: '#0b0d12', speed: 1, quality: 1, ...defaults },
1304
+ run: (el, o, ctx) => canvasBackground(el, ctx, spec, o),
1305
+ });
1306
+ const AUDIO_FX = [
1307
+ audioFx('spectrum-bars', 'Frequency bars dance to the audio (mirror them with `mirror: true`).', { bars: 48, gap: 2, mirror: false }, {
1308
+ draw: ({ ctx, w, h, t, o }) => {
1309
+ ctx.fillStyle = o.background;
1310
+ ctx.fillRect(0, 0, w, h);
1311
+ const s = read(t, o.bars);
1312
+ const per = Math.max(1, Math.floor((s.freq.length * 0.7) / o.bars));
1313
+ const bw = w / o.bars;
1314
+ for (let i = 0; i < o.bars; i++) {
1315
+ let v = 0;
1316
+ for (let k = 0; k < per; k++)
1317
+ v += s.freq[Math.min(s.freq.length - 1, i * per + k)];
1318
+ const bh = (v / per / 255) * h * (o.mirror ? 0.5 : 0.9);
1319
+ ctx.fillStyle = o.colors[i % o.colors.length];
1320
+ ctx.fillRect(i * bw + o.gap / 2, o.mirror ? h / 2 - bh : h - bh, Math.max(1, bw - o.gap), o.mirror ? bh * 2 : bh);
1321
+ }
1322
+ },
1323
+ }),
1324
+ audioFx('pulse-ring', 'Glowing rings pulse with the bass.', { rings: 3, color: '#7c5cff' }, {
1325
+ draw: ({ ctx, w, h, t, o }) => {
1326
+ ctx.fillStyle = o.background;
1327
+ ctx.fillRect(0, 0, w, h);
1328
+ const s = read(t, 32);
1329
+ const [r, g, b] = hexRgb(o.color);
1330
+ const base = Math.min(w, h) * 0.18;
1331
+ for (let i = 0; i < o.rings; i++) {
1332
+ const rad = base * (1 + i * 0.55) * (1 + s.bass * 0.6);
1333
+ ctx.strokeStyle = `rgba(${r},${g},${b},${(0.9 - i * 0.25) * (0.4 + s.level)})`;
1334
+ ctx.lineWidth = 3 + s.bass * 10 - i;
1335
+ ctx.beginPath();
1336
+ ctx.arc(w / 2, h / 2, rad, 0, Math.PI * 2);
1337
+ ctx.stroke();
1338
+ }
1339
+ },
1340
+ }),
1341
+ audioFx('wave-ring', 'The live waveform wrapped into a circle.', { color: '#22d3ee', amplitude: 0.35 }, {
1342
+ draw: ({ ctx, w, h, t, o }) => {
1343
+ ctx.fillStyle = o.background;
1344
+ ctx.fillRect(0, 0, w, h);
1345
+ const s = read(t, 64);
1346
+ const n = s.wave.length;
1347
+ const base = Math.min(w, h) * 0.28;
1348
+ ctx.strokeStyle = o.color;
1349
+ ctx.lineWidth = 2;
1350
+ ctx.beginPath();
1351
+ for (let i = 0; i <= n; i++) {
1352
+ const a = (i / n) * Math.PI * 2;
1353
+ const rad = base * (1 + ((s.wave[i % n] - 128) / 128) * o.amplitude * 2);
1354
+ const x = w / 2 + Math.cos(a) * rad;
1355
+ const y = h / 2 + Math.sin(a) * rad;
1356
+ if (i)
1357
+ ctx.lineTo(x, y);
1358
+ else
1359
+ ctx.moveTo(x, y);
1360
+ }
1361
+ ctx.closePath();
1362
+ ctx.stroke();
1363
+ },
1364
+ }),
1365
+ ];
1366
+ /**
1367
+ * `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
1368
+ * `[data-audio-toggle]`, or one it renders) that enables the audio source on
1369
+ * click; children with `data-usa-beat="effect"` play that effect on every
1370
+ * beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
1371
+ * Emits `usa-beat` and `usa-audio-error`.
1372
+ */
1373
+ function defineAudio(tag = 'usa-audio') {
1374
+ return base.defineElement(tag, (Base) => class UsaAudio extends Base {
1375
+ constructor() {
1376
+ super(...arguments);
1377
+ this.active = false;
1378
+ this.audio = null;
1379
+ this.off = null;
1380
+ }
1381
+ static get observedAttributes() {
1382
+ return ['source'];
1383
+ }
1384
+ button() {
1385
+ let b = this.querySelector('[data-audio-toggle]');
1386
+ if (!b) {
1387
+ b = document.createElement('button');
1388
+ b.type = 'button';
1389
+ b.setAttribute('data-audio-toggle', '');
1390
+ b.textContent = this.str('label', '🔊 Enable sound-reactive effects');
1391
+ this.prepend(b);
1392
+ this.onCleanup(() => b.remove());
1393
+ }
1394
+ return b;
1395
+ }
1396
+ async toggle() {
1397
+ const b = this.button();
1398
+ if (this.active)
1399
+ return this.stop();
1400
+ try {
1401
+ this.audio = await enableAudio(this.str('source', 'mic'));
1402
+ this.active = true;
1403
+ this.removeAttribute('data-audio-error');
1404
+ b.setAttribute('aria-pressed', 'true');
1405
+ this.off = onBeat((d) => {
1406
+ this.dispatchEvent(new CustomEvent('usa-beat', { detail: d, bubbles: true }));
1407
+ if (base.prefersReducedMotion())
1408
+ return;
1409
+ this.querySelectorAll('[data-usa-beat]').forEach((el) => {
1410
+ let opts = {};
1411
+ try {
1412
+ opts = JSON.parse(el.dataset.usaBeatOptions || '{}') || {};
1413
+ }
1414
+ catch {
1415
+ /* ignore bad JSON */
1416
+ }
1417
+ components_fx.playEffect(el, el.dataset.usaBeat || 'pulse', opts).catch(() => undefined);
1418
+ });
1419
+ }, { threshold: this.num('threshold', 1.35), cooldown: this.num('cooldown', 250) });
1420
+ }
1421
+ catch (err) {
1422
+ this.setAttribute('data-audio-error', '');
1423
+ this.dispatchEvent(new CustomEvent('usa-audio-error', { detail: { error: err }, bubbles: true }));
1424
+ }
1425
+ }
1426
+ stop() {
1427
+ this.off?.();
1428
+ this.off = null;
1429
+ if (this.audio && current === this.audio)
1430
+ this.audio.stop();
1431
+ this.audio = null;
1432
+ this.active = false;
1433
+ this.querySelector('[data-audio-toggle]')?.setAttribute('aria-pressed', 'false');
1434
+ }
1435
+ mount() {
1436
+ const b = this.button();
1437
+ b.setAttribute('aria-pressed', 'false');
1438
+ this.listen(b, 'click', () => void this.toggle());
1439
+ this.onCleanup(() => this.stop());
1440
+ }
1441
+ }, { id: 'usa-audio', text: 'usa-audio{display:block}usa-audio[data-audio-error] [data-audio-toggle]{outline:2px solid #ff5c8a}' });
1442
+ }
1443
+
1444
+ const touchOk = (e, o) => o.touch || e.pointerType !== 'touch';
1445
+ /** A fixed, viewport-sized canvas in the fx layer, redrawn while `draw` returns true. */
1446
+ function overlayCanvas(draw) {
1447
+ const c = document.createElement('canvas');
1448
+ c.style.cssText = 'position:absolute;inset:0;width:100%;height:100%';
1449
+ fxLayer().appendChild(c);
1450
+ const g = c.getContext('2d');
1451
+ let raf = 0;
1452
+ const frame = (now) => {
1453
+ raf = 0;
1454
+ if (!g)
1455
+ return;
1456
+ const w = innerWidth;
1457
+ const h = innerHeight;
1458
+ const dpr = Math.min(2, devicePixelRatio || 1);
1459
+ if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
1460
+ c.width = Math.round(w * dpr);
1461
+ c.height = Math.round(h * dpr);
1462
+ }
1463
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
1464
+ g.clearRect(0, 0, w, h);
1465
+ if (draw(g, w, h, now))
1466
+ raf = requestAnimationFrame(frame);
1467
+ };
1468
+ return {
1469
+ kick: () => {
1470
+ if (!raf && g)
1471
+ raf = requestAnimationFrame(frame);
1472
+ },
1473
+ stop: () => {
1474
+ cancelAnimationFrame(raf);
1475
+ raf = 0;
1476
+ c.remove();
1477
+ },
1478
+ };
1479
+ }
1480
+ /** Pointer trail on `el`: keeps the last `life` ms of points and redraws them. */
1481
+ function trail(el, o, paint) {
1482
+ const pts = [];
1483
+ const oc = overlayCanvas((g, _w, _h, now) => {
1484
+ while (pts.length && now - pts[0].t > o.life)
1485
+ pts.shift();
1486
+ if (pts.length > 1)
1487
+ paint(g, pts, now);
1488
+ return pts.length > 0;
1489
+ });
1490
+ const move = (e) => {
1491
+ if (!touchOk(e, o))
1492
+ return;
1493
+ pts.push({ x: e.clientX, y: e.clientY, t: performance.now() });
1494
+ if (pts.length > 64)
1495
+ pts.shift();
1496
+ oc.kick();
1497
+ };
1498
+ el.addEventListener('pointermove', move, { passive: true });
1499
+ return () => {
1500
+ el.removeEventListener('pointermove', move);
1501
+ oc.stop();
1502
+ };
1503
+ }
1504
+ const CURSOR_FX = [
1505
+ {
1506
+ name: 'comet-trail',
1507
+ kind: 'cursor',
1508
+ description: 'A glowing comet tail follows the pointer over the element (persistent; `color`, `width`, `life`).',
1509
+ defaults: { color: '#7c5cff', width: 10, life: 320, touch: false },
1510
+ run: (el, o) => trail(el, o, (g, pts, now) => {
1511
+ const [r, gg, b] = hexRgb(o.color);
1512
+ g.lineCap = 'round';
1513
+ for (let i = 1; i < pts.length; i++) {
1514
+ const k = 1 - (now - pts[i].t) / o.life;
1515
+ g.strokeStyle = `rgba(${r},${gg},${b},${(k * 0.9).toFixed(3)})`;
1516
+ g.lineWidth = Math.max(0.5, o.width * k);
1517
+ g.beginPath();
1518
+ g.moveTo(pts[i - 1].x, pts[i - 1].y);
1519
+ g.lineTo(pts[i].x, pts[i].y);
1520
+ g.stroke();
1521
+ }
1522
+ }),
1523
+ },
1524
+ {
1525
+ name: 'ribbon-trail',
1526
+ kind: 'cursor',
1527
+ description: 'A smooth rainbow ribbon flows behind the pointer (persistent; `width`, `life`).',
1528
+ defaults: { width: 18, life: 600, touch: false },
1529
+ run: (el, o) => trail(el, o, (g, pts, now) => {
1530
+ for (let i = 2; i < pts.length; i++) {
1531
+ const k = 1 - (now - pts[i].t) / o.life;
1532
+ const a = pts[i - 2];
1533
+ const m = pts[i - 1];
1534
+ const b = pts[i];
1535
+ g.strokeStyle = `hsla(${(pts[i].t / 6) % 360},90%,62%,${(k * 0.85).toFixed(3)})`;
1536
+ g.lineWidth = Math.max(0.5, o.width * k * Math.sin(Math.PI * Math.min(1, (i / pts.length) * 1.1)));
1537
+ g.lineCap = 'round';
1538
+ g.beginPath();
1539
+ g.moveTo((a.x + m.x) / 2, (a.y + m.y) / 2);
1540
+ g.quadraticCurveTo(m.x, m.y, (m.x + b.x) / 2, (m.y + b.y) / 2);
1541
+ g.stroke();
1542
+ }
1543
+ }),
1544
+ },
1545
+ {
1546
+ name: 'sparkle-trail',
1547
+ kind: 'cursor',
1548
+ description: 'Little stars twinkle off the pointer as it moves (persistent; `colors`, `spacing` px between stars).',
1549
+ defaults: { colors: PALETTE, spacing: 14, size: 14, touch: false },
1550
+ run: (el, o, ctx) => {
1551
+ let lx = -1e4;
1552
+ let ly = -1e4;
1553
+ const move = (e) => {
1554
+ if (!touchOk(e, o) || Math.hypot(e.clientX - lx, e.clientY - ly) < o.spacing)
1555
+ return;
1556
+ lx = e.clientX;
1557
+ ly = e.clientY;
1558
+ const s = o.size * rand(0.6, 1.2);
1559
+ spawn(e.clientX - s / 2, e.clientY - s / 2, `font-size:${s}px;line-height:1;color:${o.colors[Math.floor(rand(0, o.colors.length))]}`, ctx, [{ transform: 'translate(0,0) scale(0) rotate(0deg)', opacity: 1 }, { transform: `translate(${rand(-14, 14)}px,${rand(4, 26)}px) scale(1) rotate(${rand(-90, 90)}deg)`, opacity: 0 }], { duration: rand(500, 800), easing: 'ease-out' }, '✦');
1560
+ };
1561
+ el.addEventListener('pointermove', move, { passive: true });
1562
+ return () => el.removeEventListener('pointermove', move);
1563
+ },
1564
+ },
1565
+ {
1566
+ name: 'magnetic-dots',
1567
+ kind: 'cursor',
1568
+ description: 'A grid of dots behind the content leans toward the pointer like iron filings to a magnet (persistent; `gap`, `radius`, `color`).',
1569
+ defaults: { gap: 22, radius: 120, color: '#7c5cff', background: 'transparent', touch: false },
1570
+ reduced: 'skip',
1571
+ run: (el, o, ctx) => {
1572
+ const p = { x: -1e4, y: -1e4 };
1573
+ const move = (e) => {
1574
+ if (!touchOk(e, o))
1575
+ return;
1576
+ const r = el.getBoundingClientRect();
1577
+ p.x = e.clientX - r.left;
1578
+ p.y = e.clientY - r.top;
1579
+ };
1580
+ const leave = () => ((p.x = -1e4), (p.y = -1e4));
1581
+ el.addEventListener('pointermove', move, { passive: true });
1582
+ el.addEventListener('pointerleave', leave);
1583
+ const off = canvasBackground(el, ctx, {
1584
+ draw: ({ ctx: g, w, h }) => {
1585
+ g.clearRect(0, 0, w, h);
1586
+ if (o.background !== 'transparent') {
1587
+ g.fillStyle = o.background;
1588
+ g.fillRect(0, 0, w, h);
1589
+ }
1590
+ g.fillStyle = o.color;
1591
+ for (let y = o.gap / 2; y < h; y += o.gap)
1592
+ for (let x = o.gap / 2; x < w; x += o.gap) {
1593
+ const dx = p.x - x;
1594
+ const dy = p.y - y;
1595
+ const d = Math.hypot(dx, dy);
1596
+ const k = d < o.radius ? (1 - d / o.radius) ** 2 : 0;
1597
+ const s = 1.5 + k * 3;
1598
+ g.globalAlpha = 0.35 + k * 0.65;
1599
+ g.fillRect(x + dx * k * 0.35 - s / 2, y + dy * k * 0.35 - s / 2, s, s);
1600
+ }
1601
+ g.globalAlpha = 1;
1602
+ },
1603
+ }, o);
1604
+ return () => {
1605
+ el.removeEventListener('pointermove', move);
1606
+ el.removeEventListener('pointerleave', leave);
1607
+ off();
1608
+ };
1609
+ },
1610
+ },
1611
+ {
1612
+ name: 'spotlight-cursor',
1613
+ kind: 'cursor',
1614
+ description: 'A soft light follows the pointer across the element, easing behind it (persistent; `color`, `size`).',
1615
+ defaults: { color: '#ffffff', size: 260, ease: 0.18, touch: false },
1616
+ run: (el, o) => {
1617
+ const [r, g, b] = hexRgb(o.color);
1618
+ const s = document.createElement('span');
1619
+ s.setAttribute('aria-hidden', 'true');
1620
+ s.style.cssText = `position:absolute;inset:0;pointer-events:none;border-radius:inherit;opacity:0;transition:opacity .25s;mix-blend-mode:soft-light;background:radial-gradient(circle ${o.size / 2}px at var(--sx,50%) var(--sy,50%),rgba(${r},${g},${b},.75),transparent)`;
1621
+ const restore = el.style.position;
1622
+ if (getComputedStyle(el).position === 'static')
1623
+ el.style.position = 'relative';
1624
+ el.appendChild(s);
1625
+ let tx = 0;
1626
+ let ty = 0;
1627
+ let x = 0;
1628
+ let y = 0;
1629
+ let raf = 0;
1630
+ const tick = () => {
1631
+ x += (tx - x) * o.ease;
1632
+ y += (ty - y) * o.ease;
1633
+ s.style.setProperty('--sx', `${x.toFixed(1)}px`);
1634
+ s.style.setProperty('--sy', `${y.toFixed(1)}px`);
1635
+ raf = Math.abs(tx - x) + Math.abs(ty - y) > 0.3 ? requestAnimationFrame(tick) : 0;
1636
+ };
1637
+ const move = (e) => {
1638
+ if (!touchOk(e, o))
1639
+ return;
1640
+ const rc = el.getBoundingClientRect();
1641
+ tx = e.clientX - rc.left;
1642
+ ty = e.clientY - rc.top;
1643
+ if (s.style.opacity !== '1')
1644
+ ((x = tx), (y = ty), (s.style.opacity = '1'));
1645
+ if (!raf)
1646
+ raf = requestAnimationFrame(tick);
1647
+ };
1648
+ const leave = () => (s.style.opacity = '0');
1649
+ el.addEventListener('pointermove', move, { passive: true });
1650
+ el.addEventListener('pointerleave', leave);
1651
+ return () => {
1652
+ cancelAnimationFrame(raf);
1653
+ el.removeEventListener('pointermove', move);
1654
+ el.removeEventListener('pointerleave', leave);
1655
+ s.remove();
1656
+ el.style.position = restore;
1657
+ };
1658
+ },
1659
+ },
1660
+ ];
1661
+ // --- gestures → effects ------------------------------------------------------
1662
+ const GESTURES = ['fling', 'twist', 'long-press'];
1663
+ /** Release velocity (px/ms) from recent pointer samples: uses the last `window` ms (pure). */
1664
+ function flingVelocity(pts, window = 90) {
1665
+ if (pts.length < 2)
1666
+ return { vx: 0, vy: 0, speed: 0 };
1667
+ const last = pts[pts.length - 1];
1668
+ let first = pts[0];
1669
+ for (let i = pts.length - 2; i >= 0; i--) {
1670
+ first = pts[i];
1671
+ if (last.t - pts[i].t >= window)
1672
+ break;
1673
+ }
1674
+ const dt = Math.max(1, last.t - first.t);
1675
+ const vx = (last.x - first.x) / dt;
1676
+ const vy = (last.y - first.y) / dt;
1677
+ return { vx, vy, speed: Math.hypot(vx, vy) };
1678
+ }
1679
+ /** Signed smallest difference between two angles in degrees, in (-180, 180] (pure). */
1680
+ function angleDelta(a, b) {
1681
+ let d = (b - a) % 360;
1682
+ if (d > 180)
1683
+ d -= 360;
1684
+ if (d <= -180)
1685
+ d += 360;
1686
+ return d;
1687
+ }
1688
+ /**
1689
+ * Fire `effect` (a registered effect name, or a callback) when `gesture`
1690
+ * happens on `el`. Returns an unbind.
1691
+ */
1692
+ function bindGesture(el, gesture, effect, o = {}) {
1693
+ const { velocity = 0.8, angle = 30, duration = 650, tolerance = 10, effectOptions = {} } = o;
1694
+ const pts = new Map();
1695
+ let path = [];
1696
+ let base = null;
1697
+ let press = null;
1698
+ const fire = (d, e) => {
1699
+ el.dispatchEvent(new CustomEvent('usa-gesture', { detail: d, bubbles: true }));
1700
+ if (typeof effect === 'function')
1701
+ effect(d, e);
1702
+ else
1703
+ components_fx.playEffect(el, effect, effectOptions, e).catch(() => undefined);
1704
+ };
1705
+ const twistAngle = () => {
1706
+ const [a, b] = [...pts.values()];
1707
+ return (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
1708
+ };
1709
+ const charge = (k) => {
1710
+ el.style.setProperty('--usa-charge', k.toFixed(3));
1711
+ el.toggleAttribute('data-charging', k > 0 && k < 1);
1712
+ };
1713
+ const endPress = () => {
1714
+ if (!press)
1715
+ return;
1716
+ cancelAnimationFrame(press.raf);
1717
+ press = null;
1718
+ charge(0);
1719
+ };
1720
+ const down = (e) => {
1721
+ pts.set(e.pointerId ?? 1, { x: e.clientX, y: e.clientY });
1722
+ if (gesture === 'fling')
1723
+ path = [{ x: e.clientX, y: e.clientY, t: e.timeStamp || performance.now() }];
1724
+ if (gesture === 'twist' && pts.size === 2)
1725
+ base = twistAngle();
1726
+ if (gesture === 'long-press' && pts.size === 1) {
1727
+ press = { x: e.clientX, y: e.clientY, t0: performance.now(), raf: 0 };
1728
+ const step = (now) => {
1729
+ if (!press)
1730
+ return;
1731
+ const k = Math.min(1, (now - press.t0) / duration);
1732
+ charge(k);
1733
+ if (k >= 1) {
1734
+ endPress();
1735
+ el.style.setProperty('--usa-charge', '1');
1736
+ fire({ gesture, charge: 1 }, e);
1737
+ }
1738
+ else
1739
+ press.raf = requestAnimationFrame(step);
1740
+ };
1741
+ press.raf = requestAnimationFrame(step);
1742
+ }
1743
+ };
1744
+ const move = (e) => {
1745
+ if (!pts.has(e.pointerId ?? 1))
1746
+ return;
1747
+ pts.set(e.pointerId ?? 1, { x: e.clientX, y: e.clientY });
1748
+ if (gesture === 'fling') {
1749
+ path.push({ x: e.clientX, y: e.clientY, t: e.timeStamp || performance.now() });
1750
+ if (path.length > 20)
1751
+ path.shift();
1752
+ }
1753
+ else if (gesture === 'twist' && pts.size >= 2 && base !== null) {
1754
+ const now = twistAngle();
1755
+ const d = angleDelta(base, now);
1756
+ if (Math.abs(d) >= angle) {
1757
+ base = now;
1758
+ fire({ gesture, angle: d, direction: d > 0 ? 'cw' : 'ccw' }, e);
1759
+ }
1760
+ }
1761
+ else if (gesture === 'long-press' && press && Math.hypot(e.clientX - press.x, e.clientY - press.y) > tolerance)
1762
+ endPress();
1763
+ };
1764
+ const up = (e) => {
1765
+ pts.delete(e.pointerId ?? 1);
1766
+ if (gesture === 'fling' && path.length) {
1767
+ path.push({ x: e.clientX, y: e.clientY, t: e.timeStamp || performance.now() });
1768
+ const v = flingVelocity(path);
1769
+ path = [];
1770
+ if (v.speed >= velocity) {
1771
+ const direction = Math.abs(v.vx) > Math.abs(v.vy) ? (v.vx > 0 ? 'right' : 'left') : v.vy > 0 ? 'down' : 'up';
1772
+ fire({ gesture, ...v, direction }, e);
1773
+ }
1774
+ }
1775
+ if (pts.size < 2)
1776
+ base = null;
1777
+ endPress();
1778
+ };
1779
+ el.addEventListener('pointerdown', down);
1780
+ el.addEventListener('pointermove', move);
1781
+ el.addEventListener('pointerup', up);
1782
+ el.addEventListener('pointercancel', up);
1783
+ if (gesture === 'twist' && !el.style.touchAction)
1784
+ el.style.touchAction = 'none';
1785
+ return () => {
1786
+ endPress();
1787
+ el.removeEventListener('pointerdown', down);
1788
+ el.removeEventListener('pointermove', move);
1789
+ el.removeEventListener('pointerup', up);
1790
+ el.removeEventListener('pointercancel', up);
1791
+ };
1792
+ }
1793
+ /**
1794
+ * `<usa-gesture-fx gesture="fling | twist | long-press" effect="tada"
1795
+ * options='{"…"}' velocity angle duration>` — plays `effect` on its first
1796
+ * child (or itself with `self`) when the gesture happens.
1797
+ */
1798
+ function defineGestureFx(tag = 'usa-gesture-fx') {
1799
+ return base.defineElement(tag, (Base) => class UsaGestureFx extends Base {
1800
+ static get observedAttributes() {
1801
+ return ['gesture', 'effect', 'options'];
1802
+ }
1803
+ get gesture() {
1804
+ const g = this.str('gesture', 'fling');
1805
+ return GESTURES.includes(g) ? g : 'fling';
1806
+ }
1807
+ mount() {
1808
+ let effectOptions = {};
1809
+ try {
1810
+ effectOptions = JSON.parse(this.str('options', '{}')) || {};
1811
+ }
1812
+ catch {
1813
+ /* ignore bad JSON */
1814
+ }
1815
+ const target = this.flag('self') ? this : this.firstElementChild || this;
1816
+ const effect = this.str('effect', 'pulse');
1817
+ this.onCleanup(bindGesture(this, this.gesture, (_d, e) => void components_fx.playEffect(target, effect, effectOptions, e).catch(() => undefined), {
1818
+ velocity: this.num('velocity', 0.8),
1819
+ angle: this.num('angle', 30),
1820
+ duration: this.num('duration', 650),
1821
+ }));
1822
+ }
1823
+ }, { id: 'usa-gesture-fx', text: 'usa-gesture-fx{display:inline-block;touch-action:none;user-select:none}' });
1824
+ }
1825
+
1118
1826
  /**
1119
1827
  * use-scroll-animate/components/effects — the 5.x effect packs, all
1120
1828
  * registered through `registerEffect()` (5.0) and playable with
@@ -1133,6 +1841,8 @@ const EFFECT_PACKS = {
1133
1841
  physics: PHYSICS_FX,
1134
1842
  page: PAGE_FX,
1135
1843
  generative: GENERATIVE_FX,
1844
+ audio: AUDIO_FX,
1845
+ cursor: CURSOR_FX,
1136
1846
  };
1137
1847
  /** 5.1: card & click effects 2.0. */
1138
1848
  function registerCardClickEffects() {
@@ -1150,9 +1860,19 @@ function registerPageEffects() {
1150
1860
  function registerGenerativeEffects() {
1151
1861
  components_fx.registerEffects(EFFECT_PACKS.generative);
1152
1862
  }
1863
+ /** 5.6: sound-reactive (Web Audio) backgrounds. */
1864
+ function registerAudioEffects() {
1865
+ components_fx.registerEffects(EFFECT_PACKS.audio);
1866
+ }
1867
+ /** 5.7: cursor trails, magnetic dots, spotlight cursor. */
1868
+ function registerCursorEffects() {
1869
+ components_fx.registerEffects(EFFECT_PACKS.cursor);
1870
+ }
1153
1871
  /** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
1154
1872
  function defineEffectElements() {
1155
1873
  defineStory();
1874
+ defineAudio();
1875
+ defineGestureFx();
1156
1876
  }
1157
1877
  /** Register the built-ins and every pack (idempotent). */
1158
1878
  function registerAllEffects() {
@@ -1161,23 +1881,39 @@ function registerAllEffects() {
1161
1881
  components_fx.registerEffects(defs);
1162
1882
  }
1163
1883
 
1884
+ exports.AUDIO_FX = AUDIO_FX;
1164
1885
  exports.CARD_FX = CARD_FX;
1165
1886
  exports.CLICK_FX = CLICK_FX;
1887
+ exports.CURSOR_FX = CURSOR_FX;
1166
1888
  exports.EFFECT_PACKS = EFFECT_PACKS;
1167
1889
  exports.GENERATIVE_FX = GENERATIVE_FX;
1890
+ exports.GESTURES = GESTURES;
1168
1891
  exports.PAGE_FX = PAGE_FX;
1169
1892
  exports.PHYSICS_FX = PHYSICS_FX;
1170
1893
  exports.STORY_TEMPLATES = STORY_TEMPLATES;
1894
+ exports.angleDelta = angleDelta;
1895
+ exports.bindBeat = bindBeat;
1896
+ exports.bindGesture = bindGesture;
1171
1897
  exports.bounceKeyframes = bounceKeyframes;
1172
1898
  exports.canvasBackground = canvasBackground;
1899
+ exports.createBeatDetector = createBeatDetector;
1900
+ exports.defineAudio = defineAudio;
1173
1901
  exports.defineEffectElements = defineEffectElements;
1902
+ exports.defineGestureFx = defineGestureFx;
1174
1903
  exports.defineStory = defineStory;
1904
+ exports.disableAudio = disableAudio;
1905
+ exports.enableAudio = enableAudio;
1906
+ exports.flingVelocity = flingVelocity;
1175
1907
  exports.formatCount = formatCount;
1176
1908
  exports.fxLayer = fxLayer;
1909
+ exports.getAudio = getAudio;
1177
1910
  exports.hexRgb = hexRgb;
1178
1911
  exports.noise2 = noise2;
1912
+ exports.onBeat = onBeat;
1179
1913
  exports.registerAllEffects = registerAllEffects;
1914
+ exports.registerAudioEffects = registerAudioEffects;
1180
1915
  exports.registerCardClickEffects = registerCardClickEffects;
1916
+ exports.registerCursorEffects = registerCursorEffects;
1181
1917
  exports.registerGenerativeEffects = registerGenerativeEffects;
1182
1918
  exports.registerPageEffects = registerPageEffects;
1183
1919
  exports.registerPhysicsEffects = registerPhysicsEffects;