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

No edit summary
Tag: Reverted
No edit summary
 
(25 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* =========================================================
/*
  BHC Accessibility Gadget – MediaWiki (Timeless + Vector)
⚠️ Do not reorder rules below this line — contrast overrides depend on cascade order.
  - Floating toggle + panel
*/
  - Contrast, underline, text size, spacing, cursor, ruler
  - Skip links injection
  - Persistence via localStorage (cookie fallback)
  ========================================================= */
(function () {
  'use strict';


  // ------------------------------
(() => {
  // Config
   "use strict";
  // ------------------------------
  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;
  // Storage policy:
   var SCALE_MAX = 1.75;
  // - localStorage first
   var SCALE_STEP = 0.12;
  // - cookie fallback only if localStorage unavailable
   // =========================================================
   const LS_KEY = "bhc_a11y_state_v2"; // bumped because state model changed
   const COOKIE_PREFIX = "a11y_";


  // ------------------------------
   function canUseLocalStorage() {
  // Utilities: storage (localStorage + cookie fallback)
  // ------------------------------
   function lsAvailable() {
     try {
     try {
       var x = '__bhc_test__';
       const k = "__a11y_test__";
       window.localStorage.setItem(x, x);
       window.localStorage.setItem(k, "1");
       window.localStorage.removeItem(x);
       window.localStorage.removeItem(k);
       return true;
       return true;
     } catch (e) {
     } catch (e) {
Line 33: Line 24:
     }
     }
   }
   }
  const HAS_LS = canUseLocalStorage();


   function setCookie(name, value, days) {
  // Cookie helpers (fallback)
     var maxAge = days * 24 * 60 * 60;
   function setCookie(name, value, days = 365) {
     const exp = new Date();
    exp.setTime(exp.getTime() + days * 24 * 60 * 60 * 1000);
     document.cookie =
     document.cookie =
       encodeURIComponent(name) + '=' + encodeURIComponent(value) +
       `${encodeURIComponent(COOKIE_PREFIX + name)}=${encodeURIComponent(String(value))}; ` +
       '; Max-Age=' + maxAge +
       `expires=${exp.toUTCString()}; path=/; SameSite=Lax`;
      '; 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 null;
     return fallback;
  }
  function delCookie(name) {
    document.cookie =
      `${encodeURIComponent(COOKIE_PREFIX + name)}=; ` +
      `expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`;
   }
   }


   function storageGet() {
  // Unified read/write
     try {
   function saveStateToStorage(stateObj) {
       if (lsAvailable()) {
     if (HAS_LS) {
         return window.localStorage.getItem(STORAGE_KEY);
       try {
         window.localStorage.setItem(LS_KEY, JSON.stringify(stateObj));
        return;
      } catch (e) {
        // fall through to cookies
       }
       }
      return getCookie(STORAGE_KEY);
    } catch (e) {
      return null;
     }
     }
    // cookie fallback (per-key)
    setCookie("contrastMode", stateObj.contrastMode); // off|yellow|white
    setCookie("underline", stateObj.underline ? "1" : "0");
    setCookie("spacing", stateObj.spacing);
    setCookie("cursor", stateObj.cursor);            // off|white|yellow
    setCookie("ruler", stateObj.ruler ? "1" : "0");
    setCookie("fontPct", String(stateObj.fontPct));
   }
   }


   function storageSet(val) {
   function loadStateFromStorage() {
     try {
     if (HAS_LS) {
       if (lsAvailable()) {
       try {
         window.localStorage.setItem(STORAGE_KEY, val);
         const raw = window.localStorage.getItem(LS_KEY);
       } else {
        if (!raw) return null;
         setCookie(STORAGE_KEY, val, COOKIE_FALLBACK_DAYS);
        const obj = JSON.parse(raw);
        return (obj && typeof obj === "object") ? obj : null;
       } catch (e) {
         // fall through to cookies
       }
       }
    } catch (e) {
      // ignore
     }
     }
    // cookie fallback (legacy / no-LS)
    return {
      contrastMode: getCookie("contrastMode", "off"),
      underline: getCookie("underline", "0") === "1",
      spacing: getCookie("spacing", "off"),
      cursor: getCookie("cursor", "off"),
      ruler: getCookie("ruler", "0") === "1",
      fontPct: parseInt(getCookie("fontPct", "100"), 10)
    };
   }
   }


   function storageClear() {
   function clearStoredState() {
     try {
     if (HAS_LS) {
      if (lsAvailable()) {
      try { window.localStorage.removeItem(LS_KEY); } catch (e) {}
        window.localStorage.removeItem(STORAGE_KEY);
       return;
      } else {
        setCookie(STORAGE_KEY, '', -1);
      }
    } catch (e) {
       // ignore
     }
     }
    ["contrastMode","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie);
   }
   }


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


   function loadState() {
   const FONT_STEPS = [90, 102, 114, 126, 138, 150, 162, 175];
    var raw = storageGet();
 
    if (!raw) return;
  let liveEl = null;
    try {
  let statusEl = null;
      var parsed = JSON.parse(raw);
  let rulerEl = null;
      if (parsed && typeof parsed === 'object') {
  let rulerY = 0;
        state.contrast = !!parsed.contrast;
 
        state.underline = !!parsed.underline;
  function announce(msg) {
        state.spacing = (parsed.spacing === 'plus' || parsed.spacing === 'minus') ? parsed.spacing : 'normal';
    if (!liveEl) return;
        state.cursor = (parsed.cursor === 'white' || parsed.cursor === 'yellow') ? parsed.cursor : 'normal';
    liveEl.textContent = "";
        state.ruler = !!parsed.ruler;
    setTimeout(() => { liveEl.textContent = msg; }, 10);
  }
 
  function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }


        var sc = Number(parsed.scale);
  function nextFontPct(current, dir) {
        if (isFinite(sc)) state.scale = clamp(sc, SCALE_MIN, SCALE_MAX);
    const cur = clamp(current, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]);
      }
    let idx = 0;
     } catch (e) {
     for (let i = 0; i < FONT_STEPS.length; i++) {
       // ignore bad data
       if (FONT_STEPS[i] >= cur) { idx = i; break; }
     }
     }
    if (dir > 0) return FONT_STEPS[Math.min(FONT_STEPS.length - 1, idx + 1)];
    if (FONT_STEPS[idx] === cur) return FONT_STEPS[Math.max(0, idx - 1)];
    return FONT_STEPS[Math.max(0, idx - 1)];
   }
   }


   function saveState() {
  // =========================================================
     storageSet(JSON.stringify(state));
  // Status line (display-only)
  // =========================================================
   function updateStatusLine() {
     if (!statusEl) return;
 
    const contrast =
      STATE.contrastMode === "yellow" ? "Contrast: Yellow/Black" :
      STATE.contrastMode === "white" ? "Contrast: White/Black" :
      "Contrast: Off";
 
    const text = `Text ${STATE.fontPct}%`;
    const underline = STATE.underline ? "Links: Underlined" : "Links: Normal";
    const spacing =
      STATE.spacing === "plus" ? "Spacing +" :
      STATE.spacing === "minus" ? "Spacing −" :
      "Spacing: Normal";
    const cursor =
      STATE.cursor === "yellow" ? "Cursor: Yellow" :
      STATE.cursor === "white" ? "Cursor: White" :
      "Cursor: Off";
    const ruler = STATE.ruler ? "Ruler: On" : "Ruler: Off";
 
    statusEl.textContent = `Status: ${contrast} · ${text} · ${underline} · ${spacing} · ${cursor} · ${ruler}`;
   }
   }


   function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
  // =========================================================
  // Apply state to DOM
  // =========================================================
   function applyState() {
    // Contrast scheme classes
    const contrastOn = STATE.contrastMode !== "off";
    document.body.classList.toggle("a11y-contrast", contrastOn);
    document.body.classList.toggle("a11y-contrast-yellow", STATE.contrastMode === "yellow");
    document.body.classList.toggle("a11y-contrast-white", STATE.contrastMode === "white");
 
    // Other features
    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");
 
    document.body.classList.toggle("a11y-cursor-white", STATE.cursor === "white");
    document.body.classList.toggle("a11y-cursor-yellow", STATE.cursor === "yellow");


  // ------------------------------
    document.documentElement.style.fontSize = `${STATE.fontPct}%`;
  // DOM helpers
 
  // ------------------------------
     ensureRuler();
  function el(tag, attrs, children) {
     if (STATE.ruler) {
     var node = document.createElement(tag);
       rulerEl.hidden = false;
     if (attrs) {
      setRulerY(rulerY || Math.floor(window.innerHeight / 2));
       Object.keys(attrs).forEach(function (k) {
      enableRulerListeners();
        if (k === 'class') node.className = attrs[k];
    } else {
        else if (k === 'html') node.innerHTML = attrs[k];
      if (rulerEl) rulerEl.hidden = true;
        else if (k === 'text') node.textContent = attrs[k];
      disableRulerListeners();
        else node.setAttribute(k, attrs[k]);
      });
     }
     }
     if (children && children.length) {
 
      children.forEach(function (c) { node.appendChild(c); });
     updateUiButtons();
     }
    updateStatusLine();
    return node;
     saveStateToStorage(STATE);
   }
   }


   // ------------------------------
   // =========================================================
   // Apply state to document
   // Ruler support
   // ------------------------------
   // =========================================================
   function applyContrast(on) {
   function ensureRuler() {
     document.body.classList.toggle('a11y-contrast', !!on);
     if (rulerEl) return;
    rulerEl = document.getElementById("a11y-ruler");
    if (!rulerEl) {
      rulerEl = document.createElement("div");
      rulerEl.id = "a11y-ruler";
      rulerEl.hidden = true;
      document.body.appendChild(rulerEl);
    }
   }
   }


   function applyUnderline(on) {
   function setRulerY(y) {
     document.body.classList.toggle('a11y-underline-links', !!on);
     rulerY = clamp(y, 0, Math.max(0, window.innerHeight - 1));
    if (rulerEl) rulerEl.style.transform = `translateY(${rulerY}px)`;
   }
   }


   function applySpacing(mode) {
   function onMouseMove(e) {
     document.body.classList.toggle('a11y-spacing-plus', mode === 'plus');
     if (!STATE.ruler) return;
     document.body.classList.toggle('a11y-spacing-minus', mode === 'minus');
     setRulerY(e.clientY - 11);
   }
   }


   function applyCursor(mode) {
   function onKeyDown(e) {
     document.body.classList.toggle('a11y-cursor-white', mode === 'white');
     if (!STATE.ruler) return;
    document.body.classList.toggle('a11y-cursor-yellow', mode === 'yellow');
  }


  function applyRuler(on) {
    if (e.key === "ArrowDown") {
     var r = document.getElementById('a11y-ruler');
      e.preventDefault();
     if (!r) {
      setRulerY(rulerY + 10);
       r = el('div', { id: 'a11y-ruler', hidden: '' });
      announce("Reading ruler moved down");
       document.body.appendChild(r);
     } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setRulerY(rulerY - 10);
      announce("Reading ruler moved up");
     } else if (e.key === "Escape") {
       e.preventDefault();
      STATE.ruler = false;
      applyState();
       announce("Reading ruler off");
     }
     }
    if (on) r.removeAttribute('hidden');
    else r.setAttribute('hidden', '');
   }
   }


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


   function applyAll() {
  let rulerListenersOn = false;
     applyContrast(state.contrast);
   function enableRulerListeners() {
     applyUnderline(state.underline);
     if (rulerListenersOn) return;
     applySpacing(state.spacing);
    rulerListenersOn = true;
     applyCursor(state.cursor);
    window.addEventListener("mousemove", onMouseMove, { passive: true });
     applyRuler(state.ruler);
     window.addEventListener("keydown", onKeyDown);
     applyScale(state.scale);
    window.addEventListener("resize", onResize, { passive: true });
  }
  function disableRulerListeners() {
     if (!rulerListenersOn) return;
     rulerListenersOn = false;
    window.removeEventListener("mousemove", onMouseMove);
     window.removeEventListener("keydown", onKeyDown);
     window.removeEventListener("resize", onResize);
   }
   }


   // ------------------------------
   function getFocusableInside(container) {
  // Skip links (appear on focus)
    if (!container) return [];
  // ------------------------------
    const selectors = [
  function ensureSkipLinks() {
      'a[href]',
     if (document.getElementById('bhc-skiplinks')) return;
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
     ].join(',');


     // Targets (best-effort across skins)
     return Array.from(container.querySelectorAll(selectors)).filter(el => {
    var contentTarget =
       if (el.hidden) return false;
       document.getElementById('mw-content-text') ||
       const style = window.getComputedStyle(el);
       document.getElementById('mw-content') ||
       return style.display !== "none" && style.visibility !== "hidden";
       document.getElementById('content');
    });
  }


    var navTarget =
  // =========================================================
      document.getElementById('site-navigation') ||
  // UI creation
      document.getElementById('mw-panel') ||
  // =========================================================
      document.getElementById('p-navigation');
  function ensureUi() {
    if (document.getElementById("a11y-toggle")) return;


     var searchTarget = document.getElementById('p-search');
     const helpHref = (window.mw && mw.util && mw.util.getUrl)
      ? mw.util.getUrl("Accessibility")
      : "/index.php/Accessibility";


     // We'll anchor the toggle itself.
     const toggle = document.createElement("button");
     var a11yTargetId = 'a11y-toolbar';
    toggle.type = "button";
    toggle.id = "a11y-toggle";
     toggle.className = "a11y-toggle";
    toggle.setAttribute("aria-label", "Accessibility options");
    toggle.setAttribute("aria-haspopup", "dialog");
    toggle.setAttribute("aria-expanded", "false");


     var wrap = el('div', { id: 'bhc-skiplinks' });
     toggle.innerHTML = `
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <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"/>
      </svg>
    `;
    document.body.appendChild(toggle);


     function addSkip(label, targetEl, fallbackId) {
     const panel = document.createElement("div");
      var href = '#';
    panel.id = "a11y-panel";
      if (targetEl && targetEl.id) href = '#' + targetEl.id;
    panel.className = "a11y-panel";
      else if (fallbackId) href = '#' + fallbackId;
    panel.setAttribute("role", "dialog");
      else return;
    panel.setAttribute("aria-label", "Accessibility options");
    panel.hidden = true;


       wrap.appendChild(el('a', { class: 'skip-link', href: href, text: label }));
    // Layout per your request
    }
    panel.innerHTML = `
       <div class="a11y-head">
        <h2>Accessibility</h2>
        <a class="a11y-help" href="${helpHref}" aria-label="Accessibility help">?</a>
      </div>


    addSkip('Skip to content', contentTarget);
      <div class="a11y-row" role="group" aria-label="Text size">
    addSkip('Skip to navigation', navTarget);
        <button type="button" class="a11y-btn" data-action="font-minus" aria-label="Decrease text size">A−</button>
    if (searchTarget) addSkip('Skip to search', searchTarget);
        <button type="button" class="a11y-btn" data-action="font-plus" aria-label="Increase text size">A+</button>
    addSkip('Skip to accessibility tools', null, a11yTargetId);
        <button type="button" class="a11y-btn" data-action="font-reset" aria-label="Reset text size">Reset</button>
      </div>


    // Insert right at start of body
      <div class="a11y-row" role="group" aria-label="Text spacing">
    document.body.insertBefore(wrap, document.body.firstChild);
        <button type="button" class="a11y-btn" data-action="spacing-minus">Spacing−</button>
  }
        <button type="button" class="a11y-btn" data-action="spacing-plus">Spacing+</button>
        <button type="button" class="a11y-btn" data-action="spacing-reset">Spacing reset</button>
      </div>


  // ------------------------------
      <div class="a11y-row" role="group" aria-label="High contrast">
  // UI: toggle + panel
        <button type="button" class="a11y-btn" data-action="contrast-yellow">High contrast (yellow)</button>
  // ------------------------------
        <button type="button" class="a11y-btn" data-action="contrast-white">High contrast (white)</button>
  var toggleBtn, panel, live, statusLine;
        <button type="button" class="a11y-btn" data-action="contrast-reset">Contrast reset</button>
      </div>


  function announce(msg) {
      <div class="a11y-row" role="group" aria-label="Large cursor">
    if (!live) return;
        <button type="button" class="a11y-btn" data-action="cursor-yellow">Cursor yellow</button>
    live.textContent = ''; // nudge SRs
        <button type="button" class="a11y-btn" data-action="cursor-white">Cursor white</button>
    window.setTimeout(function () { live.textContent = msg; }, 10);
        <button type="button" class="a11y-btn" data-action="cursor-reset">Cursor reset</button>
  }
      </div>


  function updateStatus() {
      <div class="a11y-row" role="group" aria-label="Links and reading aid">
    if (!statusLine) return;
        <button type="button" class="a11y-btn" data-action="underline">Underline links</button>
        <button type="button" class="a11y-btn" data-action="ruler">Reading ruler</button>
      </div>


    var bits = [];
      <div class="a11y-row" role="group" aria-label="Reset all">
    if (state.contrast) bits.push('Contrast');
        <button type="button" class="a11y-btn" data-action="reset">Reset all</button>
    if (state.underline) bits.push('Underlined links');
      </div>
    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.';
      <div id="a11y-status" class="a11y-status" aria-live="off"></div>
  }


  function btn(label, onClick) {
      <div class="a11y-hint">
    var b = el('button', { type: 'button', class: 'a11y-btn', 'aria-pressed': 'false', text: label });
        Settings are saved on this device. When Reading ruler is on, use <strong>↑/↓</strong> to move it and <strong>Esc</strong> to turn it off.
    b.addEventListener('click', onClick);
      </div>
    return b;
  }


  function setPressed(buttonEl, pressed) {
      <div id="a11y-live" class="a11y-sr" aria-live="polite" aria-atomic="true"></div>
     buttonEl.setAttribute('aria-pressed', pressed ? 'true' : 'false');
    `;
  }
     document.body.appendChild(panel);


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


  function closePanel() {
    function openPanel() {
    panel.setAttribute('hidden', '');
      panel.hidden = false;
    toggleBtn.setAttribute('aria-expanded', 'false');
      toggle.setAttribute("aria-expanded", "true");
  }
      const firstBtn = panel.querySelector("button.a11y-btn");
      if (firstBtn) firstBtn.focus();
    }


  function togglePanel() {
    function closePanel(opts = {}) {
    var isHidden = panel.hasAttribute('hidden');
      const { returnFocusToToggle = true } = opts;
    if (isHidden) openPanel();
      panel.hidden = true;
     else closePanel();
      toggle.setAttribute("aria-expanded", "false");
  }
      if (returnFocusToToggle) toggle.focus();
     }


  function buildUI() {
    toggle.addEventListener("click", () => {
    // Toggle button
       if (panel.hidden) openPanel();
    toggleBtn = el('button', {
       else closePanel({ returnFocusToToggle: false });
       id: 'a11y-toolbar',
       type: 'button',
      class: 'a11y-toggle',
      'aria-label': 'Accessibility options',
      'aria-haspopup': 'dialog',
      'aria-expanded': 'false'
     });
     });


     // Icon (inline SVG)
     document.addEventListener("click", (e) => {
    toggleBtn.innerHTML =
       if (panel.hidden) return;
       "<svg viewBox='0 0 24 24' aria-hidden='true' focusable='false'>" +
       if (panel.contains(e.target) || toggle.contains(e.target)) return;
       "<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'/>" +
       closePanel({ returnFocusToToggle: false });
       "</svg>";
    });


     toggleBtn.addEventListener('click', function (e) {
     document.addEventListener("keydown", (e) => {
       e.preventDefault();
       if (panel.hidden) return;
      togglePanel();
      if (e.key === "Escape") {
        e.preventDefault();
        closePanel({ returnFocusToToggle: false });
      }
     });
     });


     // Panel
     // STRICT 1A (keyboard): close immediately when tabbing out of the panel
     panel = el('div', { class: 'a11y-panel', role: 'dialog', 'aria-label': 'Accessibility options', hidden: '' });
     panel.addEventListener("keydown", (e) => {
      if (panel.hidden) return;
      if (e.key !== "Tab") return;


    var head = el('div', { class: 'a11y-head' });
      const focusables = getFocusableInside(panel);
    head.appendChild(el('h2', { text: 'Accessibility' }));
      if (!focusables.length) return;


    // Help link (your ? link)
      const first = focusables[0];
    var help = el('a', {
       const last = focusables[focusables.length - 1];
       class: 'a11y-help',
       const active = document.activeElement;
       href: '/index.php/Accessibility',
      text: '?',
      title: 'Accessibility help'
    });
    head.appendChild(help);


    panel.appendChild(head);
      if (e.shiftKey && active === first) {
        e.preventDefault();
        closePanel({ returnFocusToToggle: true });
        return;
      }


    statusLine = el('div', { class: 'a11y-hint', text: '' });
      if (!e.shiftKey && active === last) {
    panel.appendChild(statusLine);
        e.preventDefault();
        closePanel({ returnFocusToToggle: false });


    // Live region for announcements
        const all = Array.from(document.querySelectorAll(
    live = el('div', { class: 'a11y-sr', 'aria-live': 'polite', 'aria-atomic': 'true' });
          'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
    panel.appendChild(live);
        )).filter(el => {
          const style = window.getComputedStyle(el);
          return style.display !== "none" && style.visibility !== "hidden";
        });


    // Row: Contrast + Underline
        let next = null;
    var row1 = el('div', { class: 'a11y-row' });
        for (const el of all) {
    var bContrast = btn('Contrast', function () {
          if (panel.contains(el) || toggle.contains(el)) continue;
      state.contrast = !state.contrast;
          if (panel.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) {
      applyContrast(state.contrast);
            next = el;
      setPressed(bContrast, state.contrast);
            break;
      saveState();
          }
      updateStatus();
        }
       announce(state.contrast ? 'High contrast enabled' : 'High contrast disabled');
        if (next) next.focus();
        return;
       }
     });
     });


     var bUnderline = btn('Underline links', function () {
     // Close if focus moves outside (screen reader / keyboard)
       state.underline = !state.underline;
    document.addEventListener("focusin", (e) => {
       applyUnderline(state.underline);
       if (panel.hidden) return;
       setPressed(bUnderline, state.underline);
      const target = e.target;
       saveState();
      if (!target) return;
       updateStatus();
       if (panel.contains(target) || toggle.contains(target)) return;
       announce(state.underline ? 'Underlined links enabled' : 'Underlined links disabled');
       closePanel({ returnFocusToToggle: false });
    }, true);
 
    panel.addEventListener("click", (e) => {
      const btn = e.target.closest("button[data-action]");
       if (!btn) return;
       const action = btn.getAttribute("data-action") || "";
       handleAction(action);
     });
     });
  }
  function updateUiButtons() {
    const panel = document.getElementById("a11y-panel");
    if (!panel) return;


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


     // Row: Text size
     // Font
     var row2 = el('div', { class: 'a11y-row' });
     setPressed("font-reset", STATE.fontPct !== 100);
     var bSmaller = btn('Text −', function () {
    setPressed("font-plus", false);
      state.scale = clamp(state.scale - SCALE_STEP, SCALE_MIN, SCALE_MAX);
    setPressed("font-minus", false);
      applyScale(state.scale);
 
      saveState();
    // Spacing
      updateStatus();
     setPressed("spacing-plus", STATE.spacing === "plus");
      announce('Text size ' + Math.round(state.scale * 100) + ' percent');
    setPressed("spacing-minus", STATE.spacing === "minus");
     });
    setPressed("spacing-reset", STATE.spacing === "off");
 
    // Contrast scheme
    setPressed("contrast-yellow", STATE.contrastMode === "yellow");
    setPressed("contrast-white", STATE.contrastMode === "white");
    setPressed("contrast-reset", STATE.contrastMode === "off");
 
    // Cursor scheme
    setPressed("cursor-yellow", STATE.cursor === "yellow");
    setPressed("cursor-white", STATE.cursor === "white");
     setPressed("cursor-reset", STATE.cursor === "off");


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


     row2.appendChild(bSmaller);
     setPressed("reset", false);
    row2.appendChild(bBigger);
  }
    panel.appendChild(row2);


    // Row: Spacing
  // =========================================================
    var row3 = el('div', { class: 'a11y-row' });
  // Actions
     var bSpacePlus = btn('Spacing +', function () {
  // =========================================================
       state.spacing = (state.spacing === 'plus') ? 'normal' : 'plus';
  function handleAction(action) {
       applySpacing(state.spacing);
     switch (action) {
      setPressed(bSpacePlus, state.spacing === 'plus');
       // Contrast schemes
      setPressed(bSpaceMinus, state.spacing === 'minus');
       case "contrast-yellow":
      saveState();
        STATE.contrastMode = (STATE.contrastMode === "yellow") ? "off" : "yellow";
      updateStatus();
        applyState();
      announce(state.spacing === 'plus' ? 'Text spacing increased' : 'Text spacing normal');
        announce(STATE.contrastMode === "yellow" ? "High contrast yellow on" : "High contrast off");
    });
        return;


    var bSpaceMinus = btn('Spacing −', function () {
      case "contrast-white":
      state.spacing = (state.spacing === 'minus') ? 'normal' : 'minus';
        STATE.contrastMode = (STATE.contrastMode === "white") ? "off" : "white";
      applySpacing(state.spacing);
        applyState();
      setPressed(bSpaceMinus, state.spacing === 'minus');
        announce(STATE.contrastMode === "white" ? "High contrast white on" : "High contrast off");
      setPressed(bSpacePlus, state.spacing === 'plus');
        return;
      saveState();
      updateStatus();
      announce(state.spacing === 'minus' ? 'Text spacing reduced' : 'Text spacing normal');
    });


    row3.appendChild(bSpacePlus);
      case "contrast-reset":
    row3.appendChild(bSpaceMinus);
        STATE.contrastMode = "off";
    panel.appendChild(row3);
        applyState();
        announce("High contrast off");
        return;


    // Row: Cursor
      // Underline + Ruler toggles
    var row4 = el('div', { class: 'a11y-row' });
       case "underline":
    var bCurWhite = btn('Cursor (white)', function () {
        STATE.underline = !STATE.underline;
       state.cursor = (state.cursor === 'white') ? 'normal' : 'white';
        applyState();
      applyCursor(state.cursor);
        announce(`Underline links ${STATE.underline ? "on" : "off"}`);
      setPressed(bCurWhite, state.cursor === 'white');
        return;
      setPressed(bCurYellow, state.cursor === 'yellow');
      saveState();
      updateStatus();
      announce(state.cursor === 'white' ? 'Large white cursor enabled' : 'Cursor normal');
    });


    var bCurYellow = btn('Cursor (yellow)', function () {
      case "ruler":
      state.cursor = (state.cursor === 'yellow') ? 'normal' : 'yellow';
        STATE.ruler = !STATE.ruler;
      applyCursor(state.cursor);
        if (STATE.ruler && !rulerY) rulerY = Math.floor(window.innerHeight / 2);
      setPressed(bCurYellow, state.cursor === 'yellow');
        applyState();
      setPressed(bCurWhite, state.cursor === 'white');
        announce(`Reading ruler ${STATE.ruler ? "on" : "off"}`);
      saveState();
        return;
      updateStatus();
      announce(state.cursor === 'yellow' ? 'Large yellow cursor enabled' : 'Cursor normal');
    });


    row4.appendChild(bCurWhite);
      // Spacing
    row4.appendChild(bCurYellow);
      case "spacing-plus":
    panel.appendChild(row4);
        STATE.spacing = (STATE.spacing === "plus") ? "off" : "plus";
        applyState();
        announce(STATE.spacing === "plus" ? "Text spacing increased" : "Text spacing normal");
        return;


    // Row: Ruler + Reset
      case "spacing-minus":
    var row5 = el('div', { class: 'a11y-row' });
        STATE.spacing = (STATE.spacing === "minus") ? "off" : "minus";
    var bRuler = btn('Reading ruler', function () {
        applyState();
      state.ruler = !state.ruler;
        announce(STATE.spacing === "minus" ? "Text spacing decreased" : "Text spacing normal");
      applyRuler(state.ruler);
        return;
      setPressed(bRuler, state.ruler);
      saveState();
      updateStatus();
      announce(state.ruler ? 'Reading ruler enabled' : 'Reading ruler disabled');
    });


    var bReset = btn('Reset all', function () {
       case "spacing-reset":
       state = {
         STATE.spacing = "off";
        contrast: false,
        applyState();
        underline: false,
        announce("Text spacing reset");
        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');
    });


    row5.appendChild(bRuler);
      // Cursor schemes
    row5.appendChild(bReset);
      case "cursor-yellow":
    panel.appendChild(row5);
        STATE.cursor = (STATE.cursor === "yellow") ? "off" : "yellow";
        applyState();
        announce(STATE.cursor === "yellow" ? "Large cursor yellow" : "Large cursor off");
        return;


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


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


    // Focus leaving closes (gentle)
      // Text size
    document.addEventListener('focusin', function (e) {
      case "font-plus":
      if (panel.hasAttribute('hidden')) return;
        STATE.fontPct = nextFontPct(STATE.fontPct, +1);
      if (panel.contains(e.target) || toggleBtn.contains(e.target)) return;
        applyState();
      closePanel();
        announce(`Text size ${STATE.fontPct}%`);
    });
        return;


    document.body.appendChild(toggleBtn);
      case "font-minus":
    document.body.appendChild(panel);
        STATE.fontPct = nextFontPct(STATE.fontPct, -1);
        applyState();
        announce(`Text size ${STATE.fontPct}%`);
        return;


    // Initialise pressed states based on state
      case "font-reset":
    setPressed(bContrast, state.contrast);
        STATE.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);


    updateStatus();
      // Reset all
      case "reset":
        clearStoredState();
        STATE.contrastMode = "off";
        STATE.underline = false;
        STATE.spacing = "off";
        STATE.cursor = "off";
        STATE.ruler = false;
        STATE.fontPct = 100;
        rulerY = 0;
        applyState();
        announce("Accessibility settings reset");
        return;
    }
   }
   }


   // ------------------------------
   // =========================================================
   // Ruler tracking
   // Load saved settings
   // ------------------------------
   // =========================================================
   function initRulerTracking() {
   function loadState() {
     var r = document.getElementById('a11y-ruler');
     const saved = loadStateFromStorage();
     if (!r) return;
     if (!saved) return;
 
    const cm = saved.contrastMode;
    STATE.contrastMode = (cm === "yellow" || cm === "white") ? cm : "off";


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


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


     // If user scrolls with keyboard, keep ruler near top-ish
     const fp = parseInt(saved.fontPct, 10);
    window.addEventListener('scroll', function () {
    STATE.fontPct = isNaN(fp) ? 100 : clamp(fp, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]);
      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
   // Init
   // ------------------------------
   // =========================================================
   function boot() {
   function init() {
     if (!document.body) return;
     ensureUi();
 
     loadState();
     loadState();
     applyAll();
     applyState();
    ensureSkipLinks();
    buildUI();
    initRulerTracking();
   }
   }


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

Latest revision as of 08:33, 29 January 2026

/*
⚠️ Do not reorder rules below this line — contrast overrides depend on cascade order.
*/

(() => {
  "use strict";

  // =========================================================
  // Storage policy:
  // - localStorage first
  // - cookie fallback only if localStorage unavailable
  // =========================================================
  const LS_KEY = "bhc_a11y_state_v2"; // bumped because state model changed
  const COOKIE_PREFIX = "a11y_";

  function canUseLocalStorage() {
    try {
      const k = "__a11y_test__";
      window.localStorage.setItem(k, "1");
      window.localStorage.removeItem(k);
      return true;
    } catch (e) {
      return false;
    }
  }
  const HAS_LS = canUseLocalStorage();

  // Cookie helpers (fallback)
  function setCookie(name, value, days = 365) {
    const exp = new Date();
    exp.setTime(exp.getTime() + days * 24 * 60 * 60 * 1000);
    document.cookie =
      `${encodeURIComponent(COOKIE_PREFIX + name)}=${encodeURIComponent(String(value))}; ` +
      `expires=${exp.toUTCString()}; path=/; SameSite=Lax`;
  }
  function getCookie(name, fallback = "") {
    const key = encodeURIComponent(COOKIE_PREFIX + name) + "=";
    const parts = document.cookie.split(";").map(s => s.trim());
    for (const p of parts) {
      if (p.startsWith(key)) return decodeURIComponent(p.substring(key.length));
    }
    return fallback;
  }
  function delCookie(name) {
    document.cookie =
      `${encodeURIComponent(COOKIE_PREFIX + name)}=; ` +
      `expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax`;
  }

  // Unified read/write
  function saveStateToStorage(stateObj) {
    if (HAS_LS) {
      try {
        window.localStorage.setItem(LS_KEY, JSON.stringify(stateObj));
        return;
      } catch (e) {
        // fall through to cookies
      }
    }
    // cookie fallback (per-key)
    setCookie("contrastMode", stateObj.contrastMode); // off|yellow|white
    setCookie("underline", stateObj.underline ? "1" : "0");
    setCookie("spacing", stateObj.spacing);
    setCookie("cursor", stateObj.cursor);            // off|white|yellow
    setCookie("ruler", stateObj.ruler ? "1" : "0");
    setCookie("fontPct", String(stateObj.fontPct));
  }

  function loadStateFromStorage() {
    if (HAS_LS) {
      try {
        const raw = window.localStorage.getItem(LS_KEY);
        if (!raw) return null;
        const obj = JSON.parse(raw);
        return (obj && typeof obj === "object") ? obj : null;
      } catch (e) {
        // fall through to cookies
      }
    }
    // cookie fallback (legacy / no-LS)
    return {
      contrastMode: getCookie("contrastMode", "off"),
      underline: getCookie("underline", "0") === "1",
      spacing: getCookie("spacing", "off"),
      cursor: getCookie("cursor", "off"),
      ruler: getCookie("ruler", "0") === "1",
      fontPct: parseInt(getCookie("fontPct", "100"), 10)
    };
  }

  function clearStoredState() {
    if (HAS_LS) {
      try { window.localStorage.removeItem(LS_KEY); } catch (e) {}
      return;
    }
    ["contrastMode","underline","spacing","cursor","ruler","fontPct"].forEach(delCookie);
  }

  // =========================================================
  // State + announcements
  // =========================================================
  const STATE = {
    contrastMode: "off",      // off | yellow | white
    underline: false,
    spacing: "off",           // off | plus | minus
    cursor: "off",            // off | white | yellow
    ruler: false,
    fontPct: 100              // 90..175
  };

  const FONT_STEPS = [90, 102, 114, 126, 138, 150, 162, 175];

  let liveEl = null;
  let statusEl = null;
  let rulerEl = null;
  let rulerY = 0;

  function announce(msg) {
    if (!liveEl) return;
    liveEl.textContent = "";
    setTimeout(() => { liveEl.textContent = msg; }, 10);
  }

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

  function nextFontPct(current, dir) {
    const cur = clamp(current, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]);
    let idx = 0;
    for (let i = 0; i < FONT_STEPS.length; i++) {
      if (FONT_STEPS[i] >= cur) { idx = i; break; }
    }
    if (dir > 0) return FONT_STEPS[Math.min(FONT_STEPS.length - 1, idx + 1)];
    if (FONT_STEPS[idx] === cur) return FONT_STEPS[Math.max(0, idx - 1)];
    return FONT_STEPS[Math.max(0, idx - 1)];
  }

  // =========================================================
  // Status line (display-only)
  // =========================================================
  function updateStatusLine() {
    if (!statusEl) return;

    const contrast =
      STATE.contrastMode === "yellow" ? "Contrast: Yellow/Black" :
      STATE.contrastMode === "white" ? "Contrast: White/Black" :
      "Contrast: Off";

    const text = `Text ${STATE.fontPct}%`;
    const underline = STATE.underline ? "Links: Underlined" : "Links: Normal";
    const spacing =
      STATE.spacing === "plus" ? "Spacing +" :
      STATE.spacing === "minus" ? "Spacing −" :
      "Spacing: Normal";
    const cursor =
      STATE.cursor === "yellow" ? "Cursor: Yellow" :
      STATE.cursor === "white" ? "Cursor: White" :
      "Cursor: Off";
    const ruler = STATE.ruler ? "Ruler: On" : "Ruler: Off";

    statusEl.textContent = `Status: ${contrast} · ${text} · ${underline} · ${spacing} · ${cursor} · ${ruler}`;
  }

  // =========================================================
  // Apply state to DOM
  // =========================================================
  function applyState() {
    // Contrast scheme classes
    const contrastOn = STATE.contrastMode !== "off";
    document.body.classList.toggle("a11y-contrast", contrastOn);
    document.body.classList.toggle("a11y-contrast-yellow", STATE.contrastMode === "yellow");
    document.body.classList.toggle("a11y-contrast-white", STATE.contrastMode === "white");

    // Other features
    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");

    document.body.classList.toggle("a11y-cursor-white", STATE.cursor === "white");
    document.body.classList.toggle("a11y-cursor-yellow", STATE.cursor === "yellow");

    document.documentElement.style.fontSize = `${STATE.fontPct}%`;

    ensureRuler();
    if (STATE.ruler) {
      rulerEl.hidden = false;
      setRulerY(rulerY || Math.floor(window.innerHeight / 2));
      enableRulerListeners();
    } else {
      if (rulerEl) rulerEl.hidden = true;
      disableRulerListeners();
    }

    updateUiButtons();
    updateStatusLine();
    saveStateToStorage(STATE);
  }

  // =========================================================
  // Ruler support
  // =========================================================
  function ensureRuler() {
    if (rulerEl) return;
    rulerEl = document.getElementById("a11y-ruler");
    if (!rulerEl) {
      rulerEl = document.createElement("div");
      rulerEl.id = "a11y-ruler";
      rulerEl.hidden = true;
      document.body.appendChild(rulerEl);
    }
  }

  function setRulerY(y) {
    rulerY = clamp(y, 0, Math.max(0, window.innerHeight - 1));
    if (rulerEl) rulerEl.style.transform = `translateY(${rulerY}px)`;
  }

  function onMouseMove(e) {
    if (!STATE.ruler) return;
    setRulerY(e.clientY - 11);
  }

  function onKeyDown(e) {
    if (!STATE.ruler) return;

    if (e.key === "ArrowDown") {
      e.preventDefault();
      setRulerY(rulerY + 10);
      announce("Reading ruler moved down");
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setRulerY(rulerY - 10);
      announce("Reading ruler moved up");
    } else if (e.key === "Escape") {
      e.preventDefault();
      STATE.ruler = false;
      applyState();
      announce("Reading ruler off");
    }
  }

  // Named resize handler so we can remove it cleanly (prevents handler buildup)
  function onResize() {
    setRulerY(rulerY);
  }

  let rulerListenersOn = false;
  function enableRulerListeners() {
    if (rulerListenersOn) return;
    rulerListenersOn = true;
    window.addEventListener("mousemove", onMouseMove, { passive: true });
    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("resize", onResize, { passive: true });
  }
  function disableRulerListeners() {
    if (!rulerListenersOn) return;
    rulerListenersOn = false;
    window.removeEventListener("mousemove", onMouseMove);
    window.removeEventListener("keydown", onKeyDown);
    window.removeEventListener("resize", onResize);
  }

  function getFocusableInside(container) {
    if (!container) return [];
    const selectors = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(',');

    return Array.from(container.querySelectorAll(selectors)).filter(el => {
      if (el.hidden) return false;
      const style = window.getComputedStyle(el);
      return style.display !== "none" && style.visibility !== "hidden";
    });
  }

  // =========================================================
  // UI creation
  // =========================================================
  function ensureUi() {
    if (document.getElementById("a11y-toggle")) return;

    const helpHref = (window.mw && mw.util && mw.util.getUrl)
      ? mw.util.getUrl("Accessibility")
      : "/index.php/Accessibility";

    const toggle = document.createElement("button");
    toggle.type = "button";
    toggle.id = "a11y-toggle";
    toggle.className = "a11y-toggle";
    toggle.setAttribute("aria-label", "Accessibility options");
    toggle.setAttribute("aria-haspopup", "dialog");
    toggle.setAttribute("aria-expanded", "false");

    toggle.innerHTML = `
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <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"/>
      </svg>
    `;
    document.body.appendChild(toggle);

    const panel = document.createElement("div");
    panel.id = "a11y-panel";
    panel.className = "a11y-panel";
    panel.setAttribute("role", "dialog");
    panel.setAttribute("aria-label", "Accessibility options");
    panel.hidden = true;

    // Layout per your request
    panel.innerHTML = `
      <div class="a11y-head">
        <h2>Accessibility</h2>
        <a class="a11y-help" href="${helpHref}" aria-label="Accessibility help">?</a>
      </div>

      <div class="a11y-row" role="group" aria-label="Text size">
        <button type="button" class="a11y-btn" data-action="font-minus" aria-label="Decrease text size">A−</button>
        <button type="button" class="a11y-btn" data-action="font-plus" aria-label="Increase text size">A+</button>
        <button type="button" class="a11y-btn" data-action="font-reset" aria-label="Reset text size">Reset</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Text spacing">
        <button type="button" class="a11y-btn" data-action="spacing-minus">Spacing−</button>
        <button type="button" class="a11y-btn" data-action="spacing-plus">Spacing+</button>
        <button type="button" class="a11y-btn" data-action="spacing-reset">Spacing reset</button>
      </div>

      <div class="a11y-row" role="group" aria-label="High contrast">
        <button type="button" class="a11y-btn" data-action="contrast-yellow">High contrast (yellow)</button>
        <button type="button" class="a11y-btn" data-action="contrast-white">High contrast (white)</button>
        <button type="button" class="a11y-btn" data-action="contrast-reset">Contrast reset</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Large cursor">
        <button type="button" class="a11y-btn" data-action="cursor-yellow">Cursor yellow</button>
        <button type="button" class="a11y-btn" data-action="cursor-white">Cursor white</button>
        <button type="button" class="a11y-btn" data-action="cursor-reset">Cursor reset</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Links and reading aid">
        <button type="button" class="a11y-btn" data-action="underline">Underline links</button>
        <button type="button" class="a11y-btn" data-action="ruler">Reading ruler</button>
      </div>

      <div class="a11y-row" role="group" aria-label="Reset all">
        <button type="button" class="a11y-btn" data-action="reset">Reset all</button>
      </div>

      <div id="a11y-status" class="a11y-status" aria-live="off"></div>

      <div class="a11y-hint">
        Settings are saved on this device. 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>
    `;
    document.body.appendChild(panel);

    liveEl = panel.querySelector("#a11y-live");
    statusEl = panel.querySelector("#a11y-status");
    updateStatusLine();

    function openPanel() {
      panel.hidden = false;
      toggle.setAttribute("aria-expanded", "true");
      const firstBtn = panel.querySelector("button.a11y-btn");
      if (firstBtn) firstBtn.focus();
    }

    function closePanel(opts = {}) {
      const { returnFocusToToggle = true } = opts;
      panel.hidden = true;
      toggle.setAttribute("aria-expanded", "false");
      if (returnFocusToToggle) toggle.focus();
    }

    toggle.addEventListener("click", () => {
      if (panel.hidden) openPanel();
      else closePanel({ returnFocusToToggle: false });
    });

    document.addEventListener("click", (e) => {
      if (panel.hidden) return;
      if (panel.contains(e.target) || toggle.contains(e.target)) return;
      closePanel({ returnFocusToToggle: false });
    });

    document.addEventListener("keydown", (e) => {
      if (panel.hidden) return;
      if (e.key === "Escape") {
        e.preventDefault();
        closePanel({ returnFocusToToggle: false });
      }
    });

    // STRICT 1A (keyboard): close immediately when tabbing out of the panel
    panel.addEventListener("keydown", (e) => {
      if (panel.hidden) return;
      if (e.key !== "Tab") return;

      const focusables = getFocusableInside(panel);
      if (!focusables.length) return;

      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      const active = document.activeElement;

      if (e.shiftKey && active === first) {
        e.preventDefault();
        closePanel({ returnFocusToToggle: true });
        return;
      }

      if (!e.shiftKey && active === last) {
        e.preventDefault();
        closePanel({ returnFocusToToggle: false });

        const all = Array.from(document.querySelectorAll(
          'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
        )).filter(el => {
          const style = window.getComputedStyle(el);
          return style.display !== "none" && style.visibility !== "hidden";
        });

        let next = null;
        for (const el of all) {
          if (panel.contains(el) || toggle.contains(el)) continue;
          if (panel.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) {
            next = el;
            break;
          }
        }
        if (next) next.focus();
        return;
      }
    });

    // Close if focus moves outside (screen reader / keyboard)
    document.addEventListener("focusin", (e) => {
      if (panel.hidden) return;
      const target = e.target;
      if (!target) return;
      if (panel.contains(target) || toggle.contains(target)) return;
      closePanel({ returnFocusToToggle: false });
    }, true);

    panel.addEventListener("click", (e) => {
      const btn = e.target.closest("button[data-action]");
      if (!btn) return;
      const action = btn.getAttribute("data-action") || "";
      handleAction(action);
    });
  }

  function updateUiButtons() {
    const panel = document.getElementById("a11y-panel");
    if (!panel) return;

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

    // Font
    setPressed("font-reset", STATE.fontPct !== 100);
    setPressed("font-plus", false);
    setPressed("font-minus", false);

    // Spacing
    setPressed("spacing-plus", STATE.spacing === "plus");
    setPressed("spacing-minus", STATE.spacing === "minus");
    setPressed("spacing-reset", STATE.spacing === "off");

    // Contrast scheme
    setPressed("contrast-yellow", STATE.contrastMode === "yellow");
    setPressed("contrast-white", STATE.contrastMode === "white");
    setPressed("contrast-reset", STATE.contrastMode === "off");

    // Cursor scheme
    setPressed("cursor-yellow", STATE.cursor === "yellow");
    setPressed("cursor-white", STATE.cursor === "white");
    setPressed("cursor-reset", STATE.cursor === "off");

    // Toggles
    setPressed("underline", STATE.underline);
    setPressed("ruler", STATE.ruler);

    setPressed("reset", false);
  }

  // =========================================================
  // Actions
  // =========================================================
  function handleAction(action) {
    switch (action) {
      // Contrast schemes
      case "contrast-yellow":
        STATE.contrastMode = (STATE.contrastMode === "yellow") ? "off" : "yellow";
        applyState();
        announce(STATE.contrastMode === "yellow" ? "High contrast yellow on" : "High contrast off");
        return;

      case "contrast-white":
        STATE.contrastMode = (STATE.contrastMode === "white") ? "off" : "white";
        applyState();
        announce(STATE.contrastMode === "white" ? "High contrast white on" : "High contrast off");
        return;

      case "contrast-reset":
        STATE.contrastMode = "off";
        applyState();
        announce("High contrast off");
        return;

      // Underline + Ruler toggles
      case "underline":
        STATE.underline = !STATE.underline;
        applyState();
        announce(`Underline links ${STATE.underline ? "on" : "off"}`);
        return;

      case "ruler":
        STATE.ruler = !STATE.ruler;
        if (STATE.ruler && !rulerY) rulerY = Math.floor(window.innerHeight / 2);
        applyState();
        announce(`Reading ruler ${STATE.ruler ? "on" : "off"}`);
        return;

      // Spacing
      case "spacing-plus":
        STATE.spacing = (STATE.spacing === "plus") ? "off" : "plus";
        applyState();
        announce(STATE.spacing === "plus" ? "Text spacing increased" : "Text spacing normal");
        return;

      case "spacing-minus":
        STATE.spacing = (STATE.spacing === "minus") ? "off" : "minus";
        applyState();
        announce(STATE.spacing === "minus" ? "Text spacing decreased" : "Text spacing normal");
        return;

      case "spacing-reset":
        STATE.spacing = "off";
        applyState();
        announce("Text spacing reset");
        return;

      // Cursor schemes
      case "cursor-yellow":
        STATE.cursor = (STATE.cursor === "yellow") ? "off" : "yellow";
        applyState();
        announce(STATE.cursor === "yellow" ? "Large cursor yellow" : "Large cursor off");
        return;

      case "cursor-white":
        STATE.cursor = (STATE.cursor === "white") ? "off" : "white";
        applyState();
        announce(STATE.cursor === "white" ? "Large cursor white" : "Large cursor off");
        return;

      case "cursor-reset":
        STATE.cursor = "off";
        applyState();
        announce("Large cursor off");
        return;

      // Text size
      case "font-plus":
        STATE.fontPct = nextFontPct(STATE.fontPct, +1);
        applyState();
        announce(`Text size ${STATE.fontPct}%`);
        return;

      case "font-minus":
        STATE.fontPct = nextFontPct(STATE.fontPct, -1);
        applyState();
        announce(`Text size ${STATE.fontPct}%`);
        return;

      case "font-reset":
        STATE.fontPct = 100;
        applyState();
        announce("Text size reset");
        return;

      // Reset all
      case "reset":
        clearStoredState();
        STATE.contrastMode = "off";
        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
  // =========================================================
  function loadState() {
    const saved = loadStateFromStorage();
    if (!saved) return;

    const cm = saved.contrastMode;
    STATE.contrastMode = (cm === "yellow" || cm === "white") ? cm : "off";

    STATE.underline = !!saved.underline;

    const spacing = saved.spacing;
    STATE.spacing = (spacing === "plus" || spacing === "minus") ? spacing : "off";

    const cursor = saved.cursor;
    STATE.cursor = (cursor === "white" || cursor === "yellow") ? cursor : "off";

    STATE.ruler = !!saved.ruler;

    const fp = parseInt(saved.fontPct, 10);
    STATE.fontPct = isNaN(fp) ? 100 : clamp(fp, FONT_STEPS[0], FONT_STEPS[FONT_STEPS.length - 1]);
  }

  // =========================================================
  // Init
  // =========================================================
  function init() {
    ensureUi();
    loadState();
    applyState();
  }

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