MediaWiki:Common.js

From Skateboarding on the Pacific Ocean

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * =============================================================
 *  PREACHER'S DAUGHTER — MediaWiki Wiki Theme JS
 *  Based on the 2022 album by Ethel Cain
 *  Southern Gothic · Dark Americana · Sacred & Profane
 * =============================================================
 *  Install: Paste into MediaWiki:Common.js
 *  Requires: PreachersDaughter.css (MediaWiki:Common.css)
 * =============================================================
 */

( function () {
  'use strict';

  /* ── CONFIG ─────────────────────────────────────────────── */
  var CFG = {
    dust: {
      enabled:    true,
      count:      28,         /* live particles on screen        */
      minSize:    1,          /* px                              */
      maxSize:    3.5,        /* px                              */
      minSpeed:   0.18,       /* px / frame                      */
      maxSpeed:   0.55,
      minDrift:   -0.25,      /* horizontal drift px/frame       */
      maxDrift:    0.35,
      colors:     ['rgba(224,200,160,{a})', 'rgba(160,80,80,{a})', 'rgba(255,240,210,{a})']
    },
    flicker: {
      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 ─────────────────────────────────────────────── */
  function rand( min, max ) { return Math.random() * ( max - min ) + min; }
  function randInt( min, max ) { return Math.floor( rand( min, max + 1 ) ); }
  function qs( sel, ctx ) { return ( ctx || document ).querySelector( sel ); }
  function qsa( sel, ctx ) { return Array.from( ( ctx || document ).querySelectorAll( sel ) ); }

  /* ── 1. WATERMARK CROSS ──────────────────────────────────── */
  function initWatermark() {
    if ( !CFG.watermark.enabled ) return;
    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 initDust() {
    if ( !CFG.dust.enabled ) return;

    var canvas = document.createElement( 'canvas' );
    canvas.id  = 'pd-dust-canvas';
    canvas.setAttribute( 'aria-hidden', 'true' );
    document.body.appendChild( canvas );

    var ctx    = canvas.getContext( '2d' );
    var W, H;
    var particles = [];

    function resize() {
      W = canvas.width  = window.innerWidth;
      H = canvas.height = window.innerHeight;
    }
    window.addEventListener( 'resize', resize, { passive: true } );
    resize();

    function makeParticle( initial ) {
      var colorTpl = CFG.dust.colors[ randInt( 0, CFG.dust.colors.length - 1 ) ];
      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() {
      ctx.clearRect( 0, 0, W, H );

      particles.forEach( function ( p, idx ) {
        p.y   += p.vy;
        p.x   += p.vx + Math.sin( p.y * 0.008 + idx ) * 0.18;
        p.rot += p.rspd;

        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 );
  }

  /* ── 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 ────────────────────────────────────── */
  function initReveal() {
    if ( !CFG.reveal.enabled ) return;
    if ( typeof IntersectionObserver === 'undefined' ) return;

    var targets = qsa(
      '.mw-body-content h2, .mw-body-content h3, ' +
      '.mw-body-content p, .mw-body-content .thumb, ' +
      '.mw-body-content table, .mw-body-content blockquote, ' +
      '.mw-body-content ul, .mw-body-content ol'
    );

    var seen = new Set();

    var obs = new IntersectionObserver( function ( entries ) {
      entries.forEach( function ( entry ) {
        if ( entry.isIntersecting && !seen.has( entry.target ) ) {
          seen.add( entry.target );
          entry.target.classList.add( 'pd-reveal' );
          obs.unobserve( entry.target );
        }
      } );
    }, { rootMargin: CFG.reveal.rootMargin } );

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

  /* ── 5. CANDLE AMBIENT PULSE ─────────────────────────────── */
  function initCandlePulse() {
    if ( !CFG.candle.enabled ) return;

    var targets = qsa( '#toc, .toc, .infobox, table.infobox, #content, .mw-body' );
    targets.forEach( function ( el ) {
      el.classList.add( 'pd-pulse-border' );
    } );
  }

  /* ── 6. ORNATE SECTION DIVIDERS ──────────────────────────── */
  function initDividers() {
    /* Insert a decorative vine divider after every h2 */
    var ornament = '<span class="pd-ornament" aria-hidden="true" ' +
      'style="display:block;text-align:center;font-size:0.75rem;' +
      'color:rgba(122,28,36,0.45);letter-spacing:0.6em;' +
      'margin:-0.2rem 0 0.8rem;font-family:serif;">' +
      '— ✦ —' +
      '</span>';

    qsa( '.mw-body-content h2' ).forEach( function ( h ) {
      h.insertAdjacentHTML( 'afterend', ornament );
    } );
  }

  /* ── 7. TYPEWRITER HEADING (opt-in) ──────────────────────── */
  function initTypewriter() {
    if ( !CFG.typewriter.enabled ) return;

    var heading = qs( '#firstHeading, .mw-first-heading' );
    if ( !heading ) return;

    var originalText = heading.textContent.trim();
    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 );
  }

  /* ── 8. LINK HOVER SOUND (optional — disabled by default) ── */
  /*
  function initHoverSound() {
    // Uncomment if you want subtle audio on link hover
    // Requires AudioContext support
    qsa( 'a' ).forEach( function ( a ) {
      a.addEventListener( 'mouseenter', function () {
        try {
          var ctx = new AudioContext();
          var osc = ctx.createOscillator();
          var gain = ctx.createGain();
          osc.connect( gain );
          gain.connect( ctx.destination );
          osc.frequency.value = 220;
          gain.gain.setValueAtTime( 0.04, ctx.currentTime );
          gain.gain.exponentialRampToValueAtTime( 0.0001, ctx.currentTime + 0.25 );
          osc.start();
          osc.stop( ctx.currentTime + 0.25 );
        } catch ( e ) {}
      } );
    } );
  }
  */

  /* ── 9. REDLINK STYLING ──────────────────────────────────── */
  function styleRedlinks() {
    qsa( 'a.new' ).forEach( function ( a ) {
      a.title = ( a.title || '' ) + ' [page not yet written]';
    } );
  }

  /* ── 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';
      overlay.style.opacity = '1';
    } );
  }

  /* ── 14. QUOTE PULL — RANDOM TRACK EPIGRAPH ──────────────── */
  var EPIGRAPHS = [
    '"I was baptized but I still die." — Family Tree',
    '"God only listens to the men around here." — Gibson Girl',
    '"Nothing happened here except everything." — Thoroughfare',
    '"Hallelujah, hallelujah, come back to me." — Strangers',
    '"Sometimes I swear I can still hear the choir." — Hard Road',
    '"Do not mistake her softness for surrender." — American Teenager'
  ];

  function initEpigraph() {
    var sidebar = qs( '#mw-panel, .vector-sidebar-toc-container, #vector-toc-collapsed-button' );
    if ( !sidebar ) return;

    var quote = EPIGRAPHS[ randInt( 0, EPIGRAPHS.length - 1 ) ];

    var el = document.createElement( 'div' );
    el.setAttribute( 'aria-label', 'Album epigraph' );
    el.style.cssText =
      'margin: 1.5rem 0.8rem;' +
      'padding: 0.8rem 1rem;' +
      'border-left: 2px solid rgba(122,28,36,0.5);' +
      'font-family: "IM Fell English", Georgia, serif;' +
      'font-style: italic;' +
      'font-size: 0.82rem;' +
      'color: rgba(158,139,114,0.8);' +
      'line-height: 1.6;';
    el.textContent = quote;

    sidebar.appendChild( el );
  }

  /* ── INIT — run after DOM ready ──────────────────────────── */
  function main() {
    initWatermark();
    initDust();
    initFlicker();
    initReveal();
    initCandlePulse();
    initDividers();
    initTypewriter();
    styleRedlinks();
    initSidebarAnimations();
    initTableStagger();
    initCursorTrail();
    initPageTransition();
    initEpigraph();

    /* Mark body so CSS can scope theme selectors */
    document.documentElement.classList.add( 'pd-theme' );

    /* Console signature */
    console.log(
      '%c✝ Preacher\'s Daughter Wiki Theme%c\n' +
      '  Ethel Cain · 2022 · Southern Gothic\n' +
      '  "God is a place you will wait for the rest of your life."',
      'color:#c03040;font-size:14px;font-weight:bold;',
      'color:#9e8b72;font-size:11px;'
    );
  }

  if ( document.readyState === 'loading' ) {
    document.addEventListener( 'DOMContentLoaded', main );
  } else {
    main();
  }

} )();