openvibers/openvibe.shared ov-fx.js

ov-fx.js

main 24.7 KB · 324 lines · javascript

1/**
2 * 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,
3 * a glow, a press) for anything on any OpenVibe page, plus reveals, counting numbers, a ripple, a tilt and confetti.
4 * navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.
5 *
6 * <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them
7 * <section data-fx="reveal" data-fx-stagger="40">…</section>
8 * its heading rises 12 px (8 on a phone) out of a 4 px blur,
9 * a [data-fx-rule] draws from the left, and its cards follow in
10 * 40 ms steps (at most six; the rest together), once.
11 * <b data-fx="count">12,480</b> the number counts up the first time it is seen
12 * <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed
13 * OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)
14 * OVFx.scan(root) wire effects in markup added later (scanned by itself too)
15 *
16 * Effects: glint (a band of light crosses on hover), glow (the accent glows behind on hover), lift (rises a little),
17 * press (gives on press and springs back), ripple (from the point pressed), tilt (leans toward the pointer; mouse
18 * only), reveal, count, confetti, shimmer (a placeholder that shimmers until data-fx is removed).
19 *
20 * Motion tokens (a look can set them; theme-loader.js passes a theme's own): --ov-dur-1…4, --ov-ease-out,
21 * --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a
22 * 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
23 * element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion:
24 * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a tilt
25 * leans a third as far, a burst of confetti is a third the size, nothing overshoots; a reveal keeps only a short
26 * fade. Only Off (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.
27 *
28 * The reveal (278) is driven by the scroll timeline where the browser has animation-timeline: view(), so no script
29 * runs on the main thread while scrolling; elsewhere one shared IntersectionObserver does the same job. Content is
30 * visible by default: the start state is only applied (a) by CSS inside @supports, or (b) to elements the script
31 * knows are below the fold, with an observer ready to show them. A heading is [data-fx-heading] or the first h1–h6
32 * in the container; a rule is [data-fx-rule] (or an <hr>); cards are [data-fx-card]s, the container's own children,
33 * or the children of a plain list/grid child. Point at them with data-fx-cards="selector" when the markup is not
34 * one of those shapes. data-fx-self reveals the element itself. Distances halve on a phone, Calm fades, Off is still.
35 */
36(function (root) {
37 'use strict';
38 if (!root || root.OVFx) return;
39 const doc = root.document;
40 const feel = (ids) => ids.map((id) => `[data-theme=${id}]`).join(',');
41 const CALM = feel(['paper', 'daylight', 'cloud', 'snow', 'sand', 'rose', 'meadow', 'peach', 'lavender', 'dawn', 'solarized-light', 'catppuccin-latte', 'nord', 'nord-light']);
42 const SNAPPY = feel(['neon-tokyo', 'vapor', 'matrix', 'hacker', 'high-contrast', 'dracula', 'monokai', 'abyss']);
43 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)}'
44 + `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)}`
45 + `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)}`
46 + '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)}'
47 + '@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)}}'
48 + 'html[data-ov-motion=off]{--ov-move:0;--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'
49 // A demo scope plays in full whatever the page's motion is, in the look's own feel.
50 + '[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}'
51 + `html:is(${CALM}) [data-fx-demo]{--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s}`
52 + `html:is(${SNAPPY}) [data-fx-demo]{--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s}`
53 + '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'
54 + '.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}'
55 + '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'
56 + '.ovfx-glow{transition:box-shadow var(--ov-dur-3) var(--ov-ease-out)}'
57 + '.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)}'
58 + '.ovfx-lift{transition:transform var(--ov-dur-3) var(--ov-ease-spring),box-shadow var(--ov-dur-3) var(--ov-ease-out)}'
59 + '.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)));box-shadow:0 12px 28px -14px rgba(0,0,0,.45)}'
60 + '.ovfx-press{transition:transform var(--ov-dur-4) var(--ov-ease-spring)}'
61 + '.ovfx-press:active{transform:scale(calc(1 - .04 * var(--ov-move,1)));transition-duration:var(--ov-dur-1)}'
62 + '.ovfx-lift.ovfx-press:active{transform:translateY(calc(-1px * var(--ov-move,1))) scale(calc(1 - .03 * var(--ov-move,1)))}'
63 + '.ovfx-ripple{position:relative;overflow:hidden;isolation:isolate}'
64 + '.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}'
65 + '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'
66 + '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'
67 // The reveal (278): a heading out of a blur, its rule drawing from the left, then its cards. The scroll timeline
68 // plays it where view() exists; the classes only carry the start state, so the page is never left blank by CSS.
69 + '.ovfx-head{--ovfx-rise:12px}.ovfx-card{--ovfx-card-rise:10px}'
70 + '@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}.ovfx-card{--ovfx-card-rise:6px}}'
71 + '.ovfx-rule{transform-origin:left center}'
72 + '@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)}}'
73 + '@keyframes ovfxCardRise{from{opacity:0;transform:translateY(calc(var(--ovfx-card-rise,10px) * var(--ov-move,1)))}to{opacity:1;transform:none}}'
74 + '@keyframes ovfxRuleDraw{from{transform:scaleX(0);opacity:.35}to{transform:scaleX(1);opacity:1}}'
75 + '@keyframes ovfxFadeIn{from{opacity:0}to{opacity:1}}'
76 + '@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%}}'
77 + '.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px));filter:blur(4px)}'
78 + '.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)}'
79 + '.ovfx-card.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-card-rise,10px))}'
80 + '.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)}'
81 + '.ovfx-rule.ovfx-wait{transform:scaleX(0);opacity:.35}'
82 + '.ovfx-rule.ovfx-in{transform:scaleX(1);opacity:1;transition:transform .6s var(--ov-ease-out) 80ms,opacity .4s var(--ov-ease-out) 80ms}'
83 + '.ovfx-fade.ovfx-wait{opacity:0}'
84 + '.ovfx-fade.ovfx-in{opacity:1;transition:opacity .28s var(--ov-ease-out)}'
85 + '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%}'
86 + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}'
87 + '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)}'
88 + '@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)}}'
89 + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important;transition:none!important}'
90 + '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}'
91 + '.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}'
92 + '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
93 + '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
94 // Calm: the sheen crosses fainter and slower, the ripple is fainter. Off: no sheen, no shimmer, nothing fades in.
95 + '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}'
96 + '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}';
97
98 // The demo scope (4.180.0): inside [data-fx-demo] the effects play in full whatever the page's motion is. A page
99 // that exists to show motion sets it on one container, only after the person presses a button that says so.
100 const demo = (el) => !!(el && el.closest && el.closest('[data-fx-demo]'));
101 // How far things move here: 1 in full, CALM_MOVE under Calm, 0 when motion is off. <html data-ov-motion> is the signal
102 // (theme-loader.js writes it, the device's own setting counted in); a page without the loader asks the device.
103 const CALM_MOVE = 0.35;
104 const move = (el) => {
105 if (demo(el)) return 1;
106 const m = doc.documentElement.getAttribute('data-ov-motion');
107 if (m === 'off') return 0;
108 if (m === 'reduced') return CALM_MOVE;
109 return !m && root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches ? CALM_MOVE : 1;
110 };
111 const off = (el) => move(el) === 0;
112 const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);
113 const done = new WeakSet();
114 let io = null;
115
116 function css() {
117 if (doc.getElementById('ov-fx-css')) return;
118 const st = doc.createElement('style'); st.id = 'ov-fx-css'; st.textContent = CSS; (doc.head || doc.documentElement).appendChild(st);
119 }
120
121 // ── Seen: reveal and count, once each, when they come on screen ──
122 function observe(el, fn) {
123 if (!('IntersectionObserver' in root)) return fn(el);
124 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' });
125 el.__ovfxSeen = fn;
126 io.observe(el);
127 }
128 const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };
129 function seenCount(el) { const c = el.__ovfxCount; if (c) { el.__ovfxCount = null; countUp(el, c); } }
130
131 // ── Reveal (278): a heading, its rule and its cards, once. The scroll timeline (where view() exists) plays it
132 // without any script on the main thread; the shared observer is the fallback. Only elements the script knows are
133 // below the fold ever get a start state, and every path has a way back to the finished state.
134 const HEAD_SEL = 'h1,h2,h3,h4,h5,h6';
135 const CARDISH = 'li,article,a,[data-fx-card],[class*="card"],[class*="tile"],[class*="item"]';
136 function findHead(el) {
137 if (el.hasAttribute('data-fx-self')) return el;
138 const marked = el.querySelector('[data-fx-heading]');
139 if (marked) return marked;
140 if (el.matches(HEAD_SEL)) return el;
141 for (const h of el.querySelectorAll(HEAD_SEL)) {
142 const card = h.closest(CARDISH);
143 if (card && card !== el && el.contains(card)) continue; // a card's own title is not the section's heading
144 let depth = 0, p = h;
145 while (p && p !== el) { p = p.parentElement; depth++; }
146 if (depth <= 3) return h;
147 }
148 return null;
149 }
150 function findRule(el, head) {
151 const marked = el.querySelector('[data-fx-rule]');
152 if (marked && marked !== head) return marked;
153 const hr = el.querySelector('hr');
154 return hr && hr !== head ? hr : null;
155 }
156 function findCards(el, head, rule) {
157 const sel = el.getAttribute('data-fx-cards');
158 if (sel) { try { const list = Array.from(el.querySelectorAll(sel)); if (list.length) return list; } catch (e) { /* the default below */ } }
159 const marked = Array.from(el.querySelectorAll('[data-fx-card]'));
160 if (marked.length) return marked;
161 const out = [];
162 for (const k of Array.from(el.children)) {
163 if (k === head || k === rule || (head && k.contains(head)) || (rule && k.contains(rule))) continue;
164 const cls = typeof k.className === 'string' ? k.className : '';
165 if (k.matches('ul,ol,[role="list"]') || /(^|[\s_-])(grid|cards|rail|row|list)([\s_-]|$)/i.test(cls)) {
166 const inner = Array.from(k.children);
167 if (inner.length) { out.push(...inner); continue; }
168 }
169 out.push(k);
170 }
171 return out;
172 }
173 let viewTimeline = null;
174 function hasViewTimeline() {
175 if (viewTimeline != null) return viewTimeline;
176 try { viewTimeline = !!(root.CSS && root.CSS.supports && root.CSS.supports('animation-timeline: view()')); } catch (e) { viewTimeline = false; }
177 return viewTimeline;
178 }
179 function showReveal(el) {
180 if (el.__ovfxShown) return;
181 el.__ovfxShown = true;
182 el.classList.add('ovfx-in');
183 const delay = Number(String(el.style.getPropertyValue('--ovfx-d')).replace('ms', '')) || 0;
184 root.setTimeout(() => { el.classList.remove('ovfx-wait', 'ovfx-in'); el.style.removeProperty('--ovfx-d'); }, 900 + delay);
185 }
186 function wireReveal(el) {
187 if (off(el)) return;
188 const mode = demo(el) ? 'full' : move(el) < 1 ? 'fade' : 'full';
189 const self = el.hasAttribute('data-fx-self');
190 const head = findHead(el), rule = self ? null : findRule(el, head), cards = self ? [] : findCards(el, head, rule);
191 const parts = [];
192 if (head) parts.push([head, 'head', 0]);
193 if (rule) parts.push([rule, 'rule', 0]);
194 cards.forEach((k, i) => parts.push([k, 'card', Math.min(i, 5)]));
195 if (!parts.length) return;
196 // Never hide what someone can already see: a section below the fold is safe whole, one already on screen
197 // is checked part by part.
198 const live = below(el) ? parts : parts.filter(([k]) => below(k));
199 if (!live.length) return;
200 const timeline = hasViewTimeline();
201 if (!timeline && !('IntersectionObserver' in root)) return;
202 const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 40));
203 const follow = head ? 140 : 0;
204 live.forEach(([k, kind, i]) => {
205 k.classList.add(mode === 'fade' ? 'ovfx-fade' : kind === 'head' ? 'ovfx-head' : kind === 'rule' ? 'ovfx-rule' : 'ovfx-card');
206 if (timeline) {
207 k.classList.add('ovfx-scroll');
208 if (kind === 'card') k.style.setProperty('--ovfx-i', String(i));
209 } else {
210 k.classList.add('ovfx-wait');
211 if (kind === 'card' && mode === 'full') k.style.setProperty('--ovfx-d', `${follow + i * gap}ms`);
212 observe(k, showReveal);
213 }
214 });
215 }
216
217 function countUp(el, c) {
218 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);
219 const step = (now) => {
220 const p = Math.min(1, ((now || Date.now()) - t0) / ms), e = 1 - Math.pow(1 - p, 3);
221 el.textContent = p < 1 ? c.fmt(c.to * e) : c.text;
222 if (p < 1) root.requestAnimationFrame(step);
223 };
224 root.requestAnimationFrame(step);
225 }
226 function countOf(el) {
227 const text = el.textContent;
228 const m = /^(\D*?)(\d(?:[\d.,\s ]*\d)?)(\D*)$/.exec(text.trim());
229 if (!m) return null;
230 const digits = m[2].replace(/[\s ]/g, '');
231 const dec = /[.,]\d{1,2}$/.test(digits) && !/^\d{1,3}([.,]\d{3})+$/.test(digits) ? digits.slice(-3).replace(/^\D/, '').length : 0;
232 const to = Number(dec ? digits.replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.') : digits.replace(/[.,]/g, ''));
233 if (!Number.isFinite(to) || to <= 0) return null;
234 const nf = new Intl.NumberFormat(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });
235 return { to, text, fmt: (v) => m[1] + nf.format(v) + m[3] };
236 }
237
238 // ── Pointer effects ──
239 function ripple(el, ev) {
240 if (off(el)) return;
241 const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;
242 const s = doc.createElement('span'); s.className = 'ovfx-rip';
243 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`;
244 el.appendChild(s); root.setTimeout(() => s.remove(), 650);
245 }
246 function tilt(el) {
247 let raf = 0;
248 el.addEventListener('pointermove', (ev) => {
249 if (ev.pointerType !== 'mouse' || off(el) || raf) return;
250 raf = root.requestAnimationFrame(() => {
251 raf = 0;
252 const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;
253 const k = move(el);
254 el.style.transform = `perspective(900px) rotateX(${(-y * 6 * k).toFixed(2)}deg) rotateY(${(x * 7 * k).toFixed(2)}deg)`;
255 });
256 });
257 el.addEventListener('pointerleave', () => { if (raf) { root.cancelAnimationFrame(raf); raf = 0; } el.style.transform = ''; });
258 }
259
260 // ── Confetti: one canvas, about 1.6 s, then gone. Calm: a third of the pieces, thrown half as hard ──
261 function confetti(opts) {
262 const k = move(opts && opts.from);
263 if (!k || !doc.body) return false;
264 const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);
265 const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';
266 const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];
267 const cv = doc.createElement('canvas'); cv.className = 'ovfx-confetti'; cv.setAttribute('aria-hidden', 'true');
268 const dpr = Math.min(2, root.devicePixelRatio || 1), W = root.innerWidth, H = root.innerHeight;
269 cv.width = W * dpr; cv.height = H * dpr; doc.body.appendChild(cv);
270 const ctx = cv.getContext('2d'); if (!ctx) { cv.remove(); return false; }
271 ctx.scale(dpr, dpr);
272 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;
273 const ps = Array.from({ length: n }, (_, i) => {
274 const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9, v = (6 + Math.random() * 7) * throwBy;
275 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 };
276 });
277 const t0 = Date.now();
278 const frame = () => {
279 const t = (Date.now() - t0) / 1600;
280 ctx.clearRect(0, 0, W, H);
281 ps.forEach((p) => {
282 p.vy += 0.28; p.vx *= 0.985; p.vy *= 0.985; p.x += p.vx; p.y += p.vy; p.r += p.vr;
283 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;
284 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);
285 ctx.restore();
286 });
287 if (t < 1 && !doc.hidden) root.requestAnimationFrame(frame); else cv.remove();
288 };
289 root.requestAnimationFrame(frame);
290 return true;
291 }
292
293 // ── Wiring ──
294 function wire(el) {
295 if (done.has(el)) return;
296 done.add(el);
297 const names = String(el.getAttribute('data-fx') || '').toLowerCase().split(/\s+/).filter(Boolean);
298 for (const n of names) {
299 if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);
300 if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
301 if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
302 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 }); });
303 if (n === 'reveal') wireReveal(el);
304 if (n === 'count' && !off(el)) {
305 const c = countOf(el);
306 if (c && below(el)) { el.__ovfxCount = c; observe(el, seenCount); } else if (c) { el.__ovfxCount = c; seenCount(el); }
307 }
308 }
309 }
310 function scan(scope) {
311 const base = scope && scope.querySelectorAll ? scope : doc;
312 if (base.matches && base.matches('[data-fx]')) wire(base);
313 base.querySelectorAll('[data-fx]').forEach(wire);
314 }
315 function init() {
316 css();
317 scan(doc);
318 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 });
319 }
320
321 root.OVFx = { scan, confetti, wire, move, FEEL: { CALM, SNAPPY } };
322 if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init();
323})(typeof window !== 'undefined' ? window : this);
324