1// Canon Wars â Scroll storytelling + custom cursor (v27) 2// No external deps. Vanilla JS, ~3KB minified. 3 4(function() { 5 'use strict'; 6 7 // Bail if user prefers reduced motion 8 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 9 document.documentElement.classList.add('cw-reduced-motion'); 10 return; 11 } 12 13 // === Custom brass cursor === 14 // Only enable on devices with fine pointer (mouse, not touch) 15 if (window.matchMedia('(pointer: fine)').matches && window.innerWidth >= 768) { 16 const cursor = document.createElement('div'); 17 cursor.className = 'cw-cursor'; 18 cursor.innerHTML = '<svg viewBox="0 0 32 32" width="32" height="32"><path fill="currentColor" d="M16 2l1.5 3 3-1 .5 3 3 1.5-1 3 2.5 2-2 2.5 1 3-3 1.5-.5 3-3-1L16 30l-1.5-3-3 1-.5-3-3-1.5 1-3-2.5-2 2.5-2.5-1-3 3-1.5.5-3 3 1zm0 8a6 6 0 100 12 6 6 0 000-12z"/></svg>'; 19 document.body.appendChild(cursor); 20 21 let cursorX = 0, cursorY = 0; 22 let targetX = 0, targetY = 0; 23 24 document.addEventListener('mousemove', (e) => { 25 targetX = e.clientX; 26 targetY = e.clientY; 27 }); 28 29 // Smooth follow 30 function animate() { 31 cursorX += (targetX - cursorX) * 0.18; 32 cursorY += (targetY - cursorY) * 0.18; 33 cursor.style.transform = `translate3d(${cursorX - 16}px, ${cursorY - 16}px, 0)`; 34 requestAnimationFrame(animate); 35 } 36 animate(); 37 38 // Grow cursor over interactive elements 39 const interactive = 'a, button, [role="button"], input, textarea, [data-portal]'; 40 document.addEventListener('mouseover', (e) => { 41 if (e.target.matches && e.target.matches(interactive) || 42 e.target.closest && e.target.closest(interactive)) { 43 cursor.classList.add('cw-cursor--grow'); 44 } 45 }); 46 document.addEventListener('mouseout', (e) => { 47 cursor.classList.remove('cw-cursor--grow'); 48 }); 49 50 // Hide on document leave 51 document.addEventListener('mouseleave', () => cursor.style.opacity = '0'); 52 document.addEventListener('mouseenter', () => cursor.style.opacity = ''); 53 } 54 55 // === Scroll-triggered reveals === 56 if ('IntersectionObserver' in window) { 57 const observer = new IntersectionObserver((entries) => { 58 entries.forEach((entry) => { 59 if (entry.isIntersecting) { 60 entry.target.classList.add('cw-revealed'); 61 observer.unobserve(entry.target); 62 } 63 }); 64 }, { 65 threshold: 0.1, 66 rootMargin: '0px 0px -80px 0px' 67 }); 68 69 // Observe everything that should reveal 70 const reveals = document.querySelectorAll( 71 '.post-card, .cogs-log-card, .section-intro, .post-subscribe-cta, ' + 72 '.post-full-content > h2, .post-full-content > h3, ' + 73 '.archive-heading' 74 ); 75 reveals.forEach((el) => { 76 el.classList.add('cw-reveal'); 77 observer.observe(el); 78 }); 79 } 80 81 // === Hero parallax on scroll (subtle, performant) === 82 const heroCog = document.querySelector('.site-hero'); 83 if (heroCog) { 84 let ticking = false; 85 window.addEventListener('scroll', () => { 86 if (!ticking) { 87 requestAnimationFrame(() => { 88 const scrollY = window.scrollY; 89 if (scrollY < 800) { 90 heroCog.style.setProperty('--cw-scroll-y', `${scrollY * 0.5}px`); 91 } 92 ticking = false; 93 }); 94 ticking = true; 95 } 96 }); 97 } 98 99 // === Smooth anchor scroll === 100 document.querySelectorAll('a[href^="#"]').forEach((anchor) => { 101 anchor.addEventListener('click', (e) => { 102 const id = anchor.getAttribute('href'); 103 if (id === '#' || id.length < 2) return; 104 const target = document.querySelector(id); 105 if (!target) return; 106 e.preventDefault(); 107 target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 108 }); 109 }); 110 111})();
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.