MediaWiki:Common.js: Difference between revisions

From Skateboarding on the Pacific Ocean
 
No edit summary
 
Line 1: Line 1:
/**
/**
  * =============================================================
  * Willoughby Tucker, I'll Always Love You — MediaWiki Skin JS
*  PREACHER'S DAUGHTER — MediaWiki Wiki Theme JS
  * Ethel Cain, 2025
  * Based on the 2022 album by Ethel Cain
  *
  *  Southern Gothic · Dark Americana · Sacred & Profane
  * Load via MediaWiki:Common.js or skin template
  * =============================================================
  * Requires: vanilla JS, no dependencies
  *  Install: Paste into MediaWiki:Common.js
  *
  *  Requires: PreachersDaughter.css (MediaWiki:Common.css)
  * Effects:
  * =============================================================
*  1. Amber loading bar
*  2. Dust particle system
  *  3. Magnetic cursor (dot + ring)
*  4. Scroll-reveal observer
*  5. Sidebar link ripples
*  6. Title ambient pulse
*  7. Search placeholder cycling
*  8. VHS glitch on title
*  9. Table row glow
  * 10. Fog parallax on sidebar
  */
  */


( function () {
/* ═══════════════════════════════════════════════════════
  'use strict';
  JS EFFECTS
  ═══════════════════════════════════════════════════════ */


  /* ── CONFIG ─────────────────────────────────────────────── */
/* 1. Amber loading bar */
   var CFG = {
(function() {
    dust: {
   const bar = document.getElementById('loading-bar');
      enabled:    true,
   if (!bar) return;
      count:      28,        /* live particles on screen        */
   let w = 0;
      minSize:    1,          /* px                              */
   const iv = setInterval(() => {
      maxSize:    3.5,        /* px                              */
    w += Math.random() * 18 + 4;
      minSpeed:   0.18,      /* px / frame                      */
     if (w >= 95) { clearInterval(iv); w = 95; }
      maxSpeed:   0.55,
    bar.style.width = w + '%';
      minDrift:   -0.25,      /* horizontal drift px/frame      */
  }, 80);
      maxDrift:    0.35,
  window.addEventListener('load', () => {
      colors:     ['rgba(224,200,160,{a})', 'rgba(160,80,80,{a})', 'rgba(255,240,210,{a})']
     clearInterval(iv);
    },
     bar.style.width = '100%';
    flicker: {
     setTimeout(() => { bar.style.opacity = '0'; bar.style.transition = 'opacity 0.5s'; }, 300);
      enabled:    true,
  });
      heading:    true,      /* animate page heading            */
})();
      interval:  [4000, 14000] /* random ms between flickers  */
     },
     reveal: {
      enabled:    true,
      rootMargin: '0px 0px -60px 0px'
     },
    watermark: {
      enabled:    true,
      char:      ''
    },
    candle: {
      enabled:    true        /* ambient glow pulse on toc/sidebar */
    },
    typewriter: {
      enabled:    false      /* set true to typewrite the main heading */
    }
  };


  /* ── UTILITY ─────────────────────────────────────────────── */
/* 2. Dust particle system */
  function rand( min, max ) { return Math.random() * ( max - min ) + min; }
(function() {
   function randInt( min, max ) { return Math.floor( rand( min, max + 1 ) ); }
  const canvas = document.getElementById('dust-canvas');
   function qs( sel, ctx ) { return ( ctx || document ).querySelector( sel ); }
   if (!canvas) return;
   function qsa( sel, ctx ) { return Array.from( ( ctx || document ).querySelectorAll( sel ) ); }
   const ctx = canvas.getContext('2d');
   let W, H, particles = [];


  /* ── 1. WATERMARK CROSS ──────────────────────────────────── */
   function resize() {
   function initWatermark() {
     W = canvas.width = window.innerWidth;
     if ( !CFG.watermark.enabled ) return;
     H = canvas.height = window.innerHeight;
    var el = document.createElement( 'div' );
     el.id = 'pd-watermark';
    el.setAttribute( 'aria-hidden', 'true' );
    el.textContent = CFG.watermark.char;
    document.body.appendChild( el );
   }
   }


   /* ── 2. DUST / ASH PARTICLE SYSTEM ──────────────────────── */
   function createParticle() {
  function initDust() {
    return {
    if ( !CFG.dust.enabled ) return;
      x: Math.random() * W,
      y: H + Math.random() * 20,
      vx: (Math.random() - 0.5) * 0.4,
      vy: -(Math.random() * 0.6 + 0.1),
      size: Math.random() * 2 + 0.3,
      opacity: Math.random() * 0.5 + 0.1,
      life: 0,
      maxLife: Math.random() * 600 + 200,
      hue: Math.random() * 20 + 25
    };
  }


     var canvas = document.createElement( 'canvas' );
  for (let i = 0; i < 80; i++) {
     canvas.id  = 'pd-dust-canvas';
     const p = createParticle();
     canvas.setAttribute( 'aria-hidden', 'true' );
     p.y = Math.random() * H;
     document.body.appendChild( canvas );
     p.life = Math.random() * p.maxLife;
     particles.push(p);
  }


     var ctx   = canvas.getContext( '2d' );
  function draw() {
    var W, H;
     ctx.clearRect(0, 0, W, H);
     var particles = [];
     for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.x += p.vx + Math.sin(p.life * 0.01) * 0.15;
      p.y += p.vy;
      p.life++;


    function resize() {
      const fade = p.life < 60 ? p.life / 60 : p.life > p.maxLife - 60 ? (p.maxLife - p.life) / 60 : 1;
       W = canvas.width  = window.innerWidth;
       ctx.beginPath();
       H = canvas.height = window.innerHeight;
       ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    }
      ctx.fillStyle = `hsla(${p.hue}, 55%, 60%, ${p.opacity * fade})`;
    window.addEventListener( 'resize', resize, { passive: true } );
      ctx.fill();
    resize();


    function makeParticle( initial ) {
       if (p.life >= p.maxLife || p.y < -10) {
       var colorTpl = CFG.dust.colors[ randInt( 0, CFG.dust.colors.length - 1 ) ];
         particles[i] = createParticle();
      var alpha    = rand( 0.25, 0.75 );
       }
      var color    = colorTpl.replace( '{a}', alpha.toFixed( 2 ) );
      return {
        x:    rand( 0, W ),
        y:    initial ? rand( 0, H ) : rand( -20, -2 ),
         r:    rand( CFG.dust.minSize, CFG.dust.maxSize ),
        vy:    rand( CFG.dust.minSpeed, CFG.dust.maxSpeed ),
        vx:    rand( CFG.dust.minDrift, CFG.dust.maxDrift ),
        alpha: alpha,
        color: color,
        rot:  rand( 0, Math.PI * 2 ),
        rspd:  rand( -0.01, 0.01 )
       };
    }
 
    for ( var i = 0; i < CFG.dust.count; i++ ) {
      particles.push( makeParticle( true ) );
     }
     }


     function draw() {
     // Occasional larger dust mote
      ctx.clearRect( 0, 0, W, H );
    if (Math.random() < 0.01 && particles.length < 120) {
 
      const mote = createParticle();
      particles.forEach( function ( p, idx ) {
      mote.size = Math.random() * 3 + 1.5;
        p.y  += p.vy;
      mote.opacity = 0.08;
        p.x  += p.vx + Math.sin( p.y * 0.008 + idx ) * 0.18;
      mote.vy = -(Math.random() * 0.2 + 0.05);
        p.rot += p.rspd;
      particles.push(mote);
 
        if ( p.y > H + 10 ) {
          particles[ idx ] = makeParticle( false );
          return;
        }
 
        ctx.save();
        ctx.translate( p.x, p.y );
        ctx.rotate( p.rot );
        ctx.fillStyle = p.color;
        ctx.beginPath();
        /* small elongated flake */
        ctx.ellipse( 0, 0, p.r * 0.55, p.r, 0, 0, Math.PI * 2 );
        ctx.fill();
        ctx.restore();
      } );
 
      requestAnimationFrame( draw );
     }
     }


     requestAnimationFrame( draw );
     requestAnimationFrame(draw);
  }
 
  /* ── 3. CANDLE FLICKER (heading glow) ────────────────────── */
  function initFlicker() {
    if ( !CFG.flicker.enabled || !CFG.flicker.heading ) return;
 
    var heading = qs( '#firstHeading, .mw-first-heading, .page-header h1' );
    if ( !heading ) return;
 
    heading.classList.add( 'pd-flicker' );
 
    /* Random additional intensity bursts */
    function scheduleBurst() {
      var delay = rand( CFG.flicker.interval[0], CFG.flicker.interval[1] );
      setTimeout( function () {
        heading.style.transition = 'text-shadow 0.05s ease';
        heading.style.textShadow =
          '0 0 50px rgba(200,50,50,0.75), 0 0 10px rgba(255,160,100,0.3), 0 2px 4px rgba(0,0,0,.9)';
        setTimeout( function () {
          heading.style.textShadow = '';
          heading.style.transition = '';
          scheduleBurst();
        }, rand( 60, 180 ) );
      }, delay );
    }
    scheduleBurst();
   }
   }


   /* ── 4. SCROLL REVEAL ────────────────────────────────────── */
   resize();
   function initReveal() {
   window.addEventListener('resize', resize);
    if ( !CFG.reveal.enabled ) return;
  draw();
    if ( typeof IntersectionObserver === 'undefined' ) return;
})();


    var targets = qsa(
/* 3. Magnetic cursor */
      '.mw-body-content h2, .mw-body-content h3, ' +
(function() {
      '.mw-body-content p, .mw-body-content .thumb, ' +
  const dot = document.getElementById('cursor-dot');
      '.mw-body-content table, .mw-body-content blockquote, ' +
  const ring = document.getElementById('cursor-ring');
      '.mw-body-content ul, .mw-body-content ol'
  if (!dot || !ring) return;
    );


    var seen = new Set();
  let mx = 0, my = 0, rx = 0, ry = 0;
  let entered = false;


    var obs = new IntersectionObserver( function ( entries ) {
  document.addEventListener('mouseenter', () => {
      entries.forEach( function ( entry ) {
    entered = true;
        if ( entry.isIntersecting && !seen.has( entry.target ) ) {
    dot.style.opacity = '1';
          seen.add( entry.target );
    ring.style.opacity = '1';
          entry.target.classList.add( 'pd-reveal' );
  });
          obs.unobserve( entry.target );
        }
      } );
    }, { rootMargin: CFG.reveal.rootMargin } );


    /* Pre-set opacity so reveal is visible */
  document.addEventListener('mouseleave', () => {
    targets.forEach( function ( el ) {
    entered = false;
      if ( el.getBoundingClientRect().top > window.innerHeight * 0.3 ) {
    dot.style.opacity = '0';
        el.style.opacity = '0';
    ring.style.opacity = '0';
        obs.observe( el );
  });
      }
    } );
  }


   /* ── 5. CANDLE AMBIENT PULSE ─────────────────────────────── */
   document.addEventListener('mousemove', e => {
  function initCandlePulse() {
     mx = e.clientX; my = e.clientY;
     if ( !CFG.candle.enabled ) return;
     dot.style.left = mx + 'px';
 
    dot.style.top  = my + 'px';
     var targets = qsa( '#toc, .toc, .infobox, table.infobox, #content, .mw-body' );
     if (!entered) { dot.style.opacity = '1'; ring.style.opacity = '0.7'; entered = true; }
     targets.forEach( function ( el ) {
  });
      el.classList.add( 'pd-pulse-border' );
    } );
  }


   /* ── 6. ORNATE SECTION DIVIDERS ──────────────────────────── */
   // Links make cursor expand
   function initDividers() {
   document.querySelectorAll('a, button').forEach(el => {
     /* Insert a decorative vine divider after every h2 */
     el.addEventListener('mouseenter', () => {
    var ornament = '<span class="pd-ornament" aria-hidden="true" ' +
      ring.style.width  = '44px';
       'style="display:block;text-align:center;font-size:0.75rem;' +
       ring.style.height = '44px';
       'color:rgba(122,28,36,0.45);letter-spacing:0.6em;' +
       ring.style.borderColor = 'rgba(200,132,58,0.8)';
       'margin:-0.2rem 0 0.8rem;font-family:serif;">' +
      dot.style.transform = 'translate(-50%,-50%) scale(1.8)';
       '— ✦ —' +
    });
       '</span>';
    el.addEventListener('mouseleave', () => {
       ring.style.width  = '28px';
      ring.style.height = '28px';
       ring.style.borderColor = 'rgba(200,132,58,0.4)';
       dot.style.transform = 'translate(-50%,-50%) scale(1)';
    });
  });


    qsa( '.mw-body-content h2' ).forEach( function ( h ) {
  // Smooth ring follow
      h.insertAdjacentHTML( 'afterend', ornament );
  function lerp(a, b, t) { return a + (b - a) * t; }
     } );
  function animate() {
    rx = lerp(rx, mx, 0.12);
    ry = lerp(ry, my, 0.12);
    ring.style.left = rx + 'px';
    ring.style.top  = ry + 'px';
     requestAnimationFrame(animate);
   }
   }
  animate();
})();


  /* ── 7. TYPEWRITER HEADING (opt-in) ──────────────────────── */
/* 4. Scroll-reveal observer */
  function initTypewriter() {
(function() {
    if ( !CFG.typewriter.enabled ) return;
  const els = document.querySelectorAll('.scroll-reveal');
 
  const obs = new IntersectionObserver(entries => {
    var heading = qs( '#firstHeading, .mw-first-heading' );
     entries.forEach(e => {
    if ( !heading ) return;
       if (e.isIntersecting) {
 
         e.target.classList.add('revealed');
    var originalText = heading.textContent.trim();
         obs.unobserve(e.target);
    heading.textContent = '';
    heading.style.opacity = '1';
 
    var i = 0;
     var cursor = document.createElement( 'span' );
    cursor.textContent = '|';
    cursor.style.cssText =
      'color:rgba(160,43,55,0.8);animation:pd-flickerB 1.1s ease-in-out infinite;';
    heading.appendChild( cursor );
 
    var interval = setInterval( function () {
       if ( i < originalText.length ) {
         cursor.insertAdjacentText( 'beforebegin', originalText.charAt( i ) );
         i++;
      } else {
        clearInterval( interval );
        setTimeout( function () { cursor.remove(); }, 600 );
       }
       }
     }, 55 );
     });
   }
  }, { threshold: 0.12 });
   els.forEach(el => obs.observe(el));
})();


  /* ── 8. LINK HOVER SOUND (optional — disabled by default) ── */
/* 5. Sidebar link ripple */
  /*
(function() {
  function initHoverSound() {
  document.querySelectorAll('.portal a').forEach(link => {
    // Uncomment if you want subtle audio on link hover
    link.addEventListener('click', function(e) {
    // Requires AudioContext support
      const ripple = document.createElement('span');
    qsa( 'a' ).forEach( function ( a ) {
      ripple.style.cssText = `
      a.addEventListener( 'mouseenter', function () {
        position: absolute;
        try {
        border-radius: 50%;
          var ctx = new AudioContext();
        background: rgba(200,132,58,0.25);
          var osc = ctx.createOscillator();
        width: 6px; height: 6px;
          var gain = ctx.createGain();
        top: 50%; left: ${e.offsetX}px;
          osc.connect( gain );
        transform: translate(-50%,-50%) scale(0);
          gain.connect( ctx.destination );
        animation: ripple-out 0.5s ease forwards;
          osc.frequency.value = 220;
        pointer-events: none;
          gain.gain.setValueAtTime( 0.04, ctx.currentTime );
      `;
          gain.gain.exponentialRampToValueAtTime( 0.0001, ctx.currentTime + 0.25 );
      link.appendChild(ripple);
          osc.start();
      setTimeout(() => ripple.remove(), 500);
          osc.stop( ctx.currentTime + 0.25 );
     });
        } catch ( e ) {}
   });
      } );
     } );
   }
  */


   /* ── 9. REDLINK STYLING ──────────────────────────────────── */
   const style = document.createElement('style');
  function styleRedlinks() {
  style.textContent = `
    qsa( 'a.new' ).forEach( function ( a ) {
     @keyframes ripple-out {
      a.title = ( a.title || '' ) + ' [page not yet written]';
       to { transform: translate(-50%,-50%) scale(20); opacity: 0; }
    } );
     }
  }
   `;
 
   document.head.appendChild(style);
  /* ── 10. SIDEBAR SECTION COLLAPSE ANIMATION ──────────────── */
})();
  function initSidebarAnimations() {
    qsa( '#mw-panel .portal, .vector-menu' ).forEach( function ( portal, idx ) {
      portal.style.opacity = '0';
      portal.style.transform = 'translateX(-8px)';
      portal.style.transition =
        'opacity 0.5s ease ' + ( idx * 0.08 ) + 's, ' +
        'transform 0.5s ease ' + ( idx * 0.08 ) + 's';
      /* Trigger reflow then animate in */
      setTimeout( function () {
        portal.style.opacity = '1';
        portal.style.transform = 'translateX(0)';
      }, 80 );
     } );
  }
 
  /* ── 11. TABLE ROW STAGGER ───────────────────────────────── */
  function initTableStagger() {
    qsa( '.wikitable tr' ).forEach( function ( tr, idx ) {
       tr.style.opacity = '0';
      tr.style.transition = 'opacity 0.4s ease ' + ( idx * 0.04 ) + 's';
      setTimeout( function () {
        tr.style.opacity = '1';
      }, 200 + idx * 40 );
    } );
  }
 
  /* ── 12. CROSS CURSOR TRAIL ──────────────────────────────── */
  function initCursorTrail() {
    /* Subtle: drops tiny crimson sparks on fast mouse move    */
    var lastX = 0, lastY = 0, moving = false, timer;
 
    document.addEventListener( 'mousemove', function ( e ) {
      var dx = e.clientX - lastX;
      var dy = e.clientY - lastY;
      var dist = Math.sqrt( dx * dx + dy * dy );
 
      if ( dist < 12 ) return; /* only on fast moves */
 
      lastX = e.clientX;
      lastY = e.clientY;
 
      var spark = document.createElement( 'div' );
      spark.setAttribute( 'aria-hidden', 'true' );
      spark.style.cssText =
        'position:fixed;' +
        'left:' + e.clientX + 'px;' +
        'top:' + e.clientY + 'px;' +
        'width:4px;height:4px;' +
        'border-radius:50%;' +
        'background:rgba(160,43,55,0.7);' +
        'pointer-events:none;' +
        'z-index:99999;' +
        'transform:translate(-50%,-50%);' +
        'transition:opacity 0.6s ease, transform 0.6s ease;';
 
      document.body.appendChild( spark );
 
      /* Animate out */
      requestAnimationFrame( function () {
        spark.style.opacity = '0';
        spark.style.transform =
          'translate(-50%,-50%) translateY(' + rand( -12, -30 ) + 'px) scale(0.3)';
      } );
 
      setTimeout( function () {
        if ( spark.parentNode ) spark.parentNode.removeChild( spark );
      }, 650 );
     }, { passive: true } );
   }
 
  /* ── 13. PAGE TRANSITION OVERLAY ─────────────────────────── */
   function initPageTransition() {
    /* Fade-out on navigation */
    var overlay = document.createElement( 'div' );
    overlay.setAttribute( 'aria-hidden', 'true' );
    overlay.style.cssText =
      'position:fixed;inset:0;' +
      'background:#0e0a09;' +
      'opacity:0;pointer-events:none;' +
      'z-index:99997;' +
      'transition:opacity 0.3s ease;';
    document.body.appendChild( overlay );
 
    /* Fade in (we arrive on a new page) */
    document.body.style.opacity = '0';
    document.body.style.transition = 'opacity 0.6s ease';
    requestAnimationFrame( function () {
      document.body.style.opacity = '1';
    } );
 
    /* Fade out before leaving */
    document.addEventListener( 'click', function ( e ) {
      var a = e.target.closest( 'a' );
      if ( !a || !a.href || a.target === '_blank' ||
          a.href.startsWith( '#' ) || a.href.startsWith( 'javascript' ) ) return;
      /* Skip edit/action links */
      if ( /[?&]action=/.test( a.href ) && !/action=view/.test( a.href ) ) return;


      overlay.style.pointerEvents = 'all';
/* 6. Ambient audio visualizer hint (subtle amber pulse on #firstHeading) */
      overlay.style.opacity = '1';
(function() {
     } );
  const title = document.getElementById('firstHeading');
  if (!title) return;
  let frame = 0;
  function pulse() {
    frame++;
    const s = 1 + Math.sin(frame * 0.025) * 0.003;
    const g = 0.3 + Math.sin(frame * 0.018) * 0.15;
    title.style.textShadow = `0 2px ${30 + Math.sin(frame*0.02)*20}px rgba(200,132,58,${g})`;
     requestAnimationFrame(pulse);
   }
   }
  pulse();
})();


  /* ── 14. QUOTE PULL — RANDOM TRACK EPIGRAPH ──────────────── */
