PageSourceSearch

https://infi.us/assets/js/motionfx.js

js infi.us collected 2026-09-24 10:52:23 UTC 20,022 bytes, 344 lines download raw bytes

1/* INFI — Mobile MotionFX
2 * Brings the desktop pointer-driven 3D effects to touch devices using the
3 * gyroscope (deviceorientation), direct touch-drag gestures, and haptics.
4 *
5 * Desktop inline scripts bail on `(hover:hover) and (pointer:fine)` and the
6 * mobile CSS flattens the same elements (perspective:none + transform:none).
7 * This module is the COMPLEMENT: it only runs on coarse/touch devices, and
8 * re-enables those exact effects driven by device tilt + finger, never on
9 * desktop (so it can't fight the inline pointer code).
10 *
11 * Targets (mirrors of the desktop effects):
12 *   #fomDev .............. home hero device tilt
13 *   .prob__card .......... home "peak rush" photo tilt
14 *   .eco3d__scene ........ home POS-stack 3D rotate
15 *   .clog__shot[data-tilt] home changelog photo tilt
16 *   .cph ................. feature phone mockups (hero + rows)
17 *   .vs-col .............. platform comparison cards (gentle tilt + glow)
18 *   .hub__scene .......... home/platform spine constellation (radial 3D rotate)
19 * Plus: global tap haptics, and the [data-csphere] constellation already
20 * drags on touch (pointer events) — we only add haptics there.
21 */
22(function () {
23  'use strict';
24  var mm = function (q) { return window.matchMedia && window.matchMedia(q).matches; };
25  var COARSE = mm('(pointer:coarse)') || !mm('(hover:hover) and (pointer:fine)') || ('ontouchstart' in window);
26  if (!COARSE) return; // desktop is owned by the inline pointer scripts
27
28  var REDUCE = mm('(prefers-reduced-motion: reduce)');
29  var HAS_VIBRATE = 'vibrate' in navigator;
30  var docEl = document.documentElement;
31  var clamp = function (v, a, b) { return v < a ? a : (v > b ? b : v); };
32  var now = function () { return (window.performance && performance.now) ? performance.now() : +new Date(); };
33
34  /* ============================== HAPTICS ============================== */
35  /* Light, debounced tap feedback. (navigator.vibrate is Android-only; on
36   * iOS Safari it's a graceful no-op — there is no web haptics API there.) */
37  var lastBuzz = 0;
38  function buzz(ms) {
39    if (!HAS_VIBRATE) return;
40    var t = now();
41    if (t - lastBuzz < 35) return;
42    lastBuzz = t;
43    try { navigator.vibrate(ms); } catch (e) {}
44  }
45  var TAP_SEL = 'a.pcard,.pcard,.hub__node,.hub-m__n,.btn,.btn--primary,.btn--ghost,.btn--lg,' +
46    '#themeTg,#burger,#mnavx,.mnav a,.csphere__tag,.vs-col,[data-tilt],.faq__q,.slab,.stk__back,' +
47    '.sqbadge,.clog__shot,.eco3d__cta,.eco-cta,.dl-btn,.fombar a,.foot a';
48  document.addEventListener('touchstart', function (e) {
49    var t = e.target && e.target.closest ? e.target.closest(TAP_SEL) : null;
50    if (!t) return;
51    var strong = t.id === 'themeTg' || t.id === 'burger' || t.classList.contains('btn--primary');
52    buzz(strong ? 13 : 8);
53  }, { passive: true });
54
55  /* Reduced-motion: keep the (non-visual) tap haptics, skip all tilt/gyro. */
56  if (REDUCE) return;
57
58  /* ============================ TILT TARGETS ========================== */
59  /* Each recipe maps a normalised tilt vector (nx, ny in [-1, 1]) to the
60   * element's transform, written as CSS custom props consumed by injected
61   * rules. nx = yaw (device tilted left/right), ny = pitch (top/bottom). */
62  var setRX = function (el, deg) { el.style.setProperty('--mfx-rx', deg.toFixed(2) + 'deg'); };
63  var setRY = function (el, deg) { el.style.setProperty('--mfx-ry', deg.toFixed(2) + 'deg'); };
64  /* inner-screen parallax offset (px) — the frame rotates, the screen content
65   * shifts the opposite way, which is the strongest "depth" cue for a flat
66   * mockup ("looking into" the phone). */
67  var setP  = function (el, x, y)  { el.style.setProperty('--mfx-px', x.toFixed(1) + 'px'); el.style.setProperty('--mfx-py', y.toFixed(1) + 'px'); };
68
69  /* Punchy base poses + wide amplitudes so the 3D reads strongly on a phone.
70   * Combined with the lowered perspective (see injected CSS) and the inner
71   * parallax, even a small device tilt produces a clear, deep 3D shift.
72   * The CSS fallbacks below mirror the base poses. */
73  var RECIPES = [
74    { sel: '#fomDev',               apply: function (el, nx, ny) { setRX(el, 3 - ny * 17); setRY(el, -7 + nx * 19); setP(el, -nx * 10, ny * 10); } },
75    { sel: '.prob__card',           apply: function (el, nx, ny) { setRX(el, -7 - ny * 10); setRY(el, 9 + nx * 14); } },
76    { sel: '.eco3d__scene',         apply: function (el, nx, ny) { setRX(el, 14 - ny * 10); setRY(el, nx * 16); } },
77    { sel: '.clog__shot[data-tilt]',apply: function (el, nx, ny) { setRX(el, -ny * 12);    setRY(el, -6 + nx * 13); } },
78    { sel: '.cph',                  apply: function (el, nx, ny) { setRX(el, 4 - ny * 17); setRY(el, -8 + nx * 19); setP(el, -nx * 9, ny * 9); } },
79    { sel: '.vs-col',               apply: function (el, nx, ny) {
80        setRX(el, -ny * 9); setRY(el, -5 + nx * 11);
81        el.style.setProperty('--mx', ((nx * 0.5 + 0.5) * 100).toFixed(1) + '%');
82        el.style.setProperty('--my', ((ny * 0.5 + 0.5) * 100).toFixed(1) + '%');
83      } },
84    /* platform hero stack — the 3 layers (flattened to a tappable column on
85     * mobile) now read as a cohesive set of 3D cards leaning back together. */
86    { sel: '.slab',                 apply: function (el, nx, ny) { setRX(el, -7 - ny * 9); setRY(el, nx * 13); } },
87    /* spine constellation (home + platform) — reuse the hub's own --hrx/--hry so
88     * the same radial 3D the desktop pointer drives now follows finger-drag + tilt. */
89    { sel: '.hub__scene',           apply: function (el, nx, ny) {
90        el.style.setProperty('--hry', (nx * 17).toFixed(2) + 'deg');
91        el.style.setProperty('--hrx', (-ny * 13).toFixed(2) + 'deg');
92      } }
93  ];
94
95  var TARGETS = [];
96  RECIPES.forEach(function (r) {
97    var els = document.querySelectorAll(r.sel);
98    for (var i = 0; i < els.length; i++) {
99      TARGETS.push({ el: els[i], apply: r.apply, vis: true, drag: false, blend: 0, mx: 0, my: 0 });
100    }
101  });
102
103  if (!TARGETS.length) return; // page has no tiltable effects — haptics already wired
104
105  docEl.classList.add('mfx-on');
106  injectCSS();
107
108  /* ====================== GYRO (deviceorientation) ==================== */
109  var gx = 0, gy = 0;        // smoothed target from device tilt, [-1, 1]
110  var baseB = null, baseG = null;
111  var RANGE = 16;            // degrees of tilt for full deflection (lower = more sensitive)
112  function onOrient(e) {
113    if (e.beta === null || e.gamma === null) return;
114    var ang = (window.screen && screen.orientation && screen.orientation.angle != null)
115      ? screen.orientation.angle : (window.orientation || 0);
116    var b = e.beta, g = e.gamma, ax, ay;
117    if (ang === 90)               { ax = b;  ay = -g; }
118    else if (ang === -90 || ang === 270) { ax = -b; ay = g; }
119    else                          { ax = g;  ay = b; }   // portrait
120    if (baseG === null) {                                 // auto-calibrate to how it's held
121      baseG = ax; baseB = ay;
122      docEl.classList.add('mfx-gyro-live');
123      hidePrime();
124    }
125    gx = clamp((ax - baseG) / RANGE, -1, 1);
126    gy = clamp((ay - baseB) / RANGE, -1, 1);
127    if (!raf && (Math.abs(gx - curX) > WAKE || Math.abs(gy - curY) > WAKE)) wake();
128  }
129  var gyroOn = false;
130  function startGyro() {
131    if (gyroOn) return; gyroOn = true;
132    try { window.addEventListener('deviceorientation', onOrient, true); } catch (e) {}
133  }
134
135  /* Remember a granted motion permission across visits, so a return visit never
136     re-prompts — and never even shows the pill. */
137  var GRANT_KEY = 'mfx-motion-ok';
138  function rememberedGrant() { try { return localStorage.getItem(GRANT_KEY) === '1'; } catch (e) { return false; } }
139  function rememberGrant()   { try { localStorage.setItem(GRANT_KEY, '1'); } catch (e) {} }
140
141  /* iOS 13+ needs DeviceOrientationEvent.requestPermission() from a gesture. */
142  function needsPerm() {
143    return typeof DeviceOrientationEvent !== 'undefined' &&
144           typeof DeviceOrientationEvent.requestPermission === 'function';
145  }
146  function requestGyro() {
147    if (needsPerm()) {
148      DeviceOrientationEvent.requestPermission()
149        .then(function (s) { if (s === 'granted') { rememberGrant(); startGyro(); } hidePrime(); })
150        .catch(function () {});
151    } else { startGyro(); }
152  }
153
154  /* The "Tilt for 3D" pill is shown lazily — only when a tiltable module is
155     scrolled into view (see IntersectionObserver below). On iOS, motion
156     permission is requested ONLY when the user taps that pill — never on an
157     incidental tap or scroll. Platforms that don't gate motion start directly. */
158  var wantPrime = needsPerm();
159  var primeDone = false;   // user tapped the pill (granted OR denied) → never offer again
160  var engaged = false;     // user has started scrolling → ok to surface the hint
161  if (!wantPrime) {
162    startGyro();                                   // no permission gate (Android etc.)
163  } else {
164    /* iOS. requestPermission() is NEVER called except from a tap on the pill.
165       If motion was granted before, optimistically add the listener now (this is
166       NOT a permission request): on an iOS that delivers motion to an already-
167       granted page, the gyro goes live and the pill never appears. Otherwise the
168       pill shows as usual — and tapping it is the only thing that ever asks. */
169    if (rememberedGrant()) startGyro();
170    addEventListener('scroll', function onFirstScroll() {
171      engaged = true;
172      removeEventListener('scroll', onFirstScroll);
173      syncPrime();
174    }, { passive: true });
175  }
176
177  /* ===================== TOUCH-DRAG (grab to tilt) =================== */
178  /* Touching a 3D element lets the finger drive its tilt (like the desktop
179   * cursor); on release it eases back to the ambient gyro pose. Passive —
180   * never blocks scrolling. */
181  function localVec(el, touch) {
182    var r = el.getBoundingClientRect();
183    var px = (touch.clientX - r.left) / r.width;
184    var py = (touch.clientY - r.top) / r.height;
185    return { nx: clamp((px - 0.5) * 2, -1.3, 1.3), ny: clamp((py - 0.5) * 2, -1.3, 1.3) };
186  }
187  TARGETS.forEach(function (t) {
188    t.el.addEventListener('touchstart', function (e) {
189      if (!e.touches || !e.touches[0]) return;
190      var v = localVec(t.el, e.touches[0]);
191      t.mx = v.nx; t.my = v.ny; t.blend = 1; t.drag = true; buzz(5); wake();
192    }, { passive: true });
193    t.el.addEventListener('touchmove', function (e) {
194      if (!t.drag || !e.touches || !e.touches[0]) return;
195      var v = localVec(t.el, e.touches[0]);
196      t.mx = v.nx; t.my = v.ny; wake();
197    }, { passive: true });
198    var rel = function () { t.drag = false; };
199    t.el.addEventListener('touchend', rel, { passive: true });
200    t.el.addEventListener('touchcancel', rel, { passive: true });
201  });
202
203  /* Decide whether the hint shows:
204   *   • not tapped + permission not granted + ≥1 3D module in view → show it,
205   *     pinned at bottom-centre (it stays put; it does NOT track any module).
206   *   • no 3D module in view → fade out.
207   *   • tapped — granted OR denied (primeDone) → gone for good.
208   *   • motion live → gone for good.
209   * The hero on first load doesn't pop; it waits for the first scroll. */
210  function firstVisibleTarget() {
211    for (var i = 0; i < TARGETS.length; i++) if (TARGETS[i].vis) return TARGETS[i].el;
212    return null;
213  }
214  function syncPrime() {
215    if (!wantPrime || primeDone || docEl.classList.contains('mfx-gyro-live')) {
216      if (primeEl) hidePrime();
217      return;
218    }
219    if (firstVisibleTarget()) {
220      if (!engaged) return;                            // don't pop until the user scrolls
221      if (!primeEl) buildPrime();                      // a 3D module is in view → show (pinned)
222    } else if (primeEl) {
223      hidePrime();                                     // no 3D module in view → dismiss
224    }
225  }
226
227  /* Only animate on-screen targets, and re-evaluate the hint on every change. */
228  if ('IntersectionObserver' in window) {
229    var io = new IntersectionObserver(function (es) {
230      es.forEach(function (en) {
231        for (var i = 0; i < TARGETS.length; i++) {
232          if (TARGETS[i].el === en.target) { TARGETS[i].vis = en.isIntersecting; break; }
233        }
234      });
235      syncPrime();
236      wake();                               // a target scrolled in → apply its current pose once
237    }, { rootMargin: '12% 0px' });
238    TARGETS.forEach(function (t) { io.observe(t.el); });
239  }
240
241  /* ============================== LOOP =============================== */
242  /* IDLE-OUT (battery/heat): the rAF only runs while something is actually
243     moving — settling toward a new gyro pose, or a finger drag/blend in flight.
244     When the phone is still and untouched, it stops scheduling frames entirely,
245     so a page left open on a table costs ~0 CPU/GPU instead of compositing
246     6-9 tilted 3D layers at 60fps forever. It re-arms on a real tilt (past
247     sensor noise), a touch, a scroll-into-view, or tab-visible. */
248  var curX = 0, curY = 0, raf = 0;
249  var SETTLE = 0.0016;   // |gyro target − current| below this ⇒ arrived (stop)
250  var WAKE   = 0.04;     // gyro must move at least this (beyond still-phone noise) to re-arm
251  function anyVis() { for (var i = 0; i < TARGETS.length; i++) if (TARGETS[i].vis) return true; return false; }
252  function frame() {
253    curX += (gx - curX) * 0.14;            // smooth the ambient gyro pose (snappier)
254    curY += (gy - curY) * 0.14;
255    var moving = false;
256    for (var i = 0; i < TARGETS.length; i++) {
257      var t = TARGETS[i];
258      if (!t.vis) continue;
259      var nx = curX, ny = curY;
260      if (t.blend > 0) {                    // blend finger override over gyro
261        nx = curX + (t.mx - curX) * t.blend;
262        ny = curY + (t.my - curY) * t.blend;
263        if (!t.drag) { t.blend *= 0.90; if (t.blend < 0.01) t.blend = 0; }
264      }
265      t.apply(t.el, nx, ny);
266      if (t.drag || t.blend > 0) moving = true;
267    }
268    if (Math.abs(gx - curX) > SETTLE || Math.abs(gy - curY) > SETTLE) moving = true;
269    if (moving && !document.hidden) raf = requestAnimationFrame(frame);
270    else raf = 0;                           // settled & untouched → stop ticking
271  }
272  function play()  { if (!raf && !document.hidden) raf = requestAnimationFrame(frame); }
273  function pause() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
274  function wake()  { if (anyVis()) play(); }   // re-arm only if a 3D module is on-screen
275  document.addEventListener('visibilitychange', function () { document.hidden ? pause() : wake(); });
276  play();
277
278  /* ============================ PRIME PILL =========================== */
279  /* The hint is pinned at a fixed spot (bottom-centre of the viewport, via CSS)
280     so it never moves while you scroll. It simply appears whenever any 3D module
281     is in view and fades out once none are — no per-scroll tracking, no jumping
282     between modules. */
283  var primeEl = null;
284  function buildPrime() {
285    if (primeEl) return;
286    var b = document.createElement('button');
287    b.type = 'button';
288    b.className = 'mfx-prime';
289    b.setAttribute('aria-label', 'Enable motion-driven 3D');
290    b.innerHTML = '<span class="mfx-prime__ico" aria-hidden="true">' +
291      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">' +
292      '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M12 6v4l3 1.5"/></svg></span>' +
293      '<span>Tilt for 3D</span>';
294    b.addEventListener('click', function (e) { e.stopPropagation(); primeDone = true; hidePrime(); requestGyro(); });
295    (document.body || docEl).appendChild(b);
296    primeEl = b;
297  }
298  function hidePrime() {
299    if (!primeEl) return;
300    var el = primeEl; primeEl = null;
301    el.classList.add('mfx-prime--out');
302    setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 420);
303  }
304
305  /* ========================== INJECTED CSS ========================== */
306  function injectCSS() {
307    if (document.getElementById('mfx-css')) return;
308    var s = document.createElement('style');
309    s.id = 'mfx-css';
310    s.textContent =
311      /* Re-enable the 3D the mobile breakpoints flatten, scoped to mfx-on and
312         driven by --mfx-rx / --mfx-ry. Higher specificity + !important so it
313         beats the `transform:none!important` mobile rules. */
314      'html.mfx-on #fomDev{transform:perspective(680px) translateZ(20px) rotateX(var(--mfx-rx,3deg)) rotateY(var(--mfx-ry,-7deg))!important;transition:transform .09s linear;will-change:transform;backface-visibility:hidden}' +
315      'html.mfx-on #fomDev .scr img{transform:scale(1.13) translate(var(--mfx-px,0px),var(--mfx-py,0px))!important;transition:transform .09s linear;will-change:transform}' +
316      'html.mfx-on .prob__media{perspective:760px!important}' +
317      'html.mfx-on .prob__card{transform:rotateX(var(--mfx-rx,-7deg)) rotateY(var(--mfx-ry,9deg))!important;transition:transform .11s linear;animation:none!important;will-change:transform}' +
318      'html.mfx-on .eco3d{perspective:1000px!important}' +
319      'html.mfx-on .eco3d__scene{transform:rotateX(var(--mfx-rx,
31914deg)) rotateY(var(--mfx-ry,0deg))!important;transition:transform .14s linear;will-change:transform}' +
320      'html.mfx-on .clog__shot[data-tilt]{transform:perspective(640px) rotateX(var(--mfx-rx,0deg)) rotateY(var(--mfx-ry,-6deg))!important;transition:transform .11s linear;will-change:transform}' +
321      'html.mfx-on .cph{transform:perspective(700px) translateZ(16px) rotateX(var(--mfx-rx,4deg)) rotateY(var(--mfx-ry,-8deg))!important;transition:transform .1s linear;animation:none!important;will-change:transform}' +
322      'html.mfx-on .cph .cph__s img{transform:scale(1.11) translate(var(--mfx-px,0px),var(--mfx-py,0px))!important;transition:transform .1s linear;will-change:transform}' +
323      'html.mfx-on .vs-col{transform:perspective(820px) rotateX(var(--mfx-rx,0deg)) rotateY(var(--mfx-ry,-5deg))!important;transition:transform .13s linear;will-change:transform}' +
324      'html.mfx-on .vs-col::after{display:block!important;opacity:.55!important}' +
325      'html.mfx-on .stk__scene{perspective:1100px!important;perspective-origin:50% 40%}' +
326      'html.mfx-on .slab{transform:perspective(880px) rotateX(var(--mfx-rx,-7deg)) rotateY(var(--mfx-ry,0deg))!important;transition:transform .12s linear;will-change:transform;backface-visibility:hidden}' +
327      /* Prime pill */
328      /* Pinned at bottom-centre of the viewport so it never moves on scroll;
329         transform only handles the horizontal centring + entrance/exit. */
330      '.mfx-prime{position:fixed;left:50%;bottom:calc(22px + env(safe-area-inset-bottom,0px));top:auto;transform:translateX(-50%);z-index:120;' +
331        'display:inline-flex;align-items:center;gap:7px;padding:9px 15px 9px 11px;border-radius:999px;border:1px solid rgba(120,150,255,.34);' +
332        'background:color-mix(in srgb, var(--bg, #0b0f1a) 78%, transparent);backdrop-filter:blur(12px) saturate(1.3);-webkit-backdrop-filter:blur(12px) saturate(1.3);' +
333        'color:var(--text,#eef);font:600 13px/1 var(--font-ui,system-ui),system-ui,sans-serif;letter-spacing:.01em;' +
334        'box-shadow:0 10px 30px -12px rgba(30,60,160,.6);cursor:pointer;animation:mfxPrimeIn .5s cubic-bezier(.2,.8,.2,1) both;-webkit-tap-highlight-color:transparent}' +
335      '.mfx-prime__ico{display:inline-flex;width:18px;height:18px;color:var(--blue-400,#6f9bff);animation:mfxTilt 2.6s ease-in-out infinite}' +
336      '.mfx-prime__ico svg{width:18px;height:18px}' +
337      '.mfx-prime--out{animation:mfxPrimeOut .4s ease forwards}' +
338      '@keyframes mfxPrimeIn{from{opacity:0;transform:translateX(-50%) translateY(12px) scale(.96)}to{opacity:1;transform:translateX(-50%) translateY(0) scale(1)}}' +
339      '@keyframes mfxPrimeOut{to{opacity:0;transform:translateX(-50%) translateY(10px) scale(.96)}}' +
340      '@keyframes mfxTilt{0%,100%{transform:rotate(-12deg)}50%{transform:rotate(12deg)}}' +
341      '@media (prefers-reduced-motion: reduce){.mfx-prime,.mfx-prime__ico{animation:none}}';
342    (document.head || docEl).appendChild(s);
343  }
344})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.