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