/* 7. Search bar typewriter placeholder cycling */
   var EPIGRAPHS = [
(function() {
     '"I was baptized but I still die." — Family Tree',
   const input = document.getElementById('searchInput');
     '"God only listens to the men around here." — Gibson Girl',
  if (!input) return;
     '"Nothing happened here except everything." — Thoroughfare',
  const phrases = [
     '"Hallelujah, hallelujah, come back to me." — Strangers',
     'search the dust…',
     '"Sometimes I swear I can still hear the choir." — Hard Road',
     'find Willoughby…',
     '"Do not mistake her softness for surrender." — American Teenager'
     'janie, please stay…',
     'nettles and ghosts…',
     'waco, texas, 1986…',
     'i\'ll always love you…'
   ];
   ];
  let i = 0;
  setInterval(() => {
    if (document.activeElement !== input) {
      i = (i + 1) % phrases.length;
      input.placeholder = phrases[i];
    }
  }, 3200);
})();


  function initEpigraph() {
/* 8. VHS glitch flicker on title — rare, occasional */
    var sidebar = qs( '#mw-panel, .vector-sidebar-toc-container, #vector-toc-collapsed-button' );
(function() {
    if ( !sidebar ) return;
  const title = document.getElementById('firstHeading');
 
  if (!title) return;
     var quote = EPIGRAPHS[ randInt( 0, EPIGRAPHS.length - 1 ) ];
  function maybeGlitch() {
 
     if (Math.random() < 0.3) {
    var el = document.createElement( 'div' );
      title.style.transition = 'none';
    el.setAttribute( 'aria-label', 'Album epigraph' );
      title.style.transform = `translateX(${(Math.random()-0.5)*3}px)`;
    el.style.cssText =
      title.style.filter = 'brightness(1.3) saturate(0.5)';
      'margin: 1.5rem 0.8rem;' +
       setTimeout(() => {
       'padding: 0.8rem 1rem;' +
        title.style.transform = 'translateX(0)';
      'border-left: 2px solid rgba(122,28,36,0.5);' +
        title.style.filter = '';
      'font-family: "IM Fell English", Georgia, serif;' +
       }, 60 + Math.random() * 80);
      'font-style: italic;' +
     }
      'font-size: 0.82rem;' +
     setTimeout(maybeGlitch, 4000 + Math.random() * 8000);
       'color: rgba(158,139,114,0.8);' +
      'line-height: 1.6;';
     el.textContent = quote;
 
     sidebar.appendChild( el );
   }
   }
  setTimeout(maybeGlitch, 5000);
})();


  /* ── INIT — run after DOM ready ──────────────────────────── */
