Candidate:Common.js: Difference between revisions

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
Magent (talk | contribs)
Staging full proposed Common.js for sysop review (new Show: detector IIFE at bottom)
 
Magent (talk | contribs)
Stage night mode toggle (site-wide ☾/☼, same localStorage as the zine) + zine toggle agrees with the site switches (bot-staged for promotion) (via update-page on MediaWiki MCP Server)
Line 1: Line 1:
// <proposed by="Magent">
// |status=proposed -- bot-staged; remove this line when promoting.
// |status=proposed -- bot-staged; remove this line when promoting.
/**
* Blue Railroad Submission - Date to Block Height Converter
* Adds a datepicker that converts dates to Ethereum block heights
* Also shows time ago for news templates
*/


(function() {
    'use strict';
   
    // Only run on the Blue Railroad Submission form
    if (!document.querySelector('input[name="Blue Railroad Submission[block_height]"]')) {
        return;
    }
   
    // Reference point: known block and timestamp
    // Post-merge average block time is ~12.12 seconds
    const AVG_BLOCK_TIME = 12.12;
   
    // Get current block from footer (format: "24,328,442")
    function getCurrentBlockFromFooter() {
        const footerLink = document.querySelector('a[href*="etherscan.io/block/"]');
        if (footerLink) {
            const match = footerLink.href.match(/block\/(\d+)/);
            if (match) return parseInt(match[1]);
        }
        return null;
    }
   
    // Calculate block height from date
    function dateToBlockHeight(targetDate, refBlock, refTimestamp) {
        const targetTimestamp = targetDate.getTime() / 1000;
        const secondsDiff = refTimestamp - targetTimestamp;
        const blocksDiff = Math.round(secondsDiff / AVG_BLOCK_TIME);
        return refBlock - blocksDiff;
    }
   
    // Add the datepicker UI
    function addDatePicker() {
        const blockInput = document.querySelector('input[name="Blue Railroad Submission[block_height]"]');
        if (!blockInput) return;
       
        const container = document.createElement('div');
        container.style.marginTop = '8px';
        container.innerHTML = `
            <label style="display: block; margin-bottom: 4px; font-size: 0.9em;">
                Or pick a date/time:
            </label>
            <input type="datetime-local" id="br-datepicker" style="padding: 4px; margin-right: 8px;">
            <button type="button" id="br-convert-btn" style="padding: 4px 12px; cursor: pointer;">
                Convert to Block
            </button>
            <span id="br-status" style="margin-left: 8px; font-size: 0.9em; color: #666;"></span>
        `;
       
        blockInput.parentNode.appendChild(container);
       
        const datePicker = document.getElementById('br-datepicker');
        const convertBtn = document.getElementById('br-convert-btn');
        const status = document.getElementById('br-status');
       
        // Set default to now
        const now = new Date();
        datePicker.value = now.toISOString().slice(0, 16);
       
        convertBtn.addEventListener('click', function() {
            const selectedDate = new Date(datePicker.value);
            if (isNaN(selectedDate.getTime())) {
                status.textContent = 'Invalid date';
                status.style.color = 'red';
                return;
            }
           
            const currentBlock = getCurrentBlockFromFooter();
            if (!currentBlock) {
                status.textContent = 'Could not find reference block';
                status.style.color = 'red';
                return;
            }
           
            const currentTimestamp = Date.now() / 1000;
            const estimatedBlock = dateToBlockHeight(selectedDate, currentBlock, currentTimestamp);
           
            if (estimatedBlock > currentBlock) {
                status.textContent = 'Date is in the future!';
                status.style.color = 'orange';
            } else {
                status.textContent = '~estimated';
                status.style.color = 'green';
            }
           
            blockInput.value = estimatedBlock;
        });
    }
   
    // Run when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', addDatePicker);
    } else {
        addDatePicker();
    }
})();


