1/* PodView custom-hero motion layer. 2 * 3 * Small, always-shipped, no dependencies. Activates ONLY on a custom hero that 4 * opts in via `data-hero-motion` (an allowlist set server-side in theme_tags.py). 5 * The sandboxed hero blocks never ship JS themselves; this trusted module is the 6 * only script, so there is no block-authored code-execution surface. 7 * 8 * Effects (space-separated tokens): 9 * tilt - the cover image tilts in 3D toward the pointer 10 * parallax - foreground copy drifts opposite the pointer for depth 11 * spotlight - a soft radial highlight follows the pointer 12 * 13 * Respects prefers-reduced-motion and only runs on pointer-capable devices. 14 */ 15(function () { 16 "use strict"; 17 18 var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; 19 var fine = window.matchMedia && window.matchMedia("(pointer: fine)").matches; 20 if (reduce || !fine) return; 21 22 function ready(fn) { 23 if (document.readyState !== "loading") fn(); 24 else document.addEventListener("DOMContentLoaded", fn); 25 } 26 27 function firstBg(hero) { 28 return hero.querySelector('[class$="-bg"], [class*="-art"] img, [class*="-screen"] img, [class*="-photo"] img, [class*="-panel"] img, [class*="-disc"] img'); 29 } 30 31 function initHero(hero) { 32 // Idempotence guard. This module was DOM-ready-only, so re-running it after a 33 // boosted swap would bind a SECOND pointermove/enter/leave set (and insert a 34 // second spotlight span) on every navigation back to a hero page. 35 if (hero.dataset.pvHeroMotionReady) return; 36 hero.dataset.pvHeroMotionReady = "1"; 37 38 var effects = (hero.getAttribute("data-hero-motion") || "").split(/\s+/); 39 var wantTilt = effects.indexOf("tilt") !== -1; 40 var wantParallax = effects.indexOf("parallax") !== -1; 41 var wantSpotlight = effects.indexOf("spotlight") !== -1; 42 43 var art = (wantTilt || wantParallax) ? firstBg(hero) : null; 44 if (art && wantTilt) { 45 art.style.transformStyle = "preserve-3d"; 46 art.style.transition = "transform .18s ease-out"; 47 art.style.willChange = "transform"; 48 } 49 var copy = wantParallax ? hero.querySelector('[class$="-copy"], [class$="-inner"], [class$="-lead"]') : null; 50 if (copy) copy.style.transition = "transform .2s ease-out"; 51 52 if (wantSpotlight) { 53 var glow = document.createElement("span"); 54 glow.className = "pv-hero-spotlight"; 55 glow.style.cssText = 56 "position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0;" + 57 "transition:opacity .3s ease;mix-blend-mode:soft-light;" + 58 "background:radial-gradient(240px circle at var(--mx,50%) var(--my,50%)," + 59 "rgba(255,255,255,.45),transparent 60%)"; 60 if (getComputedStyle(hero).position === "static") hero.style.position = "relative"; 61 hero.insertBefore(glow, hero.firstChild); 62 hero._pvGlow = glow; 63 } 64 65 var raf = 0; 66 var lastX = 0, lastY = 0; 67 function apply() { 68 raf = 0; 69 var px = lastX, py = lastY; // -0.5 .. 0.5 70 if (art && wantTilt) { 71 art.style.transform = "perspective(900px) rotateY(" + (px * 10) + "deg) rotateX(" + (-py * 10) + "deg)"; 72 } 73 if (copy) { 74 copy.style.transform = "translate3d(" + (-px * 14) + "px," + (-py * 10) + "px,0)"; 75 } 76 if (hero._pvGlow) { 77 hero.style.setProperty("--mx", (px + 0.5) * 100 + "%"); 78 hero.style.setProperty("--my", (py + 0.5) * 100 + "%"); 79 } 80 } 81 function onMove(e) { 82 var r = hero.getBoundingClientRect(); 83 lastX = (e.clientX - r.left) / r.width - 0.5; 84 lastY = (e.clientY - r.top) / r.height - 0.5; 85 if (!raf) raf = requestAnimationFrame(apply); 86 } 87 function onEnter() { if (hero._pvGlow) hero._pvGlow.style.opacity = "1"; } 88 function onLeave() { 89 if (hero._pvGlow) hero._pvGlow.style.opacity = "0"; 90 if (art && wantTilt) art.style.transform = ""; 91 if (copy) copy.style.transform = ""; 92 } 93 hero.addEventListener("pointermove", onMove); 94 hero.addEventListener("pointerenter", onEnter); 95 hero.addEventListener("pointerleave", onLeave); 96 } 97 98 function initAll(root) { 99 var scope = root && root.querySelectorAll ? root : document; 100 var heroes = scope.querySelectorAll(".pv-custom-hero[data-hero-motion]"); 101 for (var i = 0; i < heroes.length; i++) initHero(heroes[i]); 102 } 103 104 ready(function () { 105 initAll(document); 106 }); 107 108 // Boosted navigation swaps a new hero in. `historyRestore` matters too: Back 109 // restores cached markup and fires only that event (pv-boost-nav.js clears 110 // data-pv-hero-motion-ready first, so this re-binds rather than no-ops). 111 document.addEventListener("htmx:afterSwap", function () { 112 // Always the document, never `evt.detail.target`. Under an `outerHTML` swap â 113 // which is exactly what the boost contract uses â detail.target is the OLD 114 // element, already detached, so scoping to it queries dead DOM and binds 115 // nothing. The guard attribute makes a document-wide pass cheap and idempotent. 116 initAll(document); 117 }); 118 document.addEventListener("htmx:historyRestore", function () { 119 initAll(document); 120 }); 121})();
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.