/* 9. Table row glow on hover */
  function main() {
(function() {
    initWatermark();
  document.querySelectorAll('.wikitable tbody tr').forEach(row => {
    initDust();
     row.addEventListener('mouseenter', () => {
     initFlicker();
      row.style.boxShadow = 'inset 0 0 40px rgba(200,132,58,0.07)';
    initReveal();
     });
    initCandlePulse();
     row.addEventListener('mouseleave', () => {
     initDividers();
      row.style.boxShadow = '';
     initTypewriter();
     });
    styleRedlinks();
  });
    initSidebarAnimations();
})();
     initTableStagger();
    initCursorTrail();
    initPageTransition();
    initEpigraph();


    /* Mark body so CSS can scope theme selectors */
/* 10. Fog drifting parallax on sidebar */
    document.documentElement.classList.add( 'pd-theme' );
(function() {
 
  let ticking = false;
     /* Console signature */
  window.addEventListener('scroll', () => {
    console.log(
     if (!ticking) {
       '%c✝ Preacher\'s Daughter Wiki Theme%c\n' +
       requestAnimationFrame(() => {
      '  Ethel Cain · 2022 · Southern Gothic\n' +
        const offset = window.scrollY * 0.15;
      '  "God is a place you will wait for the rest of your life."',
        const panel = document.getElementById('mw-panel');
      'color:#c03040;font-size:14px;font-weight:bold;',
        if (panel) panel.style.backgroundPositionY = offset + 'px';
      'color:#9e8b72;font-size:11px;'
        ticking = false;
    );
      });
  }
      ticking = true;
 
     }
  if ( document.readyState === 'loading' ) {
   });
    document.addEventListener( 'DOMContentLoaded', main );
})();
  } else {
     main();
   }
 
} )();