/**
/**
  * TimeAgo — shows "X blocks ago" for transcluded templates.
  * What used to be here now ships with the PickiPediaReleases extension.
  *
  *
  * Usage in wikitext:
  *  the HLS video player  -> ext.pickipediaReleases.videoPlayer.js    (#124)
*   <span class="timeago" data-lastmod-page="Template:NewsShorts"></span>
*  Blue Railroad date picker -> ext.pickipediaReleases.blueRailroadDate.js (#131)
*
*  "X blocks ago"         -> ext.pickipediaReleases.blocksAgo.js      (#131)
* Fetches the last revision timestamp from the API and renders
*  Instrument form checks -> ext.pickipediaReleases.instrumentForm.js (#131)
* a block-based relative time string using Ethereum block heights.
*/
(function() {
    'use strict';
 
    // Post-merge average block time is ~12.12 seconds
    var AVG_BLOCK_TIME = 12.12;
 
    var spans = document.querySelectorAll('.timeago[data-lastmod-page]');
    if (!spans.length) return;
 
    // Format number with commas (e.g., 1234567 -> "1,234,567")
    function formatNumber(num) {
        return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    }
 
    // Collect unique page titles
    var titles = [];
    spans.forEach(function(span) {
        var t = span.getAttribute('data-lastmod-page');
         if (t && titles.indexOf(t) === -1) titles.push(t);
    });
 
    // Batch query the API (up to 50 titles per request)
    var api = mw.config.get('wgScriptPath') + '/api.php';
    var url = api + '?action=query&prop=revisions&rvprop=timestamp&format=json&titles=' +
        encodeURIComponent(titles.join('|'));
 
    fetch(url).then(function(r) { return r.json(); }).then(function(data) {
        var pages = data.query && data.query.pages || {};
        var timestamps = {};
 
        Object.keys(pages).forEach(function(id) {
            var page = pages[id];
            if (page.revisions && page.revisions[0]) {
                timestamps[page.title] = new Date(page.revisions[0].timestamp);
            }
        });
 
        spans.forEach(function(span) {
            var title = span.getAttribute('data-lastmod-page');
            var ts = timestamps[title];
            if (!ts) {
                span.textContent = '';
                return;
            }
            span.textContent = formatBlocksAgo(ts);
            span.title = ts.toLocaleString();
        });
    });
 
    function formatBlocksAgo(date) {
        var secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
        var blocksAgo = Math.round(secondsAgo / AVG_BLOCK_TIME);
 
        if (blocksAgo < 1) {
            return 'this block';
        } else if (blocksAgo === 1) {
            return '1 block ago';
        } else {
            return formatNumber(blocksAgo) + ' blocks ago';
        }
    }
})();
 
/**
* HLS Video Player - Initializes HLS.js for IPFS-hosted videos
  *
  *
  * Usage in wikitext (via Template:HLSVideo):
  * All four load on every page from the extension's BeforePageDisplay hook,
  *   <div class="hls-video-player" data-cid="QmXet6..."></div>
* exactly as they did from here, and each does nothing unless the form or
* element it cares about is on the page being shown. Changing any of them is
* a pull request now: reviewed, syntax-checked by CI, deployed by Jenkins —
* rather than several hundred lines pasted into this box, which is how the
  * whole of this file went down for 23 minutes on 15 September 2026.
  *
  *
  * The gadget loads hls.js and initializes players for any element
  * What remains below is the user-subpage zine styling. It stays because it is
  * with the hls-video-player class and a data-cid attribute.
* bound to classes defined in MediaWiki:Common.css, and moving the script
  * without its stylesheet would split one feature across two homes. It goes
* next, with the CSS, after which this page can be empty.
  *
  *
  * Supports both HLS streams (CID/master.m3u8) and raw video files.
  * See cryptograss/pickipedia#124 and #131.
* Tries HLS first; if the manifest 404s, falls back to direct playback.
  */
  */
