/**
* openvibe-shared/ov-fx.js — the network's effects, one kit (plan L45, 4.66.0). The mark's motion (4.65.0: a glint,
* a glow, a press) for anything on any OpenVibe page, plus reveals, counting numbers, a ripple, a tilt and confetti.
* navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.
*
* … effects by name, any number of them
* …
* its heading rises 12 px (8 on a phone) out of a 4 px blur,
* a [data-fx-rule] draws from the left, and its cards follow in
* 40 ms steps (at most six; the rest together), once.
* 12,480 the number counts up the first time it is seen
* a burst of confetti from the button when it is pressed
* OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)
* OVFx.scan(root) wire effects in markup added later (scanned by itself too)
*
* Effects: glint (a band of light crosses on hover), glow (the accent glows behind on hover), lift (rises a little),
* press (gives on press and springs back), ripple (from the point pressed), tilt (leans toward the pointer; mouse
* only), reveal, count, confetti, shimmer (a placeholder that shimmers until data-fx is removed).
*
* Motion tokens (a look can set them; theme-loader.js passes a theme's own): --ov-dur-1…4, --ov-ease-out,
* --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a
* little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. Nothing here runs by itself: every effect waits for a hover, a press, or the
* element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion:
* html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a tilt
* leans a third as far, a burst of confetti is a third the size, nothing overshoots; a reveal keeps only a short
* fade. Only Off (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.
*
* The reveal (278) is driven by the scroll timeline where the browser has animation-timeline: view(), so no script
* runs on the main thread while scrolling; elsewhere one shared IntersectionObserver does the same job. Content is
* visible by default: the start state is only applied (a) by CSS inside @supports, or (b) to elements the script
* knows are below the fold, with an observer ready to show them. A heading is [data-fx-heading] or the first h1–h6
* in the container; a rule is [data-fx-rule] (or an
); cards are [data-fx-card]s, the container's own children,
* or the children of a plain list/grid child. Point at them with data-fx-cards="selector" when the markup is not
* one of those shapes. data-fx-self reveals the element itself. Distances halve on a phone, Calm fades, Off is still.
*/
(function (root) {
'use strict';
if (!root || root.OVFx) return;
const doc = root.document;
const feel = (ids) => ids.map((id) => `[data-theme=${id}]`).join(',');
const CALM = feel(['paper', 'daylight', 'cloud', 'snow', 'sand', 'rose', 'meadow', 'peach', 'lavender', 'dawn', 'solarized-light', 'catppuccin-latte', 'nord', 'nord-light']);
const SNAPPY = feel(['neon-tokyo', 'vapor', 'matrix', 'hacker', 'high-contrast', 'dracula', 'monokai', 'abyss']);
const CSS = ':root{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s;--ov-ease-out:cubic-bezier(.2,.8,.2,1);--ov-ease-spring:cubic-bezier(.34,1.56,.64,1);--ov-ease-in-out:cubic-bezier(.65,0,.35,1)}'
+ `html:is(${CALM}){--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}`
+ `html:is(${SNAPPY}){--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s;--ov-ease-spring:cubic-bezier(.3,1.8,.6,1)}`
+ 'html[data-ov-motion=reduced]{--ov-move:.35;--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}'
+ '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]){--ov-move:.35;--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}}'
+ 'html[data-ov-motion=off]{--ov-move:0;--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'
// A demo scope plays in full whatever the page's motion is, in the look's own feel.
+ '[data-fx-demo]{--ov-move:1;--ov-ease-spring:cubic-bezier(.34,1.56,.64,1);--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s}'
+ `html:is(${CALM}) [data-fx-demo]{--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s}`
+ `html:is(${SNAPPY}) [data-fx-demo]{--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s}`
+ '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'
+ '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}'
+ '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'
+ '.ovfx-glow{transition:box-shadow var(--ov-dur-3) var(--ov-ease-out)}'
+ '.ovfx-glow:hover,.ovfx-glow:focus-visible{box-shadow:0 0 0 1px color-mix(in srgb,var(--accent,#4f7cff) 45%,transparent),0 10px 34px -10px color-mix(in srgb,var(--accent,#4f7cff) 70%,transparent)}'
+ '.ovfx-lift{transition:transform var(--ov-dur-3) var(--ov-ease-spring),box-shadow var(--ov-dur-3) var(--ov-ease-out)}'
+ '.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)));box-shadow:0 12px 28px -14px rgba(0,0,0,.45)}'
+ '.ovfx-press{transition:transform var(--ov-dur-4) var(--ov-ease-spring)}'
+ '.ovfx-press:active{transform:scale(calc(1 - .04 * var(--ov-move,1)));transition-duration:var(--ov-dur-1)}'
+ '.ovfx-lift.ovfx-press:active{transform:translateY(calc(-1px * var(--ov-move,1))) scale(calc(1 - .03 * var(--ov-move,1)))}'
+ '.ovfx-ripple{position:relative;overflow:hidden;isolation:isolate}'
+ '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'
+ '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'
+ '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'
// The reveal (278): a heading out of a blur, its rule drawing from the left, then its cards. The scroll timeline
// plays it where view() exists; the classes only carry the start state, so the page is never left blank by CSS.
+ '.ovfx-head{--ovfx-rise:12px}.ovfx-card{--ovfx-card-rise:10px}'
+ '@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}.ovfx-card{--ovfx-card-rise:6px}}'
+ '.ovfx-rule{transform-origin:left center}'
+ '@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}to{opacity:1;transform:none;filter:blur(0)}}'
+ '@keyframes ovfxCardRise{from{opacity:0;transform:translateY(calc(var(--ovfx-card-rise,10px) * var(--ov-move,1)))}to{opacity:1;transform:none}}'
+ '@keyframes ovfxRuleDraw{from{transform:scaleX(0);opacity:.35}to{transform:scaleX(1);opacity:1}}'
+ '@keyframes ovfxFadeIn{from{opacity:0}to{opacity:1}}'
+ '@supports (animation-timeline:view()){.ovfx-head.ovfx-scroll{animation:ovfxHeadRise linear backwards;animation-timeline:view();animation-range:entry 0% cover 25%}.ovfx-rule.ovfx-scroll{animation:ovfxRuleDraw linear backwards;animation-timeline:view();animation-range:entry 5% cover 35%}.ovfx-card.ovfx-scroll{animation:ovfxCardRise linear backwards;animation-timeline:view();animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)}.ovfx-fade.ovfx-scroll{animation:ovfxFadeIn linear backwards;animation-timeline:view();animation-range:entry 0% cover 15%}}'
+ '.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px));filter:blur(4px)}'
+ '.ovfx-head.ovfx-in{opacity:1;transform:none;filter:blur(0);transition:opacity .5s var(--ov-ease-out),transform .55s var(--ov-ease-spring),filter .45s var(--ov-ease-out)}'
+ '.ovfx-card.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-card-rise,10px))}'
+ '.ovfx-card.ovfx-in{opacity:1;transform:none;transition:opacity .45s var(--ov-ease-out),transform .55s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'
+ '.ovfx-rule.ovfx-wait{transform:scaleX(0);opacity:.35}'
+ '.ovfx-rule.ovfx-in{transform:scaleX(1);opacity:1;transition:transform .6s var(--ov-ease-out) 80ms,opacity .4s var(--ov-ease-out) 80ms}'
+ '.ovfx-fade.ovfx-wait{opacity:0}'
+ '.ovfx-fade.ovfx-in{opacity:1;transition:opacity .28s var(--ov-ease-out)}'
+ 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}'
+ 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}'
+ 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}'
+ '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}}'
+ 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important;transition:none!important}'
+ 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade).ovfx-wait:not([data-fx-demo] *){opacity:1!important;transform:none!important;filter:none!important}'
+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
// Calm: the sheen crosses fainter and slower, the ripple is fainter. Off: no sheen, no shimmer, nothing fades in.
+ 'html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{opacity:.55}html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *):is(:hover,:focus-visible)::after{transition-duration:1.1s}html[data-ov-motion=reduced] .ovfx-rip:not([data-fx-demo] *){opacity:.14}'
+ 'html[data-ov-motion=off] .ovfx-glint:not([data-fx-demo] *)::after{display:none}html[data-ov-motion=off] .ovfx-shimmer:not([data-fx-demo] *){animation:none}html[data-ov-motion=off] :is(.ovfx-in,.ovfx-lift,.ovfx-press,.ovfx-tilt):not([data-fx-demo] *){transition:none!important}';
// The demo scope (4.180.0): inside [data-fx-demo] the effects play in full whatever the page's motion is. A page
// that exists to show motion sets it on one container, only after the person presses a button that says so.
const demo = (el) => !!(el && el.closest && el.closest('[data-fx-demo]'));
// How far things move here: 1 in full, CALM_MOVE under Calm, 0 when motion is off. is the signal
// (theme-loader.js writes it, the device's own setting counted in); a page without the loader asks the device.
const CALM_MOVE = 0.35;
const move = (el) => {
if (demo(el)) return 1;
const m = doc.documentElement.getAttribute('data-ov-motion');
if (m === 'off') return 0;
if (m === 'reduced') return CALM_MOVE;
return !m && root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches ? CALM_MOVE : 1;
};
const off = (el) => move(el) === 0;
const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);
const done = new WeakSet();
let io = null;
function css() {
if (doc.getElementById('ov-fx-css')) return;
const st = doc.createElement('style'); st.id = 'ov-fx-css'; st.textContent = CSS; (doc.head || doc.documentElement).appendChild(st);
}
// ── Seen: reveal and count, once each, when they come on screen ──
function observe(el, fn) {
if (!('IntersectionObserver' in root)) return fn(el);
io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); (e.target.__ovfxSeen || fn)(e.target); } }), { rootMargin: '0px 0px -8% 0px' });
el.__ovfxSeen = fn;
io.observe(el);
}
const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };
function seenCount(el) { const c = el.__ovfxCount; if (c) { el.__ovfxCount = null; countUp(el, c); } }
// ── Reveal (278): a heading, its rule and its cards, once. The scroll timeline (where view() exists) plays it
// without any script on the main thread; the shared observer is the fallback. Only elements the script knows are
// below the fold ever get a start state, and every path has a way back to the finished state.
const HEAD_SEL = 'h1,h2,h3,h4,h5,h6';
const CARDISH = 'li,article,a,[data-fx-card],[class*="card"],[class*="tile"],[class*="item"]';
function findHead(el) {
if (el.hasAttribute('data-fx-self')) return el;
const marked = el.querySelector('[data-fx-heading]');
if (marked) return marked;
if (el.matches(HEAD_SEL)) return el;
for (const h of el.querySelectorAll(HEAD_SEL)) {
const card = h.closest(CARDISH);
if (card && card !== el && el.contains(card)) continue; // a card's own title is not the section's heading
let depth = 0, p = h;
while (p && p !== el) { p = p.parentElement; depth++; }
if (depth <= 3) return h;
}
return null;
}
function findRule(el, head) {
const marked = el.querySelector('[data-fx-rule]');
if (marked && marked !== head) return marked;
const hr = el.querySelector('hr');
return hr && hr !== head ? hr : null;
}
function findCards(el, head, rule) {
const sel = el.getAttribute('data-fx-cards');
if (sel) { try { const list = Array.from(el.querySelectorAll(sel)); if (list.length) return list; } catch (e) { /* the default below */ } }
const marked = Array.from(el.querySelectorAll('[data-fx-card]'));
if (marked.length) return marked;
const out = [];
for (const k of Array.from(el.children)) {
if (k === head || k === rule || (head && k.contains(head)) || (rule && k.contains(rule))) continue;
const cls = typeof k.className === 'string' ? k.className : '';
if (k.matches('ul,ol,[role="list"]') || /(^|[\s_-])(grid|cards|rail|row|list)([\s_-]|$)/i.test(cls)) {
const inner = Array.from(k.children);
if (inner.length) { out.push(...inner); continue; }
}
out.push(k);
}
return out;
}
let viewTimeline = null;
function hasViewTimeline() {
if (viewTimeline != null) return viewTimeline;
try { viewTimeline = !!(root.CSS && root.CSS.supports && root.CSS.supports('animation-timeline: view()')); } catch (e) { viewTimeline = false; }
return viewTimeline;
}
function showReveal(el) {
if (el.__ovfxShown) return;
el.__ovfxShown = true;
el.classList.add('ovfx-in');
const delay = Number(String(el.style.getPropertyValue('--ovfx-d')).replace('ms', '')) || 0;
root.setTimeout(() => { el.classList.remove('ovfx-wait', 'ovfx-in'); el.style.removeProperty('--ovfx-d'); }, 900 + delay);
}
function wireReveal(el) {
if (off(el)) return;
const mode = demo(el) ? 'full' : move(el) < 1 ? 'fade' : 'full';
const self = el.hasAttribute('data-fx-self');
const head = findHead(el), rule = self ? null : findRule(el, head), cards = self ? [] : findCards(el, head, rule);
const parts = [];
if (head) parts.push([head, 'head', 0]);
if (rule) parts.push([rule, 'rule', 0]);
cards.forEach((k, i) => parts.push([k, 'card', Math.min(i, 5)]));
if (!parts.length) return;
// Never hide what someone can already see: a section below the fold is safe whole, one already on screen
// is checked part by part.
const live = below(el) ? parts : parts.filter(([k]) => below(k));
if (!live.length) return;
const timeline = hasViewTimeline();
if (!timeline && !('IntersectionObserver' in root)) return;
const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 40));
const follow = head ? 140 : 0;
live.forEach(([k, kind, i]) => {
k.classList.add(mode === 'fade' ? 'ovfx-fade' : kind === 'head' ? 'ovfx-head' : kind === 'rule' ? 'ovfx-rule' : 'ovfx-card');
if (timeline) {
k.classList.add('ovfx-scroll');
if (kind === 'card') k.style.setProperty('--ovfx-i', String(i));
} else {
k.classList.add('ovfx-wait');
if (kind === 'card' && mode === 'full') k.style.setProperty('--ovfx-d', `${follow + i * gap}ms`);
observe(k, showReveal);
}
});
}
function countUp(el, c) {
const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220) * (move(el) < 1 ? 0.6 : 1);
const step = (now) => {
const p = Math.min(1, ((now || Date.now()) - t0) / ms), e = 1 - Math.pow(1 - p, 3);
el.textContent = p < 1 ? c.fmt(c.to * e) : c.text;
if (p < 1) root.requestAnimationFrame(step);
};
root.requestAnimationFrame(step);
}
function countOf(el) {
const text = el.textContent;
const m = /^(\D*?)(\d(?:[\d.,\s ]*\d)?)(\D*)$/.exec(text.trim());
if (!m) return null;
const digits = m[2].replace(/[\s ]/g, '');
const dec = /[.,]\d{1,2}$/.test(digits) && !/^\d{1,3}([.,]\d{3})+$/.test(digits) ? digits.slice(-3).replace(/^\D/, '').length : 0;
const to = Number(dec ? digits.replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.') : digits.replace(/[.,]/g, ''));
if (!Number.isFinite(to) || to <= 0) return null;
const nf = new Intl.NumberFormat(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });
return { to, text, fmt: (v) => m[1] + nf.format(v) + m[3] };
}
// ── Pointer effects ──
function ripple(el, ev) {
if (off(el)) return;
const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;
const s = doc.createElement('span'); s.className = 'ovfx-rip';
s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`;
el.appendChild(s); root.setTimeout(() => s.remove(), 650);
}
function tilt(el) {
let raf = 0;
el.addEventListener('pointermove', (ev) => {
if (ev.pointerType !== 'mouse' || off(el) || raf) return;
raf = root.requestAnimationFrame(() => {
raf = 0;
const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;
const k = move(el);
el.style.transform = `perspective(900px) rotateX(${(-y * 6 * k).toFixed(2)}deg) rotateY(${(x * 7 * k).toFixed(2)}deg)`;
});
});
el.addEventListener('pointerleave', () => { if (raf) { root.cancelAnimationFrame(raf); raf = 0; } el.style.transform = ''; });
}
// ── Confetti: one canvas, about 1.6 s, then gone. Calm: a third of the pieces, thrown half as hard ──
function confetti(opts) {
const k = move(opts && opts.from);
if (!k || !doc.body) return false;
const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);
const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';
const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];
const cv = doc.createElement('canvas'); cv.className = 'ovfx-confetti'; cv.setAttribute('aria-hidden', 'true');
const dpr = Math.min(2, root.devicePixelRatio || 1), W = root.innerWidth, H = root.innerHeight;
cv.width = W * dpr; cv.height = H * dpr; doc.body.appendChild(cv);
const ctx = cv.getContext('2d'); if (!ctx) { cv.remove(); return false; }
ctx.scale(dpr, dpr);
const x0 = o.x == null ? W / 2 : o.x, y0 = o.y == null ? H / 3 : o.y, n = Math.max(8, Math.round(Math.min(160, o.count || 80) * (k < 1 ? k : 1))), throwBy = k < 1 ? 0.55 : 1;
const ps = Array.from({ length: n }, (_, i) => {
const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9, v = (6 + Math.random() * 7) * throwBy;
return { x: x0, y: y0, vx: Math.cos(a) * v, vy: Math.sin(a) * v, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, w: 5 + Math.random() * 5, h: 3 + Math.random() * 4, c: colors[i % colors.length], round: Math.random() < 0.3 };
});
const t0 = Date.now();
const frame = () => {
const t = (Date.now() - t0) / 1600;
ctx.clearRect(0, 0, W, H);
ps.forEach((p) => {
p.vy += 0.28; p.vx *= 0.985; p.vy *= 0.985; p.x += p.vx; p.y += p.vy; p.r += p.vr;
ctx.save(); ctx.globalAlpha = Math.max(0, 1 - Math.max(0, t - 0.6) / 0.4); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c;
if (p.round) { ctx.beginPath(); ctx.arc(0, 0, p.h / 1.6, 0, Math.PI * 2); ctx.fill(); } else ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
});
if (t < 1 && !doc.hidden) root.requestAnimationFrame(frame); else cv.remove();
};
root.requestAnimationFrame(frame);
return true;
}
// ── Wiring ──
function wire(el) {
if (done.has(el)) return;
done.add(el);
const names = String(el.getAttribute('data-fx') || '').toLowerCase().split(/\s+/).filter(Boolean);
for (const n of names) {
if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);
if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });
if (n === 'reveal') wireReveal(el);
if (n === 'count' && !off(el)) {
const c = countOf(el);
if (c && below(el)) { el.__ovfxCount = c; observe(el, seenCount); } else if (c) { el.__ovfxCount = c; seenCount(el); }
}
}
}
function scan(scope) {
const base = scope && scope.querySelectorAll ? scope : doc;
if (base.matches && base.matches('[data-fx]')) wire(base);
base.querySelectorAll('[data-fx]').forEach(wire);
}
function init() {
css();
scan(doc);
if ('MutationObserver' in root) new root.MutationObserver((rs) => rs.forEach((r) => r.addedNodes.forEach((n) => { if (n.nodeType === 1) scan(n); }))).observe(doc.documentElement, { childList: true, subtree: true });
}
root.OVFx = { scan, confetti, wire, move, FEEL: { CALM, SNAPPY } };
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init();
})(typeof window !== 'undefined' ? window : this);