Latest revision as of 11:00, 16 April 2026

/**
 * Willoughby Tucker, I'll Always Love You — MediaWiki Skin JS
 * Ethel Cain, 2025
 *
 * Load via MediaWiki:Common.js or skin template
 * Requires: vanilla JS, no dependencies
 *
 * Effects:
 *  1. Amber loading bar
 *  2. Dust particle system
 *  3. Magnetic cursor (dot + ring)
 *  4. Scroll-reveal observer
 *  5. Sidebar link ripples
 *  6. Title ambient pulse
 *  7. Search placeholder cycling
 *  8. VHS glitch on title
 *  9. Table row glow
 * 10. Fog parallax on sidebar
 */

/* ═══════════════════════════════════════════════════════
   JS EFFECTS
   ═══════════════════════════════════════════════════════ */

/* 1. Amber loading bar */
(function() {
  const bar = document.getElementById('loading-bar');
  if (!bar) return;
  let w = 0;
  const iv = setInterval(() => {
    w += Math.random() * 18 + 4;
    if (w >= 95) { clearInterval(iv); w = 95; }
    bar.style.width = w + '%';
  }, 80);
  window.addEventListener('load', () => {
    clearInterval(iv);
    bar.style.width = '100%';
    setTimeout(() => { bar.style.opacity = '0'; bar.style.transition = 'opacity 0.5s'; }, 300);
  });
})();