(function() {
    'use strict';
    var IPFS_GATEWAY = 'https://ipfs.delivery-kid.cryptograss.live/ipfs';
    var HLS_JS_URL = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
    var hlsLoadPromise = null;
    // CIDv1 (bafy...) is Base32 lowercase, but MediaWiki capitalizes page titles.
    // CIDv0 (Qm...) is Base58 case-sensitive — must not be lowercased.
    function normalizeCid(cid) {
        if (cid && cid.substring(0, 4) === 'Bafy') {
            return cid.toLowerCase();
        }
        return cid;
    }
    function loadHls() {
        if (typeof Hls !== 'undefined') {
            return Promise.resolve();
        }
        if (hlsLoadPromise) {
            return hlsLoadPromise;
        }
        hlsLoadPromise = new Promise(function(resolve) {
            var script = document.createElement('script');
            script.src = HLS_JS_URL;
            script.onload = resolve;
            document.head.appendChild(script);
        });
        return hlsLoadPromise;
    }
    function initPlayers() {
        var containers = document.querySelectorAll('.hls-video-player[data-cid]:not([data-initialized])');
        if (!containers.length) return;
        // Mark ALL containers immediately to prevent race conditions
        containers.forEach(function(c) {
            c.setAttribute('data-initialized', 'true');
        });
        // Then load hls.js and initialize
        loadHls().then(function() {
            containers.forEach(initPlayer);
        });
    }
    function createVideoElement(width, maxWidth) {
        var video = document.createElement('video');
        video.controls = true;
        video.playsInline = true;
        video.style.width = width;
        video.style.maxWidth = maxWidth;
        video.style.backgroundColor = '#000';
        video.style.display = 'block';
        return video;
    }
    function fallbackToDirectVideo(container, cid, width, maxWidth) {
        // CID is a raw video file, not an HLS stream — play directly
        var directUrl = IPFS_GATEWAY + '/' + normalizeCid(cid);
        container.innerHTML = '';
        var video = createVideoElement(width, maxWidth);
        video.src = directUrl;
        video.addEventListener('error', function() {
            container.innerHTML = '<p style="color: red; padding: 1em;">Error loading video. The IPFS content may not be available.</p>';
        });
        container.appendChild(video);
    }
    function initPlayer(container) {
        var cid = container.getAttribute('data-cid');
        var normalCid = normalizeCid(cid);
        var width = container.getAttribute('data-width') || '100%';
        var maxWidth = container.getAttribute('data-max-width') || '800px';
       
        var hlsSrc = IPFS_GATEWAY + '/' + normalCid + '/master.m3u8';
        var video = createVideoElement(width, maxWidth);
        container.appendChild(video);
        if (video.canPlayType('application/vnd.apple.mpegurl')) {
            // Safari native HLS — try HLS first, fall back on error
            video.src = hlsSrc;
            video.addEventListener('error', function() {
                fallbackToDirectVideo(container, cid, width, maxWidth);
            });
        } else if (typeof Hls !== 'undefined' && Hls.isSupported()) {
            var hls = new Hls();
            hls.loadSource(hlsSrc);
            hls.attachMedia(video);
            hls.on(Hls.Events.ERROR, function(event, data) {
                if (data.fatal) {
                    // HLS failed (likely no master.m3u8) — try direct video
                    hls.destroy();
                    fallbackToDirectVideo(container, cid, width, maxWidth);
                }
            });
        } else {
            // No HLS support — try direct video
            fallbackToDirectVideo(container, cid, width, maxWidth);
        }
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initPlayers);
    } else {
        initPlayers();
    }
    if (typeof mw !== 'undefined' && mw.hook) {
        mw.hook('wikipage.content').add(function($content) {
            initPlayers();
        });
    }
})();
/**
* Instrument form — validation + page name computation (v24772700)
*/
(function() {
    'use strict';
    console.log('PickiPedia Common.js instrument module v24772700 loaded');
    mw.hook('pf.formValidation').add(function(args) {
        var nickname = document.querySelector('input[name="Instrument[nickname]"]');
        if (!nickname) return;
        var make = document.querySelector('input[name="Instrument[make]"]');
        var model = document.querySelector('input[name="Instrument[model]"]');
        var serial = document.querySelector('input[name="Instrument[serial]"]');
        var nickVal = nickname.value.trim();
        var makeVal = make ? make.value.trim() : '';
        var modelVal = model ? model.value.trim() : '';
        var serialVal = serial ? serial.value.trim() : '';
        var hasNickname = nickVal !== '';
        var hasMakerComplete = makeVal !== '' && modelVal !== '' && serialVal !== '';
        if (!hasNickname && !hasMakerComplete) {
            args.numErrors += 1;
            if (makeVal !== '' || modelVal !== '' || serialVal !== '') {
                alert('Maker identification requires all three: make, model, and serial number. Or provide a nickname instead.');
            } else {
                alert('Please provide either a nickname or maker identification (make, model, and serial number).');
            }
            return;
        }
        // If no nickname, fill it from make/model/serial for page naming
        if (!hasNickname) {
            nickname.value = makeVal + ' ' + modelVal + ' ' + serialVal;
        }
    });
})();


