MediaWiki:Gadget-bhc-accessibility.js: Difference between revisions

No edit summary
No edit summary
Tag: Reverted
Line 1: Line 1:
(() => {
/* =========================================================
   "use strict";
  BHC Accessibility Gadget – MediaWiki (Timeless + Vector)
  - Floating toggle + panel
  - Contrast, underline, text size, spacing, cursor, ruler
  - Skip links injection
  - Persistence via localStorage (cookie fallback)
  ========================================================= */
(function () {
   'use strict';


   // -------------------------------------------------------------------
   // ------------------------------
   // Cookie helpers
   // Config
   // -------------------------------------------------------------------
   // ------------------------------
   const COOKIE_PREFIX = "a11y_";
   var STORAGE_KEY = 'bhc_a11y_v1';
  var COOKIE_FALLBACK_DAYS = 365;


   function setCookie(name, value, days = 365) {
  // Text scale bounds (aggressive enough to be obvious in Timeless)
     const exp = new Date();
  var SCALE_MIN = 0.90;
     exp.setTime(exp.getTime() + days * 24 * 60 * 60 * 1000);
  var SCALE_MAX = 1.75;
  var SCALE_STEP = 0.12;
 
  // ------------------------------
  // Utilities: storage (localStorage + cookie fallback)
  // ------------------------------
   function lsAvailable() {
     try {
      var x = '__bhc_test__';
      window.localStorage.setItem(x, x);
      window.localStorage.removeItem(x);
      return true;
     } catch (e) {
      return false;
    }
  }
 
  function setCookie(name, value, days) {
    var maxAge = days * 24 * 60 * 60;
     document.cookie =
     document.cookie =
       `${encodeURIComponent(COOKIE_PREFIX + name)}=${encodeURIComponent(String(value))}; ` +
       encodeURIComponent(name) + '=' + encodeURIComponent(value) +
       `expires=${exp.toUTCString()}; path=/; SameSite=Lax`;
      '; Max-Age=' + maxAge +
       '; Path=/' +
      '; SameSite=Lax';
   }
   }


   function getCookie(name, fallback = "") {
   function getCookie(name) {
     const key = encodeURIComponent(COOKIE_PREFIX + name) + "=";
     var target = encodeURIComponent(name) + '=';
     const parts = document.cookie.split(";").map(s => s.trim());
     var parts = document.cookie.split(';');
     for (const p of parts) {
     for (var i = 0; i < parts.length; i++) {
       if (p.startsWith(key)) return decodeURIComponent(p.substring(key.length));
      var c = parts[i].trim();
       if (c.indexOf(target) === 0) return decodeURIComponent(c.substring(target.length));
     }
     }
     return fallback;
     return null;
   }
   }


   function delCookie(name) {
   function storageGet() {
     document.cookie =
     try {
       `${encodeURIComponent(COOKIE_PREFIX + name)}=; ` +
      if (lsAvailable()) {
       `expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`;
        return window.localStorage.getItem(STORAGE_KEY);
      }
      return getCookie(STORAGE_KEY);
    } catch (e) {
      return null;
    }
  }
 
  function storageSet(val) {
    try {
       if (lsAvailable()) {
        window.localStorage.setItem(STORAGE_KEY, val);
       } else {
        setCookie(STORAGE_KEY, val, COOKIE_FALLBACK_DAYS);
      }
    } catch (e) {
      // ignore
    }
  }
 
  function storageClear() {
    try {
      if (lsAvailable()) {
        window.localStorage.removeItem(STORAGE_KEY);
      } else {
        setCookie(STORAGE_KEY, '', -1);
      }
    } catch (e) {
      // ignore
    }
   }
   }


   // -------------------------------------------------------------------
   // ------------------------------
   // State + announcements
   // State
   // -------------------------------------------------------------------
   // ------------------------------
   const STATE = {
   var state = {
     contrast: false,
     contrast: false,
     underline: false,
     underline: false,
     spacing: "off",         // off | plus | minus
     spacing: 'normal', // normal | plus | minus
     cursor: "off",           // off | white | yellow
     cursor: 'normal', // normal | white | yellow
     ruler: false,
     ruler: false,
     fontPct: 100            // 90..140 in steps of 10
     scale: 1.0
   };
   };


   let liveEl = null;
   function loadState() {
  let rulerEl = null;
    var raw = storageGet();
  let rulerY = 0;
    if (!raw) return;
    try {
      var parsed = JSON.parse(raw);
      if (parsed && typeof parsed === 'object') {
        state.contrast = !!parsed.contrast;
        state.underline = !!parsed.underline;
        state.spacing = (parsed.spacing === 'plus' || parsed.spacing === 'minus') ? parsed.spacing : 'normal';
        state.cursor = (parsed.cursor === 'white' || parsed.cursor === 'yellow') ? parsed.cursor : 'normal';
        state.ruler = !!parsed.ruler;


  function announce(msg) {
        var sc = Number(parsed.scale);
    if (!liveEl) return;
        if (isFinite(sc)) state.scale = clamp(sc, SCALE_MIN, SCALE_MAX);
    liveEl.textContent = "";
      }
     setTimeout(() => { liveEl.textContent = msg; }, 10);
     } catch (e) {
      // ignore bad data
    }
   }
   }


   function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
   function saveState() {
    storageSet(JSON.stringify(state));
  }


  // -------------------------------------------------------------------
   function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
  // Apply state to DOM
  // -------------------------------------------------------------------
   function applyState() {
    document.body.classList.toggle("a11y-contrast", !!STATE.contrast);
    document.body.classList.toggle("a11y-underline-links", !!STATE.underline);


     document.body.classList.toggle("a11y-spacing-plus", STATE.spacing === "plus");
  // ------------------------------
     document.body.classList.toggle("a11y-spacing-minus", STATE.spacing === "minus");
  // DOM helpers
  // ------------------------------
  function el(tag, attrs, children) {
     var node = document.createElement(tag);
    if (attrs) {
      Object.keys(attrs).forEach(function (k) {
        if (k === 'class') node.className = attrs[k];
        else if (k === 'html') node.innerHTML = attrs[k];
        else if (k === 'text') node.textContent = attrs[k];
        else node.setAttribute(k, attrs[k]);
      });
    }
     if (children && children.length) {
      children.forEach(function (c) { node.appendChild(c); });
    }
    return node;
  }


    document.body.classList.toggle("a11y-cursor-white", STATE.cursor === "white");
  // ------------------------------
     document.body.classList.toggle("a11y-cursor-yellow", STATE.cursor === "yellow");
  // Apply state to document
  // ------------------------------
  function applyContrast(on) {
     document.body.classList.toggle('a11y-contrast', !!on);
  }


     document.documentElement.style.fontSize = `${STATE.fontPct}%`;
  function applyUnderline(on) {
     document.body.classList.toggle('a11y-underline-links', !!on);
  }


    ensureRuler();
  function applySpacing(mode) {
     if (STATE.ruler) {
     document.body.classList.toggle('a11y-spacing-plus', mode === 'plus');
      rulerEl.hidden = false;
     document.body.classList.toggle('a11y-spacing-minus', mode === 'minus');
      setRulerY(rulerY || Math.floor(window.innerHeight / 2));
  }
      enableRulerListeners();
     } else {
      if (rulerEl) rulerEl.hidden = true;
      disableRulerListeners();
    }


     updateUiButtons();
  function applyCursor(mode) {
    document.body.classList.toggle('a11y-cursor-white', mode === 'white');
     document.body.classList.toggle('a11y-cursor-yellow', mode === 'yellow');
   }
   }


  // -------------------------------------------------------------------
   function applyRuler(on) {
  // Ruler support
     var r = document.getElementById('a11y-ruler');
  // -------------------------------------------------------------------
     if (!r) {
   function ensureRuler() {
       r = el('div', { id: 'a11y-ruler', hidden: '' });
     if (rulerEl) return;
       document.body.appendChild(r);
    rulerEl = document.getElementById("a11y-ruler");
     if (!rulerEl) {
       rulerEl = document.createElement("div");
      rulerEl.id = "a11y-ruler";
      rulerEl.hidden = true;
       document.body.appendChild(rulerEl);
     }
     }
    if (on) r.removeAttribute('hidden');
    else r.setAttribute('hidden', '');
   }
   }


   function setRulerY(y) {
   function applyScale(scale) {
     rulerY = clamp(y, 0, Math.max(0, window.innerHeight - 1));
     // Make it work in Timeless + Vector by scaling the ROOT so rem-based UIs move.
     if (rulerEl) rulerEl.style.transform = `translateY(${rulerY}px)`;
    // We do not try to “fix” hard-coded px components; that’s by design.
    var pct = Math.round(100 * clamp(scale, SCALE_MIN, SCALE_MAX));
     document.documentElement.style.fontSize = pct + '%';
   }
   }


   function onMouseMove(e) {
   function applyAll() {
     if (!STATE.ruler) return;
     applyContrast(state.contrast);
     setRulerY(e.clientY - 11);
    applyUnderline(state.underline);
    applySpacing(state.spacing);
    applyCursor(state.cursor);
    applyRuler(state.ruler);
     applyScale(state.scale);
   }
   }


   function onKeyDown(e) {
  // ------------------------------
     if (!STATE.ruler) return;
  // Skip links (appear on focus)
  // ------------------------------
   function ensureSkipLinks() {
     if (document.getElementById('bhc-skiplinks')) return;
 
    // Targets (best-effort across skins)
    var contentTarget =
      document.getElementById('mw-content-text') ||
      document.getElementById('mw-content') ||
      document.getElementById('content');
 
    var navTarget =
      document.getElementById('site-navigation') ||
      document.getElementById('mw-panel') ||
      document.getElementById('p-navigation');
 
    var searchTarget = document.getElementById('p-search');
 
    // We'll anchor the toggle itself.
    var a11yTargetId = 'a11y-toolbar';
 
    var wrap = el('div', { id: 'bhc-skiplinks' });


     if (e.key === "ArrowDown") {
     function addSkip(label, targetEl, fallbackId) {
       e.preventDefault();
       var href = '#';
       setRulerY(rulerY + 10);
       if (targetEl && targetEl.id) href = '#' + targetEl.id;
      announce("Reading ruler moved down");
       else if (fallbackId) href = '#' + fallbackId;
    } else if (e.key === "ArrowUp") {
       else return;
      e.preventDefault();
 
       setRulerY(rulerY - 10);
       wrap.appendChild(el('a', { class: 'skip-link', href: href, text: label }));
       announce("Reading ruler moved up");
    } else if (e.key === "Escape") {
       e.preventDefault();
      STATE.ruler = false;
      setCookie("ruler", "0");
      applyState();
      announce("Reading ruler off");
     }
     }
    addSkip('Skip to content', contentTarget);
    addSkip('Skip to navigation', navTarget);
    if (searchTarget) addSkip('Skip to search', searchTarget);
    addSkip('Skip to accessibility tools', null, a11yTargetId);
    // Insert right at start of body
    document.body.insertBefore(wrap, document.body.firstChild);
   }
   }


   let rulerListenersOn = false;
   // ------------------------------
   function enableRulerListeners() {
  // UI: toggle + panel
     if (rulerListenersOn) return;
  // ------------------------------
     rulerListenersOn = true;
  var toggleBtn, panel, live, statusLine;
     window.addEventListener("mousemove", onMouseMove, { passive: true });
 
    window.addEventListener("keydown", onKeyDown);
   function announce(msg) {
    window.addEventListener("resize", () => setRulerY(rulerY), { passive: true });
     if (!live) return;
     live.textContent = ''; // nudge SRs
     window.setTimeout(function () { live.textContent = msg; }, 10);
   }
   }
   function disableRulerListeners() {
 
     if (!rulerListenersOn) return;
   function updateStatus() {
     rulerListenersOn = false;
     if (!statusLine) return;
     window.removeEventListener("mousemove", onMouseMove);
 
     window.removeEventListener("keydown", onKeyDown);
    var bits = [];
    if (state.contrast) bits.push('Contrast');
    if (state.underline) bits.push('Underlined links');
    if (state.spacing === 'plus') bits.push('Spacing +');
    if (state.spacing === 'minus') bits.push('Spacing -');
    if (state.cursor === 'white') bits.push('White cursor');
     if (state.cursor === 'yellow') bits.push('Yellow cursor');
    if (state.ruler) bits.push('Reading ruler');
     if (Math.abs(state.scale - 1.0) > 0.01) bits.push('Text size ' + Math.round(state.scale * 100) + '%');
 
     statusLine.textContent = bits.length ? ('On: ' + bits.join(' · ')) : 'All settings are currently default.';
   }
   }


  // -------------------------------------------------------------------
   function btn(label, onClick) {
  // UI creation
     var b = el('button', { type: 'button', class: 'a11y-btn', 'aria-pressed': 'false', text: label });
  // -------------------------------------------------------------------
    b.addEventListener('click', onClick);
   function ensureUi() {
    return b;
     if (document.getElementById("a11y-toggle")) return;
  }


    // Help link goes to the wiki page (you said: index.php/Accessibility)
  function setPressed(buttonEl, pressed) {
     const helpHref = (window.mw && mw.util && mw.util.getUrl)
     buttonEl.setAttribute('aria-pressed', pressed ? 'true' : 'false');
      ? mw.util.getUrl("Accessibility")
  }
      : "/index.php/Accessibility";


    const toggle = document.createElement("button");
  function openPanel() {
     toggle.type = "button";
     panel.removeAttribute('hidden');
    toggle.id = "a11y-toggle";
     toggleBtn.setAttribute('aria-expanded', 'true');
    toggle.className = "a11y-toggle";
     // Focus first control
     toggle.setAttribute("aria-label", "Accessibility options");
    var firstBtn = panel.querySelector('button');
     toggle.setAttribute("aria-haspopup", "dialog");
     if (firstBtn) firstBtn.focus({ preventScroll: true });
     toggle.setAttribute("aria-expanded", "false");
  }


     toggle.innerHTML = `
  function closePanel() {
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
     panel.setAttribute('hidden', '');
        <path d="M12 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 12 2zm9 7h-6.5c-.7 0-1.3.4-1.6 1L12 12l-.9-2c-.3-.6-.9-1-1.6-1H3a1 1 0 0 0 0 2h5.3l1.3 2.9-1.7 6.2a1 1 0 0 0 1.9.6l1.2-4.3 1.2 4.3a1 1 0 0 0 1.9-.6l-1.7-6.2 1.3-2.9H21a1 1 0 0 0 0-2z"/>
     toggleBtn.setAttribute('aria-expanded', 'false');
      </svg>
  }
    `;
     document.body.appendChild(toggle);


    const panel = document.createElement("div");
  function togglePanel() {
     panel.id = "a11y-panel";
     var isHidden = panel.hasAttribute('hidden');
    panel.className = "a11y-panel";
     if (isHidden) openPanel();
     panel.setAttribute("role", "dialog");
     else closePanel();
     panel.setAttribute("aria-label", "Accessibility options");
  }
    panel.hidden = true;


     panel.innerHTML = `
  function buildUI() {
       <div class="a11y-head">
    // Toggle button
        <h2>Accessibility</h2>
     toggleBtn = el('button', {
        <a class="a11y-help" href="${helpHref}" aria-label="Accessibility help">?</a>
       id: 'a11y-toolbar',
       </div>
      type: 'button',
      class: 'a11y-toggle',
      'aria-label': 'Accessibility options',
      'aria-haspopup': 'dialog',
       'aria-expanded': 'false'
    });


       <div class="a11y-row" role="group" aria-label="Text size">
    // Icon (inline SVG)
        <button type="button" class="a11y-btn" data-action="font-minus" aria-pressed="false" aria-label="Decrease text size">A−</button>
    toggleBtn.innerHTML =
        <button type="button" class="a11y-btn" data-action="font-plus" aria-pressed="false" aria-label="Increase text size">A+</button>
       "<svg viewBox='0 0 24 24' aria-hidden='true' focusable='false'>" +
        <button type="button" class="a11y-btn" data-action="font-reset" aria-pressed="false" aria-label="Reset text size">Reset</button>
      "<path d='M12 2a2 2 0 1 0 0 4a2 2 0 0 0 0-4Zm-1 6h2c.6 0 1 .4 1 1v2h6v2h-5v9h-2v-5H11v5H9v-9H4v-2h6V9c0-.6.4-1 1-1Z'/>" +
       </div>
       "</svg>";


       <div class="a11y-row" role="group" aria-label="Contrast and links">
    toggleBtn.addEventListener('click', function (e) {
        <button type="button" class="a11y-btn" data-action="contrast" aria-pressed="false">High contrast</button>
       e.preventDefault();
        <button type="button" class="a11y-btn" data-action="underline" aria-pressed="false">Underline links</button>
      togglePanel();
      </div>
    });


      <div class="a11y-row" role="group" aria-label="Text spacing">
    // Panel
        <button type="button" class="a11y-btn" data-action="spacing-plus" aria-pressed="false">More spacing</button>
    panel = el('div', { class: 'a11y-panel', role: 'dialog', 'aria-label': 'Accessibility options', hidden: '' });
        <button type="button" class="a11y-btn" data-action="spacing-minus" aria-pressed="false">Less spacing</button>
        <button type="button" class="a11y-btn" data-action="spacing-reset" aria-pressed="false">Spacing reset</button>
      </div>


      <div class="a11y-row" role="group" aria-label="Cursor and reading aid">
    var head = el('div', { class: 'a11y-head' });
        <button type="button" class="a11y-btn" data-action="cursor" aria-pressed="false" aria-label="Large cursor setting">Large cursor: Off</button>
    head.appendChild(el('h2', { text: 'Accessibility' }));
        <button type="button" class="a11y-btn" data-action="ruler" aria-pressed="false" aria-label="Toggle reading ruler">Reading ruler</button>
      </div>


       <div class="a11y-row" role="group" aria-label="Reset all">
    // Help link (your ? link)
        <button type="button" class="a11y-btn" data-action="reset" aria-pressed="false">Reset all</button>
    var help = el('a', {
       </div>
       class: 'a11y-help',
      href: '/index.php/Accessibility',
      text: '?',
       title: 'Accessibility help'
    });
    head.appendChild(help);


      <div class="a11y-hint">
    panel.appendChild(head);
        Settings are saved on this device. Large cursor cycles <strong>Off → White → Yellow → Off</strong>.
        When Reading ruler is on, use <strong>↑/↓</strong> to move it and <strong>Esc</strong> to turn it off.
      </div>


      <div id="a11y-live" class="a11y-sr" aria-live="polite" aria-atomic="true"></div>
    statusLine = el('div', { class: 'a11y-hint', text: '' });
    `;
     panel.appendChild(statusLine);
     document.body.appendChild(panel);


     liveEl = panel.querySelector("#a11y-live");
     // Live region for announcements
    live = el('div', { class: 'a11y-sr', 'aria-live': 'polite', 'aria-atomic': 'true' });
    panel.appendChild(live);


     function openPanel() {
     // Row: Contrast + Underline
       panel.hidden = false;
    var row1 = el('div', { class: 'a11y-row' });
      toggle.setAttribute("aria-expanded", "true");
    var bContrast = btn('Contrast', function () {
       const firstBtn = panel.querySelector("button.a11y-btn");
       state.contrast = !state.contrast;
       if (firstBtn) firstBtn.focus();
       applyContrast(state.contrast);
    }
       setPressed(bContrast, state.contrast);
    function closePanel() {
      saveState();
      panel.hidden = true;
       updateStatus();
       toggle.setAttribute("aria-expanded", "false");
       announce(state.contrast ? 'High contrast enabled' : 'High contrast disabled');
       toggle.focus();
     });
     }


     toggle.addEventListener("click", () => {
     var bUnderline = btn('Underline links', function () {
       if (panel.hidden) openPanel(); else closePanel();
      state.underline = !state.underline;
      applyUnderline(state.underline);
       setPressed(bUnderline, state.underline);
      saveState();
      updateStatus();
      announce(state.underline ? 'Underlined links enabled' : 'Underlined links disabled');
     });
     });


     document.addEventListener("click", (e) => {
     row1.appendChild(bContrast);
       if (panel.hidden) return;
    row1.appendChild(bUnderline);
       if (panel.contains(e.target) || toggle.contains(e.target)) return;
    panel.appendChild(row1);
       closePanel();
 
    // Row: Text size
    var row2 = el('div', { class: 'a11y-row' });
    var bSmaller = btn('Text −', function () {
       state.scale = clamp(state.scale - SCALE_STEP, SCALE_MIN, SCALE_MAX);
       applyScale(state.scale);
      saveState();
      updateStatus();
       announce('Text size ' + Math.round(state.scale * 100) + ' percent');
     });
     });


     document.addEventListener("keydown", (e) => {
     var bBigger = btn('Text +', function () {
       if (panel.hidden) return;
       state.scale = clamp(state.scale + SCALE_STEP, SCALE_MIN, SCALE_MAX);
       if (e.key === "Escape") {
       applyScale(state.scale);
        e.preventDefault();
      saveState();
        closePanel();
      updateStatus();
       }
       announce('Text size ' + Math.round(state.scale * 100) + ' percent');
     });
     });


     panel.addEventListener("click", (e) => {
    row2.appendChild(bSmaller);
       const btn = e.target.closest("button[data-action]");
    row2.appendChild(bBigger);
       if (!btn) return;
     panel.appendChild(row2);
       const action = btn.getAttribute("data-action") || "";
 
       handleAction(action);
    // Row: Spacing
    var row3 = el('div', { class: 'a11y-row' });
    var bSpacePlus = btn('Spacing +', function () {
       state.spacing = (state.spacing === 'plus') ? 'normal' : 'plus';
      applySpacing(state.spacing);
      setPressed(bSpacePlus, state.spacing === 'plus');
      setPressed(bSpaceMinus, state.spacing === 'minus');
       saveState();
       updateStatus();
       announce(state.spacing === 'plus' ? 'Text spacing increased' : 'Text spacing normal');
     });
     });
  }


  function updateUiButtons() {
    var bSpaceMinus = btn('Spacing −', function () {
    const panel = document.getElementById("a11y-panel");
      state.spacing = (state.spacing === 'minus') ? 'normal' : 'minus';
     if (!panel) return;
      applySpacing(state.spacing);
      setPressed(bSpaceMinus, state.spacing === 'minus');
      setPressed(bSpacePlus, state.spacing === 'plus');
      saveState();
      updateStatus();
      announce(state.spacing === 'minus' ? 'Text spacing reduced' : 'Text spacing normal');
     });


     const setPressed = (action, pressed) => {
     row3.appendChild(bSpacePlus);
      const b = panel.querySelector(`button[data-action="${action}"]`);
    row3.appendChild(bSpaceMinus);
      if (b) b.setAttribute("aria-pressed", pressed ? "true" : "false");
    panel.appendChild(row3);
    };


     setPressed("contrast", STATE.contrast);
     // Row: Cursor
     setPressed("underline", STATE.underline);
    var row4 = el('div', { class: 'a11y-row' });
    setPressed("ruler", STATE.ruler);
     var bCurWhite = btn('Cursor (white)', function () {
    setPressed("cursor", STATE.cursor !== "off");
      state.cursor = (state.cursor === 'white') ? 'normal' : 'white';
    setPressed("spacing-plus", STATE.spacing === "plus");
      applyCursor(state.cursor);
    setPressed("spacing-minus", STATE.spacing === "minus");
      setPressed(bCurWhite, state.cursor === 'white');
    setPressed("spacing-reset", false);
      setPressed(bCurYellow, state.cursor === 'yellow');
    setPressed("font-plus", false);
      saveState();
    setPressed("font-minus", false);
      updateStatus();
    setPressed("font-reset", STATE.fontPct !== 100);
      announce(state.cursor === 'white' ? 'Large white cursor enabled' : 'Cursor normal');
     setPressed("reset", false);
     });


     const cursorBtn = panel.querySelector('button[data-action="cursor"]');
     var bCurYellow = btn('Cursor (yellow)', function () {
    if (cursorBtn) {
       state.cursor = (state.cursor === 'yellow') ? 'normal' : 'yellow';
       const label = (STATE.cursor === "off") ? "Off" : (STATE.cursor === "white") ? "White" : "Yellow";
      applyCursor(state.cursor);
       cursorBtn.textContent = `Large cursor: ${label}`;
      setPressed(bCurYellow, state.cursor === 'yellow');
       cursorBtn.setAttribute(
       setPressed(bCurWhite, state.cursor === 'white');
        "aria-label",
       saveState();
        STATE.cursor === "off"
      updateStatus();
          ? "Large cursor off. Press to turn on"
      announce(state.cursor === 'yellow' ? 'Large yellow cursor enabled' : 'Cursor normal');
          : `Large cursor ${label}. Press to change colour or turn off`
     });
      );
     }
  }


  // -------------------------------------------------------------------
     row4.appendChild(bCurWhite);
  // Actions
    row4.appendChild(bCurYellow);
  // -------------------------------------------------------------------
    panel.appendChild(row4);
  function handleAction(action) {
     switch (action) {
      case "contrast":
        STATE.contrast = !STATE.contrast;
        setCookie("contrast", STATE.contrast ? "1" : "0");
        applyState();
        announce(`High contrast ${STATE.contrast ? "on" : "off"}`);
        return;


      case "underline":
    // Row: Ruler + Reset
        STATE.underline = !STATE.underline;
    var row5 = el('div', { class: 'a11y-row' });
        setCookie("underline", STATE.underline ? "1" : "0");
    var bRuler = btn('Reading ruler', function () {
        applyState();
      state.ruler = !state.ruler;
        announce(`Underline links ${STATE.underline ? "on" : "off"}`);
      applyRuler(state.ruler);
        return;
      setPressed(bRuler, state.ruler);
      saveState();
      updateStatus();
      announce(state.ruler ? 'Reading ruler enabled' : 'Reading ruler disabled');
    });


       case "spacing-plus":
    var bReset = btn('Reset all', function () {
         STATE.spacing = (STATE.spacing === "plus") ? "off" : "plus";
       state = {
        setCookie("spacing", STATE.spacing);
        contrast: false,
        applyState();
        underline: false,
        announce(STATE.spacing === "plus" ? "Text spacing increased" : "Text spacing normal");
        spacing: 'normal',
        return;
         cursor: 'normal',
        ruler: false,
        scale: 1.0
      };
      storageClear();
      applyAll();
      // Update pressed states
      setPressed(bContrast, false);
      setPressed(bUnderline, false);
      setPressed(bSpacePlus, false);
      setPressed(bSpaceMinus, false);
      setPressed(bCurWhite, false);
      setPressed(bCurYellow, false);
      setPressed(bRuler, false);
      updateStatus();
      announce('Accessibility settings reset');
    });


      case "spacing-minus":
    row5.appendChild(bRuler);
        STATE.spacing = (STATE.spacing === "minus") ? "off" : "minus";
    row5.appendChild(bReset);
        setCookie("spacing", STATE.spacing);
    panel.appendChild(row5);
        applyState();
        announce(STATE.spacing === "minus" ? "Text spacing decreased" : "Text spacing normal");
        return;


       case "spacing-reset":
    // Wire global close behaviour
        STATE.spacing = "off";
    document.addEventListener('keydown', function (e) {
         setCookie("spacing", "off");
       if (e.key === 'Escape') {
        applyState();
         if (!panel.hasAttribute('hidden')) {
        announce("Text spacing reset");
          closePanel();
         return;
          toggleBtn.focus({ preventScroll: true });
         }
      }
    });


      case "cursor":
    // Click outside closes
        STATE.cursor = (STATE.cursor === "off") ? "white" : (STATE.cursor === "white") ? "yellow" : "off";
    document.addEventListener('mousedown', function (e) {
        setCookie("cursor", STATE.cursor);
      if (panel.hasAttribute('hidden')) return;
        applyState();
      if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
        announce(
      closePanel();
          STATE.cursor === "off" ? "Large cursor off" :
    });
          STATE.cursor === "white" ? "Large cursor white" :
          "Large cursor yellow"
        );
        return;


      case "ruler":
    // Focus leaving closes (gentle)
        STATE.ruler = !STATE.ruler;
    document.addEventListener('focusin', function (e) {
        setCookie("ruler", STATE.ruler ? "1" : "0");
      if (panel.hasAttribute('hidden')) return;
        if (STATE.ruler && !rulerY) rulerY = Math.floor(window.innerHeight / 2);
      if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
        applyState();
      closePanel();
        announce(`Reading ruler ${STATE.ruler ? "on" : "off"}`);
    });
        return;


      case "font-plus":
    document.body.appendChild(toggleBtn);
        STATE.fontPct = clamp(STATE.fontPct + 10, 90, 140);
    document.body.appendChild(panel);
        setCookie("fontPct", String(STATE.fontPct));
        applyState();
        announce("Text size increased");
        return;
 
      case "font-minus":
        STATE.fontPct = clamp(STATE.fontPct - 10, 90, 140);
        setCookie("fontPct", String(STATE.fontPct));
        applyState();
        announce("Text size decreased");
        return;


      case "font-reset":
    // Initialise pressed states based on state
        STATE.fontPct = 100;
    setPressed(bContrast, state.contrast);
        setCookie("fontPct", "100");
    setPressed(bUnderline, state.underline);
        applyState();
    setPressed(bSpacePlus, state.spacing === 'plus');
        announce("Text size reset");
    setPressed(bSpaceMinus, state.spacing === 'minus');
        return;
    setPressed(bCurWhite, state.cursor === 'white');
    setPressed(bCurYellow, state.cursor === 'yellow');
    setPressed(bRuler, state.ruler);


      case "reset":
    updateStatus();
        ["contrast","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie);
        STATE.contrast = false;
        STATE.underline = false;
        STATE.spacing = "off";
        STATE.cursor = "off";
        STATE.ruler = false;
        STATE.fontPct = 100;
        rulerY = 0;
        applyState();
        announce("Accessibility settings reset");
        return;
    }
   }
   }


   // -------------------------------------------------------------------
   // ------------------------------
   // Load saved settings
   // Ruler tracking
   // -------------------------------------------------------------------
   // ------------------------------
   function loadState() {
   function initRulerTracking() {
     STATE.contrast = getCookie("contrast", "0") === "1";
     var r = document.getElementById('a11y-ruler');
     STATE.underline = getCookie("underline", "0") === "1";
     if (!r) return;


     const spacing = getCookie("spacing", "off");
     function moveTo(y) {
    STATE.spacing = (spacing === "plus" || spacing === "minus") ? spacing : "off";
      // Keep within viewport
      var top = Math.max(0, Math.min(window.innerHeight - r.offsetHeight, y - (r.offsetHeight / 2)));
      r.style.top = top + 'px';
    }


     const cursor = getCookie("cursor", "off");
     document.addEventListener('mousemove', function (e) {
    STATE.cursor = (cursor === "white" || cursor === "yellow") ? cursor : "off";
      if (!state.ruler) return;
      moveTo(e.clientY);
    }, { passive: true });


     STATE.ruler = getCookie("ruler", "0") === "1";
     // If user scrolls with keyboard, keep ruler near top-ish
    window.addEventListener('scroll', function () {
      if (!state.ruler) return;
      // Do not jump aggressively; only ensure it’s visible
      var current = parseFloat(r.style.top || '0');
      if (!isFinite(current)) current = 0;
      if (current < 0 || current > window.innerHeight) moveTo(window.innerHeight * 0.25);
    }, { passive: true });
  }


    const fp = parseInt(getCookie("fontPct", "100"), 10);
  // ------------------------------
     STATE.fontPct = clamp(isNaN(fp) ? 100 : fp, 90, 140);
  // Boot
  }
  // ------------------------------
  function boot() {
     if (!document.body) return;


  // -------------------------------------------------------------------
  // Init
  // -------------------------------------------------------------------
  function init() {
    ensureUi();
     loadState();
     loadState();
     applyState();
     applyAll();
    ensureSkipLinks();
    buildUI();
    initRulerTracking();
   }
   }


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

Revision as of 11:35, 27 January 2026

/* =========================================================
   BHC Accessibility Gadget – MediaWiki (Timeless + Vector)
   - Floating toggle + panel
   - Contrast, underline, text size, spacing, cursor, ruler
   - Skip links injection
   - Persistence via localStorage (cookie fallback)
   ========================================================= */
(function () {
  'use strict';

  // ------------------------------
  // Config
  // ------------------------------
  var STORAGE_KEY = 'bhc_a11y_v1';
  var COOKIE_FALLBACK_DAYS = 365;

  // Text scale bounds (aggressive enough to be obvious in Timeless)
  var SCALE_MIN = 0.90;
  var SCALE_MAX = 1.75;
  var SCALE_STEP = 0.12;

  // ------------------------------
  // Utilities: storage (localStorage + cookie fallback)
  // ------------------------------
  function lsAvailable() {
    try {
      var x = '__bhc_test__';
      window.localStorage.setItem(x, x);
      window.localStorage.removeItem(x);
      return true;
    } catch (e) {
      return false;
    }
  }

  function setCookie(name, value, days) {
    var maxAge = days * 24 * 60 * 60;
    document.cookie =
      encodeURIComponent(name) + '=' + encodeURIComponent(value) +
      '; Max-Age=' + maxAge +
      '; Path=/' +
      '; SameSite=Lax';
  }

  function getCookie(name) {
    var target = encodeURIComponent(name) + '=';
    var parts = document.cookie.split(';');
    for (var i = 0; i < parts.length; i++) {
      var c = parts[i].trim();
      if (c.indexOf(target) === 0) return decodeURIComponent(c.substring(target.length));
    }
    return null;
  }

  function storageGet() {
    try {
      if (lsAvailable()) {
        return window.localStorage.getItem(STORAGE_KEY);
      }
      return getCookie(STORAGE_KEY);
    } catch (e) {
      return null;
    }
  }

  function storageSet(val) {
    try {
      if (lsAvailable()) {
        window.localStorage.setItem(STORAGE_KEY, val);
      } else {
        setCookie(STORAGE_KEY, val, COOKIE_FALLBACK_DAYS);
      }
    } catch (e) {
      // ignore
    }
  }

  function storageClear() {
    try {
      if (lsAvailable()) {
        window.localStorage.removeItem(STORAGE_KEY);
      } else {
        setCookie(STORAGE_KEY, '', -1);
      }
    } catch (e) {
      // ignore
    }
  }

  // ------------------------------
  // State
  // ------------------------------
  var state = {
    contrast: false,
    underline: false,
    spacing: 'normal', // normal | plus | minus
    cursor: 'normal',  // normal | white | yellow
    ruler: false,
    scale: 1.0
  };

  function loadState() {
    var raw = storageGet();
    if (!raw) return;
    try {
      var parsed = JSON.parse(raw);
      if (parsed && typeof parsed === 'object') {
        state.contrast = !!parsed.contrast;
        state.underline = !!parsed.underline;
        state.spacing = (parsed.spacing === 'plus' || parsed.spacing === 'minus') ? parsed.spacing : 'normal';
        state.cursor = (parsed.cursor === 'white' || parsed.cursor === 'yellow') ? parsed.cursor : 'normal';
        state.ruler = !!parsed.ruler;

        var sc = Number(parsed.scale);
        if (isFinite(sc)) state.scale = clamp(sc, SCALE_MIN, SCALE_MAX);
      }
    } catch (e) {
      // ignore bad data
    }
  }

  function saveState() {
    storageSet(JSON.stringify(state));
  }

  function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }

  // ------------------------------
  // DOM helpers
  // ------------------------------
  function el(tag, attrs, children) {
    var node = document.createElement(tag);
    if (attrs) {
      Object.keys(attrs).forEach(function (k) {
        if (k === 'class') node.className = attrs[k];
        else if (k === 'html') node.innerHTML = attrs[k];
        else if (k === 'text') node.textContent = attrs[k];
        else node.setAttribute(k, attrs[k]);
      });
    }
    if (children && children.length) {
      children.forEach(function (c) { node.appendChild(c); });
    }
    return node;
  }

  // ------------------------------
  // Apply state to document
  // ------------------------------
  function applyContrast(on) {
    document.body.classList.toggle('a11y-contrast', !!on);
  }

  function applyUnderline(on) {
    document.body.classList.toggle('a11y-underline-links', !!on);
  }

  function applySpacing(mode) {
    document.body.classList.toggle('a11y-spacing-plus', mode === 'plus');
    document.body.classList.toggle('a11y-spacing-minus', mode === 'minus');
  }

  function applyCursor(mode) {
    document.body.classList.toggle('a11y-cursor-white', mode === 'white');
    document.body.classList.toggle('a11y-cursor-yellow', mode === 'yellow');
  }

  function applyRuler(on) {
    var r = document.getElementById('a11y-ruler');
    if (!r) {
      r = el('div', { id: 'a11y-ruler', hidden: '' });
      document.body.appendChild(r);
    }
    if (on) r.removeAttribute('hidden');
    else r.setAttribute('hidden', '');
  }

  function applyScale(scale) {
    // Make it work in Timeless + Vector by scaling the ROOT so rem-based UIs move.
    // We do not try to “fix” hard-coded px components; that’s by design.
    var pct = Math.round(100 * clamp(scale, SCALE_MIN, SCALE_MAX));
    document.documentElement.style.fontSize = pct + '%';
  }

  function applyAll() {
    applyContrast(state.contrast);
    applyUnderline(state.underline);
    applySpacing(state.spacing);
    applyCursor(state.cursor);
    applyRuler(state.ruler);
    applyScale(state.scale);
  }

  // ------------------------------
  // Skip links (appear on focus)
  // ------------------------------
  function ensureSkipLinks() {
    if (document.getElementById('bhc-skiplinks')) return;

    // Targets (best-effort across skins)
    var contentTarget =
      document.getElementById('mw-content-text') ||
      document.getElementById('mw-content') ||
      document.getElementById('content');

    var navTarget =
      document.getElementById('site-navigation') ||
      document.getElementById('mw-panel') ||
      document.getElementById('p-navigation');

    var searchTarget = document.getElementById('p-search');

    // We'll anchor the toggle itself.
    var a11yTargetId = 'a11y-toolbar';

    var wrap = el('div', { id: 'bhc-skiplinks' });

    function addSkip(label, targetEl, fallbackId) {
      var href = '#';
      if (targetEl && targetEl.id) href = '#' + targetEl.id;
      else if (fallbackId) href = '#' + fallbackId;
      else return;

      wrap.appendChild(el('a', { class: 'skip-link', href: href, text: label }));
    }

    addSkip('Skip to content', contentTarget);
    addSkip('Skip to navigation', navTarget);
    if (searchTarget) addSkip('Skip to search', searchTarget);
    addSkip('Skip to accessibility tools', null, a11yTargetId);

    // Insert right at start of body
    document.body.insertBefore(wrap, document.body.firstChild);
  }

  // ------------------------------
  // UI: toggle + panel
  // ------------------------------
  var toggleBtn, panel, live, statusLine;

  function announce(msg) {
    if (!live) return;
    live.textContent = ''; // nudge SRs
    window.setTimeout(function () { live.textContent = msg; }, 10);
  }

  function updateStatus() {
    if (!statusLine) return;

    var bits = [];
    if (state.contrast) bits.push('Contrast');
    if (state.underline) bits.push('Underlined links');
    if (state.spacing === 'plus') bits.push('Spacing +');
    if (state.spacing === 'minus') bits.push('Spacing -');
    if (state.cursor === 'white') bits.push('White cursor');
    if (state.cursor === 'yellow') bits.push('Yellow cursor');
    if (state.ruler) bits.push('Reading ruler');
    if (Math.abs(state.scale - 1.0) > 0.01) bits.push('Text size ' + Math.round(state.scale * 100) + '%');

    statusLine.textContent = bits.length ? ('On: ' + bits.join(' · ')) : 'All settings are currently default.';
  }

  function btn(label, onClick) {
    var b = el('button', { type: 'button', class: 'a11y-btn', 'aria-pressed': 'false', text: label });
    b.addEventListener('click', onClick);
    return b;
  }

  function setPressed(buttonEl, pressed) {
    buttonEl.setAttribute('aria-pressed', pressed ? 'true' : 'false');
  }

  function openPanel() {
    panel.removeAttribute('hidden');
    toggleBtn.setAttribute('aria-expanded', 'true');
    // Focus first control
    var firstBtn = panel.querySelector('button');
    if (firstBtn) firstBtn.focus({ preventScroll: true });
  }

  function closePanel() {
    panel.setAttribute('hidden', '');
    toggleBtn.setAttribute('aria-expanded', 'false');
  }

  function togglePanel() {
    var isHidden = panel.hasAttribute('hidden');
    if (isHidden) openPanel();
    else closePanel();
  }

  function buildUI() {
    // Toggle button
    toggleBtn = el('button', {
      id: 'a11y-toolbar',
      type: 'button',
      class: 'a11y-toggle',
      'aria-label': 'Accessibility options',
      'aria-haspopup': 'dialog',
      'aria-expanded': 'false'
    });

    // Icon (inline SVG)
    toggleBtn.innerHTML =
      "<svg viewBox='0 0 24 24' aria-hidden='true' focusable='false'>" +
      "<path d='M12 2a2 2 0 1 0 0 4a2 2 0 0 0 0-4Zm-1 6h2c.6 0 1 .4 1 1v2h6v2h-5v9h-2v-5H11v5H9v-9H4v-2h6V9c0-.6.4-1 1-1Z'/>" +
      "</svg>";

    toggleBtn.addEventListener('click', function (e) {
      e.preventDefault();
      togglePanel();
    });

    // Panel
    panel = el('div', { class: 'a11y-panel', role: 'dialog', 'aria-label': 'Accessibility options', hidden: '' });

    var head = el('div', { class: 'a11y-head' });
    head.appendChild(el('h2', { text: 'Accessibility' }));

    // Help link (your ? link)
    var help = el('a', {
      class: 'a11y-help',
      href: '/index.php/Accessibility',
      text: '?',
      title: 'Accessibility help'
    });
    head.appendChild(help);

    panel.appendChild(head);

    statusLine = el('div', { class: 'a11y-hint', text: '' });
    panel.appendChild(statusLine);

    // Live region for announcements
    live = el('div', { class: 'a11y-sr', 'aria-live': 'polite', 'aria-atomic': 'true' });
    panel.appendChild(live);

    // Row: Contrast + Underline
    var row1 = el('div', { class: 'a11y-row' });
    var bContrast = btn('Contrast', function () {
      state.contrast = !state.contrast;
      applyContrast(state.contrast);
      setPressed(bContrast, state.contrast);
      saveState();
      updateStatus();
      announce(state.contrast ? 'High contrast enabled' : 'High contrast disabled');
    });

    var bUnderline = btn('Underline links', function () {
      state.underline = !state.underline;
      applyUnderline(state.underline);
      setPressed(bUnderline, state.underline);
      saveState();
      updateStatus();
      announce(state.underline ? 'Underlined links enabled' : 'Underlined links disabled');
    });

    row1.appendChild(bContrast);
    row1.appendChild(bUnderline);
    panel.appendChild(row1);

    // Row: Text size
    var row2 = el('div', { class: 'a11y-row' });
    var bSmaller = btn('Text −', function () {
      state.scale = clamp(state.scale - SCALE_STEP, SCALE_MIN, SCALE_MAX);
      applyScale(state.scale);
      saveState();
      updateStatus();
      announce('Text size ' + Math.round(state.scale * 100) + ' percent');
    });

    var bBigger = btn('Text +', function () {
      state.scale = clamp(state.scale + SCALE_STEP, SCALE_MIN, SCALE_MAX);
      applyScale(state.scale);
      saveState();
      updateStatus();
      announce('Text size ' + Math.round(state.scale * 100) + ' percent');
    });

    row2.appendChild(bSmaller);
    row2.appendChild(bBigger);
    panel.appendChild(row2);

    // Row: Spacing
    var row3 = el('div', { class: 'a11y-row' });
    var bSpacePlus = btn('Spacing +', function () {
      state.spacing = (state.spacing === 'plus') ? 'normal' : 'plus';
      applySpacing(state.spacing);
      setPressed(bSpacePlus, state.spacing === 'plus');
      setPressed(bSpaceMinus, state.spacing === 'minus');
      saveState();
      updateStatus();
      announce(state.spacing === 'plus' ? 'Text spacing increased' : 'Text spacing normal');
    });

    var bSpaceMinus = btn('Spacing −', function () {
      state.spacing = (state.spacing === 'minus') ? 'normal' : 'minus';
      applySpacing(state.spacing);
      setPressed(bSpaceMinus, state.spacing === 'minus');
      setPressed(bSpacePlus, state.spacing === 'plus');
      saveState();
      updateStatus();
      announce(state.spacing === 'minus' ? 'Text spacing reduced' : 'Text spacing normal');
    });

    row3.appendChild(bSpacePlus);
    row3.appendChild(bSpaceMinus);
    panel.appendChild(row3);

    // Row: Cursor
    var row4 = el('div', { class: 'a11y-row' });
    var bCurWhite = btn('Cursor (white)', function () {
      state.cursor = (state.cursor === 'white') ? 'normal' : 'white';
      applyCursor(state.cursor);
      setPressed(bCurWhite, state.cursor === 'white');
      setPressed(bCurYellow, state.cursor === 'yellow');
      saveState();
      updateStatus();
      announce(state.cursor === 'white' ? 'Large white cursor enabled' : 'Cursor normal');
    });

    var bCurYellow = btn('Cursor (yellow)', function () {
      state.cursor = (state.cursor === 'yellow') ? 'normal' : 'yellow';
      applyCursor(state.cursor);
      setPressed(bCurYellow, state.cursor === 'yellow');
      setPressed(bCurWhite, state.cursor === 'white');
      saveState();
      updateStatus();
      announce(state.cursor === 'yellow' ? 'Large yellow cursor enabled' : 'Cursor normal');
    });

    row4.appendChild(bCurWhite);
    row4.appendChild(bCurYellow);
    panel.appendChild(row4);

    // Row: Ruler + Reset
    var row5 = el('div', { class: 'a11y-row' });
    var bRuler = btn('Reading ruler', function () {
      state.ruler = !state.ruler;
      applyRuler(state.ruler);
      setPressed(bRuler, state.ruler);
      saveState();
      updateStatus();
      announce(state.ruler ? 'Reading ruler enabled' : 'Reading ruler disabled');
    });

    var bReset = btn('Reset all', function () {
      state = {
        contrast: false,
        underline: false,
        spacing: 'normal',
        cursor: 'normal',
        ruler: false,
        scale: 1.0
      };
      storageClear();
      applyAll();
      // Update pressed states
      setPressed(bContrast, false);
      setPressed(bUnderline, false);
      setPressed(bSpacePlus, false);
      setPressed(bSpaceMinus, false);
      setPressed(bCurWhite, false);
      setPressed(bCurYellow, false);
      setPressed(bRuler, false);
      updateStatus();
      announce('Accessibility settings reset');
    });

    row5.appendChild(bRuler);
    row5.appendChild(bReset);
    panel.appendChild(row5);

    // Wire global close behaviour
    document.addEventListener('keydown', function (e) {
      if (e.key === 'Escape') {
        if (!panel.hasAttribute('hidden')) {
          closePanel();
          toggleBtn.focus({ preventScroll: true });
        }
      }
    });

    // Click outside closes
    document.addEventListener('mousedown', function (e) {
      if (panel.hasAttribute('hidden')) return;
      if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
      closePanel();
    });

    // Focus leaving closes (gentle)
    document.addEventListener('focusin', function (e) {
      if (panel.hasAttribute('hidden')) return;
      if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
      closePanel();
    });

    document.body.appendChild(toggleBtn);
    document.body.appendChild(panel);

    // Initialise pressed states based on state
    setPressed(bContrast, state.contrast);
    setPressed(bUnderline, state.underline);
    setPressed(bSpacePlus, state.spacing === 'plus');
    setPressed(bSpaceMinus, state.spacing === 'minus');
    setPressed(bCurWhite, state.cursor === 'white');
    setPressed(bCurYellow, state.cursor === 'yellow');
    setPressed(bRuler, state.ruler);

    updateStatus();
  }

  // ------------------------------
  // Ruler tracking
  // ------------------------------
  function initRulerTracking() {
    var r = document.getElementById('a11y-ruler');
    if (!r) return;

    function moveTo(y) {
      // Keep within viewport
      var top = Math.max(0, Math.min(window.innerHeight - r.offsetHeight, y - (r.offsetHeight / 2)));
      r.style.top = top + 'px';
    }

    document.addEventListener('mousemove', function (e) {
      if (!state.ruler) return;
      moveTo(e.clientY);
    }, { passive: true });

    // If user scrolls with keyboard, keep ruler near top-ish
    window.addEventListener('scroll', function () {
      if (!state.ruler) return;
      // Do not jump aggressively; only ensure it’s visible
      var current = parseFloat(r.style.top || '0');
      if (!isFinite(current)) current = 0;
      if (current < 0 || current > window.innerHeight) moveTo(window.innerHeight * 0.25);
    }, { passive: true });
  }

  // ------------------------------
  // Boot
  // ------------------------------
  function boot() {
    if (!document.body) return;

    loadState();
    applyAll();
    ensureSkipLinks();
    buildUI();
    initRulerTracking();
  }

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