/* 2. Dust particle system */
(function() {
  const canvas = document.getElementById('dust-canvas');
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  let W, H, particles = [];

  function resize() {
    W = canvas.width = window.innerWidth;
    H = canvas.height = window.innerHeight;
  }

  function createParticle() {
    return {
      x: Math.random() * W,
      y: H + Math.random() * 20,
      vx: (Math.random() - 0.5) * 0.4,
      vy: -(Math.random() * 0.6 + 0.1),
      size: Math.random() * 2 + 0.3,
      opacity: Math.random() * 0.5 + 0.1,
      life: 0,
      maxLife: Math.random() * 600 + 200,
      hue: Math.random() * 20 + 25
    };
  }

  for (let i = 0; i < 80; i++) {
    const p = createParticle();
    p.y = Math.random() * H;
    p.life = Math.random() * p.maxLife;
    particles.push(p);
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.x += p.vx + Math.sin(p.life * 0.01) * 0.15;
      p.y += p.vy;
      p.life++;

      const fade = p.life < 60 ? p.life / 60 : p.life > p.maxLife - 60 ? (p.maxLife - p.life) / 60 : 1;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
      ctx.fillStyle = `hsla(${p.hue}, 55%, 60%, ${p.opacity * fade})`;
      ctx.fill();

      if (p.life >= p.maxLife || p.y < -10) {
        particles[i] = createParticle();
      }
    }

    // Occasional larger dust mote
    if (Math.random() < 0.01 && particles.length < 120) {
      const mote = createParticle();
      mote.size = Math.random() * 3 + 1.5;
      mote.opacity = 0.08;
      mote.vy = -(Math.random() * 0.2 + 0.05);
      particles.push(mote);
    }

    requestAnimationFrame(draw);
  }

  resize();
  window.addEventListener('resize', resize);
  draw();
})();