/* ============================================================
/* ============================================================
Line 393: Line 77:
   var saved = null;
   var saved = null;
   try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
   try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
   if (saved === 'dark') body.classList.add('pickipedia-dark');
  // Follow the site's night preference (set by the toggle below, on any
  // page): a saved dark/light choice, else the OS's prefers-color-scheme.
  var systemDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
   if (saved === 'dark' || (saved === null && systemDark)) body.classList.add('pickipedia-dark');


   var LOGO_URL = '/images/thumb/8/80/Pickipedia-quarter-transparent.png/400px-Pickipedia-quarter-transparent.png';
   var LOGO_URL = '/images/thumb/8/80/Pickipedia-quarter-transparent.png/400px-Pickipedia-quarter-transparent.png';
Line 470: Line 157:
     var dark = body.classList.toggle('pickipedia-dark');
     var dark = body.classList.toggle('pickipedia-dark');
     toggle.textContent = dark ? '☼ Light' : '☾ Dark';
     toggle.textContent = dark ? '☼ Light' : '☾ Dark';
    // Tell the rest of the site (html.pp-night / html.pp-day) too, so a
    // choice on a zine page holds everywhere it goes next.
    var htmlEl = document.documentElement;
    htmlEl.classList.remove('pp-night');
    htmlEl.classList.remove('pp-day');
    htmlEl.classList.add(dark ? 'pp-night' : 'pp-day');
     try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {}
     try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {}
   });
   });
Line 483: Line 176:
}());
}());


/**
Wiki: pickipedia.xyz
  * Pickipedia — Show: page hero.
/* ============================================================
  * Pickipedia — Site-wide night toggle
  *
  *
  * For mainspace pages whose title starts with "Show:" (the legacy
  * Turns the stored preference into html.pp-night / html.pp-day,
  * mainspace convention used pre-namespace), adds .pickipedia-show
* the switches the night theme in MediaWiki:Common.css keys on:
  * to body and absorbs Template:Show's infobox into a poster-style
  *   pp-night — explicit dark;  pp-day — explicit light, opting
  * hero. If the page does not use Template:Show only the body class
  * out of the prefers-color-scheme media query. The zine pages
  * is added — the setlist typography in Common.css still applies.
  * render their own toggle (see above) and share the same
  */
  * localStorage key, so one choice holds across the whole site.
  * ============================================================ */