/* 3. Magnetic cursor */
(function() {
  const dot = document.getElementById('cursor-dot');
  const ring = document.getElementById('cursor-ring');
  if (!dot || !ring) return;

  let mx = 0, my = 0, rx = 0, ry = 0;
  let entered = false;

  document.addEventListener('mouseenter', () => {
    entered = true;
    dot.style.opacity = '1';
    ring.style.opacity = '1';
  });

  document.addEventListener('mouseleave', () => {
    entered = false;
    dot.style.opacity = '0';
    ring.style.opacity = '0';
  });

  document.addEventListener('mousemove', e => {
    mx = e.clientX; my = e.clientY;
    dot.style.left = mx + 'px';
    dot.style.top  = my + 'px';
    if (!entered) { dot.style.opacity = '1'; ring.style.opacity = '0.7'; entered = true; }
  });

  // Links make cursor expand
  document.querySelectorAll('a, button').forEach(el => {
    el.addEventListener('mouseenter', () => {
      ring.style.width  = '44px';
      ring.style.height = '44px';
      ring.style.borderColor = 'rgba(200,132,58,0.8)';
      dot.style.transform = 'translate(-50%,-50%) scale(1.8)';
    });
    el.addEventListener('mouseleave', () => {
      ring.style.width  = '28px';
      ring.style.height = '28px';
      ring.style.borderColor = 'rgba(200,132,58,0.4)';
      dot.style.transform = 'translate(-50%,-50%) scale(1)';
    });
  });

  // Smooth ring follow
  function lerp(a, b, t) { return a + (b - a) * t; }
  function animate() {
    rx = lerp(rx, mx, 0.12);
    ry = lerp(ry, my, 0.12);
    ring.style.left = rx + 'px';
    ring.style.top  = ry + 'px';
    requestAnimationFrame(animate);
  }
  animate();
})();

/* 4. Scroll-reveal observer */
(function() {
  const els = document.querySelectorAll('.scroll-reveal');
  const obs = new IntersectionObserver(entries => {
    entries.forEach(e => {
      if (e.isIntersecting) {
        e.target.classList.add('revealed');
        obs.unobserve(e.target);
      }
    });
  }, { threshold: 0.12 });
  els.forEach(el => obs.observe(el));
})();

/* 5. Sidebar link ripple */
(function() {
  document.querySelectorAll('.portal a').forEach(link => {
    link.addEventListener('click', function(e) {
      const ripple = document.createElement('span');
      ripple.style.cssText = `
        position: absolute;
        border-radius: 50%;
        background: rgba(200,132,58,0.25);
        width: 6px; height: 6px;
        top: 50%; left: ${e.offsetX}px;
        transform: translate(-50%,-50%) scale(0);
        animation: ripple-out 0.5s ease forwards;
        pointer-events: none;
      `;
      link.appendChild(ripple);
      setTimeout(() => ripple.remove(), 500);
    });
  });

  const style = document.createElement('style');
  style.textContent = `
    @keyframes ripple-out {
      to { transform: translate(-50%,-50%) scale(20); opacity: 0; }
    }
  `;
  document.head.appendChild(style);
})();

/* 6. Ambient audio visualizer hint (subtle amber pulse on #firstHeading) */
(function() {
  const title = document.getElementById('firstHeading');
  if (!title) return;
  let frame = 0;
  function pulse() {
    frame++;
    const s = 1 + Math.sin(frame * 0.025) * 0.003;
    const g = 0.3 + Math.sin(frame * 0.018) * 0.15;
    title.style.textShadow = `0 2px ${30 + Math.sin(frame*0.02)*20}px rgba(200,132,58,${g})`;
    requestAnimationFrame(pulse);
  }
  pulse();
})();