(function () {
(function () {
   'use strict';
   'use strict';


   if (typeof mw === 'undefined' || !mw.config) return;
   var KEY = 'pickipedia-mode';
  var saved = null;
  try { saved = localStorage.getItem(KEY); } catch (e) {}


   var nsNumber = mw.config.get('wgNamespaceNumber');
   var htmlEl = document.documentElement;
  var title    = mw.config.get('wgTitle') || '';
  if (saved === 'dark') htmlEl.classList.add('pp-night');
   var action  = mw.config.get('wgAction') || 'view';
   if (saved === 'light') htmlEl.classList.add('pp-day');


   if (nsNumber !== 0) return;
   // Zine pages carry their own control; don't add a second one.
  if (action !== 'view') return;
   if (document.body && document.body.classList.contains('pickipedia-userpost')) return;
   if (title.indexOf('Show:') !== 0) return;


   var body = document.body;
   var systemDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
   body.classList.add('pickipedia-show');
   var darkNow = htmlEl.classList.contains('pp-night') || (saved === null && systemDark);


   var content = document.querySelector('.mw-parser-output');
   var toggle = document.createElement('button');
   if (!content) return;
   toggle.className = 'pickipedia-mode-site';
 
   toggle.type = 'button';
  var infobox = content.querySelector('.show-infobox');
  toggle.textContent = darkNow ? '☼ Light' : '☾ Dark';
  if (!infobox) return; // Bare show page — body class only.
   toggle.title = darkNow ? 'Switch to light mode' : 'Switch to dark mode';
 
   toggle.addEventListener('click', function () {
  // ---------- Pull fields out of the existing infobox ----------
     darkNow = !darkNow;
  // Template:Show emits rows of <div><strong>Label:</strong> value</div>
     htmlEl.classList.remove('pp-night');
   // plus a top banner with "Artists at Venue" and optional image.
     htmlEl.classList.remove('pp-day');
  function fieldValue(label) {
     htmlEl.classList.add(darkNow ? 'pp-night' : 'pp-day');
    var rows = infobox.querySelectorAll('div');
     try { localStorage.setItem(KEY, darkNow ? 'dark' : 'light'); } catch (e) {}
    for (var i = 0; i < rows.length; i++) {
     toggle.textContent = darkNow ? '☼ Light' : '☾ Dark';
      var s = rows[i].querySelector('strong');
     toggle.title = darkNow ? 'Switch to light mode' : 'Switch to dark mode';
      if (!s) continue;
   });
      var labelText = s.textContent.replace(/[:\s]*$/, '').trim();
   document.body.appendChild(toggle);
      if (labelText !== label) continue;
      var clone = rows[i].cloneNode(true);
      var strong = clone.querySelector('strong');
      if (strong) strong.remove();
      return clone.innerHTML.trim().replace(/^[\s:]+/, '');
    }
    return '';
  }
 
   var artists  = fieldValue('Artists');
  var venue    = fieldValue('Venue');
  var showtime = fieldValue('Showtime');
  var tickets  = fieldValue('Tickets');
  var price    = fieldValue('Price');
  var ages    = fieldValue('Ages');
 
  var imageImg = infobox.querySelector('img');
 
  var status = '';
  if (infobox.classList.contains('show-verified')) status = 'verified';
  else if (infobox.classList.contains('bot-proposal')) status = 'proposed';
   else if (infobox.classList.contains('show-unverified')) status = 'unverified';
 
  // ---------- Build the poster hero ----------
  // Showtime is rendered by Template:Show as
  // [https://etherscan.io/block/N N] — pull the block number out and
  // format it with commas so the kicker reads "BLOCK 24,144,194".
  function formatShowtime(htmlIn) {
     if (!htmlIn) return '';
     var tmp = document.createElement('div');
    tmp.innerHTML = htmlIn;
    var anchor = tmp.querySelector('a');
     var blockNum = (anchor ? anchor.textContent : tmp.textContent).trim();
    var asInt = parseInt(blockNum.replace(/[^\d]/g, ''), 10);
     if (isNaN(asInt)) return blockNum;
    var formatted = asInt.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    var href = anchor ? anchor.href : '';
    if (href) {
      return '<a href="' + href + '" target="_blank" rel="noopener">Block ' + formatted + '</a>';
    }
    return 'Block ' + formatted;
  }
 
  var kickerBits = [];
  var showtimeHtml = formatShowtime(showtime);
  if (showtimeHtml) kickerBits.push(showtimeHtml);
 
  var kickerHtml = kickerBits.length
    ? '<div class="pp-kicker">' + kickerBits.join('<span class="pp-sep">·</span>') + '</div>'
    : '';
 
  var venueHtml = venue ? '<p class="pp-venue">at ' + venue + '</p>' : '';
 
  var metaBits = [];
  if (price)
     metaBits.push('<span><span class="pp-meta-label">Price</span><span class="pp-meta-value">$' +
      escapeHtml(price.replace(/^\$/, '')) + '</span></span>');
  if (ages)
    metaBits.push('<span><span class="pp-meta-label">Ages</span><span class="pp-meta-value">' +
      ages + '</span></span>');
  var metaHtml = metaBits.length
    ? '<div class="pp-meta">' + metaBits.join('') + '</div>'
    : '';
 
  var imageHtml = '';
  if (imageImg) {
    imageHtml = '<div class="pp-image"><img src="' + imageImg.src +
      '" alt="' + escapeHtml(imageImg.alt || '') + '"></div>';
  }
 
  // Tickets field is an anchor; surface its href as a poster action.
  var actionBits = [];
  if (tickets) {
     var tmp = document.createElement('div'); tmp.innerHTML = tickets;
    var ticketAnchor = tmp.querySelector('a');
    if (ticketAnchor && ticketAnchor.href) {
      actionBits.push('<a href="' + ticketAnchor.href +
        '" target="_blank" rel="noopener">Tickets</a>');
     }
  }
  var actionsHtml = actionBits.length
    ? '<div class="pp-actions">' + actionBits.join('') + '</div>'
    : '';
 
  var statusHtml = status
    ? '<div class="pp-status is-' + status + '">' + status + '</div>'
    : '';
 
   var titleHtml = artists || escapeHtml(title.replace(/^Show:\s*/, ''));
 
   var hero = document.createElement('header');
  hero.className = 'show-poster';
  hero.innerHTML =
    statusHtml +
    kickerHtml +
    '<h1 class="pp-title">' + titleHtml + '</h1>' +
    venueHtml +
    metaHtml +
    imageHtml +
    actionsHtml;
 
  content.insertBefore(hero, content.firstChild);
  infobox.classList.add('is-absorbed');
  body.classList.add('has-show-hero');
 
  function escapeHtml(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }
}());
}());
// </proposed>

Revision as of 20:15, 29 October 2026

// <proposed by="Magent">
// |status=proposed -- bot-staged; remove this line when promoting.


/**
 * What used to be here now ships with the PickiPediaReleases extension.
 *
 *   the HLS video player   -> ext.pickipediaReleases.videoPlayer.js    (#124)
 *   Blue Railroad date picker -> ext.pickipediaReleases.blueRailroadDate.js (#131)
 *   "X blocks ago"         -> ext.pickipediaReleases.blocksAgo.js      (#131)
 *   Instrument form checks -> ext.pickipediaReleases.instrumentForm.js (#131)
 *
 * All four load on every page from the extension's BeforePageDisplay hook,
 * exactly as they did from here, and each does nothing unless the form or
 * element it cares about is on the page being shown. Changing any of them is
 * a pull request now: reviewed, syntax-checked by CI, deployed by Jenkins —
 * rather than several hundred lines pasted into this box, which is how the
 * whole of this file went down for 23 minutes on 15 September 2026.
 *
 * What remains below is the user-subpage zine styling. It stays because it is
 * bound to classes defined in MediaWiki:Common.css, and moving the script
 * without its stylesheet would split one feature across two homes. It goes
 * next, with the CSS, after which this page can be empty.
 *
 * See cryptograss/pickipedia#124 and #131.
 */

/* ============================================================
 * Pickipedia — User-subpage zine/Substack styling
 *
 * Layout:
 *   - corner logo (absolute-positioned upper-left of viewport,
 *     OUTSIDE all wiki wrappers — does not push content down)
 *   - non-sticky topbar (just nav) at top of body
 *   - hero block with title (top), then USER:Username kicker below,
 *     optional dek + meta
 *   - light/dark mode toggle (persisted in localStorage)
 *
 * Variant from HTML comment marker `pickipedia:variant=letter|masthead`
 * or category membership; default letter.
 * ============================================================ */