/* 7. Search bar typewriter placeholder cycling */
(function() {
  const input = document.getElementById('searchInput');
  if (!input) return;
  const phrases = [
    'search the dust…',
    'find Willoughby…',
    'janie, please stay…',
    'nettles and ghosts…',
    'waco, texas, 1986…',
    'i\'ll always love you…'
  ];
  let i = 0;
  setInterval(() => {
    if (document.activeElement !== input) {
      i = (i + 1) % phrases.length;
      input.placeholder = phrases[i];
    }
  }, 3200);
})();

/* 8. VHS glitch flicker on title — rare, occasional */
(function() {
  const title = document.getElementById('firstHeading');
  if (!title) return;
  function maybeGlitch() {
    if (Math.random() < 0.3) {
      title.style.transition = 'none';
      title.style.transform = `translateX(${(Math.random()-0.5)*3}px)`;
      title.style.filter = 'brightness(1.3) saturate(0.5)';
      setTimeout(() => {
        title.style.transform = 'translateX(0)';
        title.style.filter = '';
      }, 60 + Math.random() * 80);
    }
    setTimeout(maybeGlitch, 4000 + Math.random() * 8000);
  }
  setTimeout(maybeGlitch, 5000);
})();

/* 9. Table row glow on hover */
(function() {
  document.querySelectorAll('.wikitable tbody tr').forEach(row => {
    row.addEventListener('mouseenter', () => {
      row.style.boxShadow = 'inset 0 0 40px rgba(200,132,58,0.07)';
    });
    row.addEventListener('mouseleave', () => {
      row.style.boxShadow = '';
    });
  });
})();

/* 10. Fog drifting parallax on sidebar */
(function() {
  let ticking = false;
  window.addEventListener('scroll', () => {
    if (!ticking) {
      requestAnimationFrame(() => {
        const offset = window.scrollY * 0.15;
        const panel = document.getElementById('mw-panel');
        if (panel) panel.style.backgroundPositionY = offset + 'px';
        ticking = false;
      });
      ticking = true;
    }
  });
})();