(function () {
  'use strict';

  if (typeof mw === 'undefined' || !mw.config) return;

  var nsNumber = mw.config.get('wgNamespaceNumber');
  var pageName = mw.config.get('wgPageName') || '';
  var title    = mw.config.get('wgTitle') || '';
  var action   = mw.config.get('wgAction') || 'view';

  if (nsNumber !== 2) return;
  if (action !== 'view') return;
  if (title.indexOf('/') === -1) return;

  var slash    = title.indexOf('/');
  var username = title.slice(0, slash).replace(/_/g, ' ');
  var subpage  = title.slice(slash + 1).replace(/_/g, ' ');

  var body = document.body;
  body.classList.add('pickipedia-userpost');

  var variant = 'letter';
  var content = document.querySelector('.mw-parser-output');
  if (content) {
    var html = content.innerHTML || '';
    var m = html.match(/pickipedia:variant\s*=\s*(letter|masthead)/i);
    if (m) variant = m[1].toLowerCase();
  }
  var cats = mw.config.get('wgCategories') || [];
  if (cats.indexOf('Masthead style') !== -1) variant = 'masthead';
  if (cats.indexOf('Letter style') !== -1) variant = 'letter';
  body.classList.add('pickipedia-variant-' + variant);

  var STORAGE_KEY = 'pickipedia-mode';
  var saved = null;
  try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
  // Follow the site's night preference (set by the toggle below, on any
  // page): a saved dark/light choice, else the OS's prefers-color-scheme.
  var systemDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
  if (saved === 'dark' || (saved === null && systemDark)) body.classList.add('pickipedia-dark');

  var LOGO_URL = '/images/thumb/8/80/Pickipedia-quarter-transparent.png/400px-Pickipedia-quarter-transparent.png';

  // ---------- Corner logo: stand-alone body child, absolute-positioned. ----------
  var cornerLogo = document.createElement('a');
  cornerLogo.className = 'pp-corner-logo';
  cornerLogo.href = '/wiki/Main_Page';
  cornerLogo.title = 'PickiPedia home';
  cornerLogo.innerHTML = '<img src="' + LOGO_URL + '" alt="PickiPedia">';
  document.body.insertBefore(cornerLogo, document.body.firstChild);

  // ---------- Topbar (nav only) ----------
  var topbar = document.createElement('header');
  topbar.className = 'pickipedia-topbar';
  topbar.innerHTML =
    '<nav class="pickipedia-topbar-nav">' +
      '<a href="' + mw.util.getUrl(pageName, { action: 'edit' }) + '">edit</a>' +
      '<a href="' + mw.util.getUrl(pageName, { action: 'history' }) + '">history</a>' +
      '<a href="' + mw.util.getUrl('Talk:' + pageName) + '">talk</a>' +
    '</nav>';
  document.body.insertBefore(topbar, cornerLogo.nextSibling);

  // ---------- Hero ----------
  if (content) {
    var dek = '';
    var dekEl = content.querySelector('.pp-dek-source');
    if (dekEl) {
      dek = dekEl.innerHTML;
      dekEl.remove();
    }

    var kickerHtml = '<a href="' + mw.util.getUrl('User:' + username.replace(/ /g, '_')) +
                     '">User:' + escapeHtml(username) + '</a>';
    var kickerEl = content.querySelector('.pp-kicker-source');
    if (kickerEl) {
      kickerHtml = escapeHtml(kickerEl.textContent.trim());
      kickerEl.remove();
    }

    var lastMod = '';
    var lastModEl = document.getElementById('footer-info-lastmod');
    if (lastModEl) lastMod = lastModEl.textContent.replace(/^\s*This page was last (edited|modified) on\s*/i, '').trim();

    var hero = document.createElement('header');
    hero.className = 'pickipedia-hero';

    if (variant === 'masthead') {
      hero.innerHTML =
        '<div class="pp-topbar">' +
          '<div class="pp-kicker">' + kickerHtml + '</div>' +
          '<div class="pp-topbar-rule"></div>' +
          (lastMod ? '<div class="pp-topbar-vol">' + escapeHtml(lastMod) + '</div>' : '') +
        '</div>' +
        '<div class="pp-titlewrap">' +
          '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
          (dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
        '</div>';
    } else {
      // Letter variant: title first, then kicker, then optional dek/meta.
      hero.innerHTML =
        '<h1 class="pp-title">' + escapeHtml(subpage) + '</h1>' +
        '<div class="pp-kicker">' + kickerHtml + '</div>' +
        (dek ? '<p class="pp-dek">' + dek + '</p>' : '') +
        (lastMod ? '<div class="pp-meta">Last edited ' + escapeHtml(lastMod) + '</div>' : '');
    }
    content.parentNode.insertBefore(hero, content);
  }

  // ---------- Light/dark toggle ----------
  var toggle = document.createElement('button');
  toggle.className = 'pickipedia-mode';
  toggle.type = 'button';
  toggle.textContent = body.classList.contains('pickipedia-dark') ? '☼ Light' : '☾ Dark';
  toggle.addEventListener('click', function () {
    var dark = body.classList.toggle('pickipedia-dark');
    toggle.textContent = dark ? '☼ Light' : '☾ Dark';
    // Tell the rest of the site (html.pp-night / html.pp-day) too, so a
    // choice on a zine page holds everywhere it goes next.
    var htmlEl = document.documentElement;
    htmlEl.classList.remove('pp-night');
    htmlEl.classList.remove('pp-day');
    htmlEl.classList.add(dark ? 'pp-night' : 'pp-day');
    try { localStorage.setItem(STORAGE_KEY, dark ? 'dark' : 'light'); } catch (e) {}
  });
  document.body.appendChild(toggle);

  function escapeHtml(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
    }
}());

Wiki: pickipedia.xyz
/* ============================================================
 * Pickipedia — Site-wide night toggle
 *
 * Turns the stored preference into html.pp-night / html.pp-day,
 * the switches the night theme in MediaWiki:Common.css keys on:
 *   pp-night — explicit dark;   pp-day — explicit light, opting
 * out of the prefers-color-scheme media query. The zine pages
 * render their own toggle (see above) and share the same
 * localStorage key, so one choice holds across the whole site.
 * ============================================================ */
(function () {
  'use strict';

  var KEY = 'pickipedia-mode';
  var saved = null;
  try { saved = localStorage.getItem(KEY); } catch (e) {}

  var htmlEl = document.documentElement;
  if (saved === 'dark') htmlEl.classList.add('pp-night');
  if (saved === 'light') htmlEl.classList.add('pp-day');

  // Zine pages carry their own control; don't add a second one.
  if (document.body && document.body.classList.contains('pickipedia-userpost')) return;

  var systemDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
  var darkNow = htmlEl.classList.contains('pp-night') || (saved === null && systemDark);

  var toggle = document.createElement('button');
  toggle.className = 'pickipedia-mode-site';
  toggle.type = 'button';
  toggle.textContent = darkNow ? '☼ Light' : '☾ Dark';
  toggle.title = darkNow ? 'Switch to light mode' : 'Switch to dark mode';
  toggle.addEventListener('click', function () {
    darkNow = !darkNow;
    htmlEl.classList.remove('pp-night');
    htmlEl.classList.remove('pp-day');
    htmlEl.classList.add(darkNow ? 'pp-night' : 'pp-day');
    try { localStorage.setItem(KEY, darkNow ? 'dark' : 'light'); } catch (e) {}
    toggle.textContent = darkNow ? '☼ Light' : '☾ Dark';
    toggle.title = darkNow ? 'Switch to light mode' : 'Switch to dark mode';
  });
  document.body.appendChild(toggle);
}());
// </proposed>