MediaWiki:Common.js: Difference between revisions

No edit summary
No edit summary
Tag: Reverted
Line 1: Line 1:
/* MediaWiki:Common.js */
/**
* gr_annotations.js —  grantha.io inline Notes + Bookmarks + Footnotes + Feedback  (v6 + Strategy B + Footnotes tab)
*/


(function () {
/* global mw, $ */
( function () {
  'use strict';


     var LS_SCRIPT_KEY = 'grantha_reader_script';
  var CONTENT_SEL  = '#mw-content-text';
     var currentScript = 'deva';
  var BM_LS_KEY    = 'grantha_bm_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
  var NT_LS_KEY     = 'grantha_nt_'  + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
  // Writes that have not reached the server yet. Scoped per user and per
  // page where it is built: the keys above are per page, and a shared
  // machine has more than one user.
  var Q_LS_KEY      = 'grantha_q_v1';
  var pageTitle     = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
  var currentUser  = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
  var userInitial  = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
  var currentUserEmail = '';


    function devanagariToTamil(text) {
  // FIXED: was `new mw.Api()` called directly and synchronously here —
        var CONSONANTS = {
  // at this point in MediaWiki:Common.js's own load lifecycle,
            'क': 'க', 'ख': 'க', 'ग': 'க', 'घ': 'க', 'ङ': 'ங',
  // mediawiki.api (the module that provides the REAL mw.Api
            'च': 'ச', 'छ': 'ச', 'ज': 'ஜ', 'झ': 'ஜ', 'ञ': 'ஞ',
  // constructor) is not guaranteed to have finished loading yet, and
            'ट': 'ட', 'ठ': 'ட', 'ड': 'ட', 'ढ': 'ட', 'ण': 'ண',
  // calling the constructor before it has throws "mw.Api is not a
            'त': 'த', 'थ': 'த', 'द': 'த', 'ध': 'த', 'न': 'ந',
  // constructor" synchronously. Confirmed directly via the real error
            'प': 'ப', 'फ': 'ப', 'ब': 'ப', 'भ': 'ப', 'म': 'ம',
  // trace, and confirmed this is pre-existing code, not anything
            'य': 'ய', 'र': 'ர', 'ल': 'ல', 'ळ': 'ள', 'व': 'வ',
  // introduced by the more recent Ullekha/tab changes. Uncaught here
            'श': 'ஶ', 'ष': 'ஷ', 'स': 'ஸ', 'ह': 'ஹ'
  // (the .catch below only ever handled promise rejections, never a
        };
  // synchronous throw from the constructor call itself), this would
        var DIACRITICS = {
  // halt the REST of this script's own execution — meaning later code
            'ा': 'ா', 'ि': 'ி', 'ी': 'ீ', 'ु': 'ு', 'ू': 'ூ',
  // in this same file (the tab-building logic) may never run at all on
            'े': 'ே', 'ै': 'ை', 'ो': 'ோ', 'ौ': 'ௌ'
  // whatever page load hits this race. mw.loader.using() guarantees the
        };
  // module is actually ready before mw.Api is ever touched, regardless
        var VOWELS = {
  // of load timing. Verified the exact failure mode and the fix
            'अ': 'அ', 'आ': 'ஆ', 'इ': 'இ', 'ई': 'ஈ', 'उ': 'உ', 'ऊ': 'ஊ',
  // directly before applying here.
            'ए': 'ஏ', 'ऐ': 'ஐ', 'ओ': 'ஓ', 'औ': 'ஔ', 'ऽ': 'ऽ'
  if ( currentUser && window.mw ) {
        };
    mw.loader.using( 'mediawiki.api' ).then( function () {
        var MISC = {
      return new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 });
            'ं':'ம்', 'ः':':', 'ँ':'ம்', 'ॐ':'ௐ',
    } ).then( function (data) {
            '०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
      var info = data && data.query && data.query.userinfo;
            '५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
      if ( info && info.email ) currentUserEmail = info.email;
        };
    } ).catch( function () {} );
        var HALANTA = '्';
   }
        var chars = Array.from(text), result = '', i = 0;
        while (i < chars.length) {
            var ch = chars[i], next = chars[i + 1];
            if (CONSONANTS[ch]) {
                var base = CONSONANTS[ch];
                if (next === HALANTA) { result += base + '்'; i += 2; }
                else if (next === 'ृ') { result += base + '்ரி'; i += 2; }
                else if (next === 'ॄ') { result += base + '்ரீ'; i += 2; }
                else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
                else { result += base; i++; }   // inherent 'a'
            }
            else if (ch === 'ऋ') { result += 'ரி'; i++; }
            else if (ch === 'ॠ') { result += 'ரீ'; i++; }
            else if (VOWELS[ch]) { result += VOWELS[ch]; i++; }
            else if (MISC[ch]) { result += MISC[ch]; i++; }
            else { result += ch; i++; }
        }
        return result;
    }


    function devanagariToIAST(text) {
  if ( window.mw ) {
        var CONSONANTS = {
    var ns = mw.config.get( 'wgNamespaceNumber' );
            'क': 'k', 'ख': 'kh', 'ग': 'g', 'घ': 'gh', 'ङ': 'ṅ',
    if ( ns < 0 ) return;
            'च': 'c', 'छ': 'ch', 'ज': 'j', 'झ': 'jh', 'ञ': 'ñ',
  }
            'ट': 'ṭ', 'ठ': 'ṭh', 'ड': 'ḍ', 'ढ': 'ḍh', 'ण': 'ṇ',
            'त': 't', 'थ': 'th', 'द': 'd', 'ध': 'dh', 'न': 'n',
            'प': 'p', 'फ': 'ph', 'ब': 'b', 'भ': 'bh', 'म': 'm',
            'य': 'y', 'र': 'r', 'ल': 'l', 'ळ': 'ḷ', 'व': 'v',
            'श': 'ś', 'ष': 'ṣ', 'स': 's', 'ह': 'h'
        };
        var DIACRITICS = {
            'ा': 'ā', 'ि': 'i', 'ी': 'ī', 'ु': 'u', 'ू': 'ū',
            'ृ': 'ṛ', 'ॄ': 'ṝ', 'े': 'e', 'ै': 'ai', 'ो': 'o', 'ौ': 'au'
        };
        var VOWELS = {
            'अ': 'a', 'आ': 'ā', 'इ': 'i', 'ई': 'ī', 'उ': 'u', 'ऊ': 'ū',
            'ऋ': 'ṛ', 'ॠ': 'ṝ', 'ए': 'e', 'ऐ': 'ai', 'ओ': 'o', 'औ': 'au', 'ऽ': "'"
        };
        var MISC = {
            'ं': 'ṃ', 'ः': 'ḥ', 'ँ': 'm̐', 'ॐ': 'oṃ',
            '०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
            '५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
        };
        var HALANTA = '्';
        var chars = Array.from(text), result = '', i = 0;
        while (i < chars.length) {
            var ch = chars[i], next = chars[i + 1];
            if (CONSONANTS[ch]) {
                var base = CONSONANTS[ch];
                if (next === HALANTA) { result += base; i += 2; }
                else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
                else if (next === 'ं' || next === 'ः') { result += base + 'a' + MISC[next]; i += 2; }
                else { result += base + 'a'; i++; }
            }
            else if (VOWELS[ch]) { result += VOWELS[ch]; i++; }
            else if (DIACRITICS[ch]) { result += DIACRITICS[ch]; i++; }
            else if (MISC[ch]) { result += MISC[ch]; i++; }
            else { result += ch; i++; }
        }
        return result;
    }


    var SCRIPT_MAP = {
  var _selRange  = null;
        kn: {
  var _selText    = '';
            'अ': 'ಅ', 'आ': 'ಆ', 'इ': 'ಇ', 'ई': 'ಈ', 'उ': 'ಉ', 'ऊ': 'ಊ', 'ऋ': 'ಋ', 'ॠ': 'ೠ',
  var _selRect    = null;
            'ए': 'ಏ', 'ऐ': 'ಐ', 'ओ': 'ಓ', 'औ': 'ಔ', 'ऽ': 'ಽ',
  var _notes      = [];
            'क': 'ಕ', 'ख': 'ಖ', 'ग': 'ಗ', 'घ': 'ಘ', 'ङ': 'ಙ',
  var _bookmarks  = [];
            'च': 'ಚ', 'छ': 'ಛ', 'ज': 'ಜ', 'झ': 'ಝ', 'ञ': 'ಞ',
  var _activeTab  = 'notes';
            'ट': 'ಟ', 'ठ': 'ಠ', 'ड': 'ಡ', 'ढ': 'ಢ', 'ण': 'ಣ',
  var _selVersion = 0;
            'त': 'ತ', 'थ': 'ಥ', 'द': 'ದ', 'ध': 'ಧ', 'न': 'ನ',
  var _fabSelVer  = -1;
            'प': 'ಪ', 'फ': 'ಫ', 'ब': 'ಬ', 'भ': 'ಭ', 'म': 'ಮ',
  var _mobile    = window.innerWidth < 768 || 'ontouchstart' in window;
            'य': 'ಯ', 'र': 'ರ', 'ल': 'ಲ', 'ळ': 'ಳ', 'व': 'ವ',
  var _fabTouched = false; // flag to prevent hideActions when tapping fab
            'श': 'ಶ', 'ष': 'ಷ', 'स': 'ಸ', 'ह': 'ಹ',
            'ा': 'ಾ', 'ि': 'ಿ', 'ी': 'ೀ', 'ु': 'ು', 'ू': 'ೂ',
            'ृ': 'ೃ', 'ॄ': 'ೄ', 'े': 'ೇ', 'ै': 'ೈ', 'ो': 'ೋ', 'ौ': 'ೌ',
            'ं': 'ಂ', 'ः': 'ಃ', '्': '್', 'ँ': 'ಁ',
            '०': '೦', '१': '೧', '२': '೨', '३': '೩', '४': '೪',
            '५': '೫', '६': '೬', '७': '೭', '८': '೮', '९': '೯'
        },
        te: {
            'अ': 'అ', 'आ': 'ఆ', 'इ': 'ఇ', 'ई': 'ఈ', 'उ': 'ఉ', 'ऊ': 'ఊ', 'ऋ': 'ఋ', 'ॠ': 'ౠ',
            'ए': 'ఏ', 'ऐ': 'ఐ', 'ओ': 'ఓ', 'औ': 'ఔ', 'ऽ': 'ఽ',
            'क': 'క', 'ख': 'ఖ', 'ग': 'గ', 'घ': 'ఘ', 'ङ': 'ఙ',
            'च': 'చ', 'छ': 'ఛ', 'ज': 'జ', 'झ': 'ఝ', 'ञ': 'ఞ',
            'ट': 'ట', 'ठ': 'ఠ', 'ड': 'డ', 'ढ': 'ఢ', 'ण': 'ణ',
            'त': 'త', 'थ': 'థ', 'द': 'ద', 'ध': 'ధ', 'न': 'న',
            'प': 'ప', 'फ': 'ఫ', 'ब': 'బ', 'भ': 'భ', 'म': 'మ',
            'य': 'య', 'र': 'ర', 'ल': 'ల', 'ळ': 'ళ', 'व': 'వ',
            'श': 'శ', 'ष': 'ష', 'स': 'స', 'ह': 'హ',
            'ा': 'ా', 'ि': 'ి', 'ी': 'ీ', 'ु': 'ు', 'ू': 'ూ',
            'ृ': 'ృ', 'ॄ': 'ౄ', 'े': 'ే', 'ै': 'ై', 'ो': 'ో', 'ौ': 'ౌ',
            'ं': 'ం', 'ः': 'ః', '्': '్', 'ँ': 'ఁ',
            '०': '౦', '१': '౧', '२': '౨', '३': '౩', '४': '౪',
            '५': '౫', '६': '౬', '७': '౭', '८': '౮', '९': '౯'
        }
    };


     var PRE = [
  function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
        [/ङ्क/g, 'ंक'], [/ङ्ख/g, 'ंख'], [/ङ्ग/g, 'ंग'], [/ङ्घ/g, 'ंघ'],
  function esc(s) {
        [/ञ्च/g, 'ंच'], [/ञ्ज/g, 'ंज'], [/ण्ट/g, 'ंट'], [/ण्ड/g, 'ंड'],
     return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;')
        [/न्त/g, 'ंत'], [/न्द/g, 'ंद'], [/म्ब/g, 'ंब'], [/म्भ/g, 'ंभ']
                        .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
     ];
  }
  function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); }
  function fmtTs(ts) {
    try {
      var d = new Date(ts);
      return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})
          + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false});
     } catch(e){ return ts; }
  }
  function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); }
  function isMobile() { return _mobile; }


    function transliterateText(text, script) {
  // The server caps a note at 8192 *bytes* (MAX_TEXT_BYTES), and Devanagari
        if (script === 'en') return devanagariToIAST(text);
  // runs three bytes to the character, so the real limit is about 2,700
        if (script === 'ta') return devanagariToTamil(text);
  // characters — reachable for the kind of thing people write here. Caught
        var map = SCRIPT_MAP[script];
  // at the composer, because the alternative is a note that looks saved,
        if (!map) return text;
  // sits in the panel all session, and is simply absent the next time the
        var t = text;
  // page is opened.
        PRE.forEach(function (p) { t = t.replace(p[0], p[1]); });
  var MAX_NOTE_BYTES = 8192;
        return Array.from(t).map(function (ch) {
  function byteLength(s) {
            return map[ch] !== undefined ? map[ch] : ch;
    try { return new TextEncoder().encode(s).length; }
        }).join('');
    catch (e) { return unescape(encodeURIComponent(s)).length; }
    }
  }


    var translatableSpans = [];
  /* ══════════════════════ Store ══════════════════════
  * Notes and bookmarks live in the wiki database, in gr_annotations, via
  * the GranthaAnnotations extension — which is what lets the same notes
  * show up in the mobile app. They used to live only in localStorage, so
  * they were stranded in one browser.
  *
  * localStorage does not go away; it changes job. It is now
  *  · a cache,  so a reload paints the panel instantly instead of waiting
  *              on the network, and a page read offline still shows notes
  *  · an outbox mirror, so a write made while offline survives a reload
  * The database is the record. The browser is a convenience in front of it.
  *
  * Anonymous readers have no database row to own, and the API refuses them
  * outright, so for them localStorage stays the store exactly as before.
  * Every write is optimistic: memory and cache update first, the panel
  * re-renders immediately, and the request follows.
  */
  var Store = ( function () {


     function tagTextNodes() {
     var loggedIn = !!currentUser;
        var content = document.querySelector('.mw-parser-output');
    // Rejections the server will never change its mind about. Anything not
        if (content) {
    // listed here is treated as temporary and retried.
            var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
    var TERMINAL = { badid: 1, notowner: 1, texttoolong: 1, missingparam: 1 };
            var nodes = [];
    var _api = null;
            while (walker.nextNode()) nodes.push(walker.currentNode);
    var flushing = null;         // the in-flight flush's promise, or null
            nodes.forEach(function (node) {
    var outbox = [];             // authoritative in memory; mirrored to LS
                var p = node.parentNode;
                if (!p) return;
                if (p.hasAttribute && p.hasAttribute('data-deva')) return;
                if (p.closest) {
                    if (p.closest('.gr-controls')) return;
                    if (p.closest('.mw-editsection')) return;
                    // Note markers are [1], [2] … — machinery, not text, and
                    // the numbers would be transliterated along with the page.
                    if (p.closest('.gr-note-marker')) return;
                }
                var orig = node.textContent;
                if (!orig.trim()) return;
                var span = document.createElement('span');
                span.setAttribute('data-deva', orig);
                span.textContent = orig;
                p.replaceChild(span, node);
                translatableSpans.push(span);
            });
        }
        document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
    }


     // A contents entry is "1.2 शीर्षकम्": the number lives in its own
     // mw.Api only exists once mediawiki.api has loaded, and asking for it
     // .vector-toc-numb span, which Common.css hides. Tagging the whole
     // sooner throws. Everything that talks to the server waits on this, so a
     // .vector-toc-text meant transliteration replaced its contents with
     // load failure degrades to cache-and-queue instead of throwing.
    // one flat string — number included — so switching to Tamil or any
     function ready() {
    // other script made the numbering appear. Only the label is tagged.
      if ( !window.mw || !mw.loader ) return $.Deferred().reject().promise();
     function tagTocEntry(textEl) {
      return mw.loader.using( 'mediawiki.api' ).then( function () {
        if (!textEl || textEl.hasAttribute('data-deva')) return;
        if ( !_api ) _api = new mw.Api();
        var label = textEl.querySelector('[data-deva]');
         return _api;
        if (!label) {
      } );
            label = document.createElement('span');
            label.className = 'gr-toc-label';
            Array.prototype.slice.call(textEl.childNodes).forEach(function (n) {
                if (n.nodeType === 1 && n.classList && n.classList.contains('vector-toc-numb')) return;
                label.appendChild(n);                // moves it, order preserved
            });
            textEl.appendChild(label);
        }
        var orig = label.textContent;
         if (!orig.trim()) return;
        if (!label.hasAttribute('data-deva')) label.setAttribute('data-deva', orig);
        if (translatableSpans.indexOf(label) === -1) translatableSpans.push(label);
     }
     }


    // Which script is on screen, published to CSS. Common.css §2b uses it to
     function ls( k, v ) {
    // swap the reading area onto --font-indic while a transliteration is
      try {
    // showing: most rules here name Adishila directly, which has no Tamil,
        if ( v === undefined ) return localStorage.getItem( k );
    // Kannada or Telugu letters, so without this the switched text fell back
        localStorage.setItem( k, v );
    // to whatever serif the visitor's machine happened to have — the reason
         return true;
    // it looked uneven from one line to the next.
      } catch ( e ) { return null; }
     function markScriptOnRoot(script) {
        try {
            var el = document.documentElement;
            if (!el) return;
            if (!script || script === 'deva') el.removeAttribute('data-gr-script');
            else el.setAttribute('data-gr-script', script);
         } catch (e) { }
    }
 
    function applyScript(script) {
        currentScript = script;
        markScriptOnRoot(script);
        translatableSpans.forEach(function (span) {
            if (!span.parentNode) return;
            var orig = span.getAttribute('data-deva');
            if (!orig) return;
            span.textContent = (script === 'deva')
                ? orig
                : transliterateText(orig, script);
        });
    }
 
    function _isNoTocPage() {
        var pn = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
        return pn === 'Main_Page' || /^[A-Za-z0-9_]+:About$/.test(pn);
     }
     }
 
     function readJson( k, fallback ) {
     function renameTocTitle() {
      try { return JSON.parse( ls( k ) || '' ) || fallback; } catch ( e ) { return fallback; }
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        var titleEl = toc.querySelector('.vector-toc-title') || toc.querySelector('.vector-pinnable-header-label');
        if (!titleEl) return;
        var LABEL = 'विषयसूची';
        var span = titleEl.querySelector('.gr-toc-title');
        if (!span) {
            titleEl.innerHTML = '';
            span = document.createElement('span');
            span.className = 'gr-toc-title';
            span.setAttribute('data-deva', LABEL);
            titleEl.appendChild(span);
            translatableSpans.push(span);
        }
        span.textContent = currentScript === 'deva' ? LABEL : transliterateText(LABEL, currentScript);
     }
     }
    function whoami() { return currentUser || null; }


     function removeTocBeginning() {
     /* ── Outbox ──────────────────────────────────────────────────────
        if (_isNoTocPage()) return;
    * Held in memory and mirrored to localStorage, not held *in* it. A
        var toc = document.querySelector('.vector-toc');
    * browser that refuses to store — private mode, quota, policy — used to
        if (!toc) return;
    * mean the write was never sent at all, because the send re-read the
        var el = toc.querySelector('#vector-toc-beginning');
    * queue it had just failed to write. Now storage only decides whether an
        if (!el) {
    * unsent write survives a reload.
            var items = toc.querySelectorAll('.vector-toc-list-item');
    *
            for (var i = 0; i < items.length; i++) {
    * Keyed per user AND per page: the cache keys have always been per page,
                var a = items[i].querySelector('a');
    * so a queue scoped only by user cannot describe them. */
                if (a) {
     function qKey() { return Q_LS_KEY + '_' + ( currentUser || 'anon' ) + '_' + pageTitle; }
                    var href = a.getAttribute('href') || '';
     function mirror() { ls( qKey(), JSON.stringify( outbox ) ); }
                    if (href.indexOf('#') === -1) { el = items[i]; break; }
                }
            }
        }
        if (el && el.parentNode) el.parentNode.removeChild(el);
    }
 
    // The commentary list at the top of a moola page has its own heading
    // (टीकाः). It isn't part of the text, so it doesn't belong in the
    // contents. Recognised by what the section holds — the .gr-doc-teekas
    // list, or a section whose only links are to /Vyakhya/ pages — with
    // the heading's own wording as a last resort, since the list markup
    // differs between pages and the commentary picker rewrites it.
    var TEEKA_HEADINGS = /^(टीकाः?|व्याख्याः?|टीकाग्रन्थाः|Teekas?|Commentaries)$/i;
     function isTeekaSection(headingEl) {
        var wrap = (headingEl.closest && headingEl.closest('.mw-heading')) || headingEl;
        var n = wrap.nextElementSibling, links = [], vyakhya = 0, steps = 0;
        while (n && steps < 6) {
            if (n.classList && n.classList.contains('gr-doc-teekas')) return true;
            if (n.querySelector && n.querySelector('.gr-doc-teekas')) return true;
            if ((n.tagName && /^H[1-6]$/.test(n.tagName)) || (n.classList && n.classList.contains('mw-heading'))) break;
            if (n.querySelectorAll) {
                Array.prototype.forEach.call(n.querySelectorAll('a[href]'), function (a) {
                    var href = a.getAttribute('href') || '';
                    if (/^#/.test(href)) return;
                    links.push(href);
                    if (/\/Vyakhya\//i.test(decodeURIComponent(href))) vyakhya++;
                });
            }
            n = n.nextElementSibling; steps++;
        }
        if (links.length && vyakhya === links.length) return true;
        return TEEKA_HEADINGS.test((headingEl.textContent || '').trim());
    }
     function removeTocTeekaEntry() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-link[href^="#"]'), function (a) {
            var id = a.getAttribute('href').slice(1);
            try { id = decodeURIComponent(id); } catch (e) { }
            var h = id && document.getElementById(id);
            if (!h || !isTeekaSection(h)) return;
            var li = a.closest('.vector-toc-list-item');
            if (li && li.parentNode) li.parentNode.removeChild(li);
        });
    }


     // Section numbers ("2.1.1") are removed from the contents outright.
     function enqueue( op ) {
    // Hiding them with CSS was not enough: the script switcher replaces an
      // One pending operation per annotation. A note edited twice before the
    // entry's contents with one flat string, which turned the hidden number
      // network returns should send its final state once, not both states in
    // into ordinary text — so it reappeared in Tamil, Kannada and the rest.
      // order, and a delete supersedes any save still waiting behind it.
    // They are also wrong here, since this contents list leaves entries out.
      outbox = outbox.filter( function ( x ) { return x.id !== op.id; } );
    var NUMBERED = /^\s*\d+(?:\.\d+)*[.)]?\s+([\s\S]*\S)\s*$/;
      outbox.push( op );
    function stripTocNumbers() {
      mirror();
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-numb'), function (n) {
            if (n.parentNode) n.parentNode.removeChild(n);
        });
        // Entries another script already flattened: take the number off the
        // text, and off the Devanagari original the switcher re-reads.
        Array.prototype.forEach.call(toc.querySelectorAll('.vector-toc-text, .vector-toc-text [data-deva]'), function (el) {
            var d = el.getAttribute && el.getAttribute('data-deva');
            if (d) { var md = NUMBERED.exec(d); if (md) el.setAttribute('data-deva', md[1]); }
            if (!el.children.length) {
                var mt = NUMBERED.exec(el.textContent);
                if (mt) el.textContent = mt[1];
            }
        });
     }
     }
     // The switcher can rewrite the contents at any time, so watch for it.
     // Removes the exact operation that was sent, by identity. Matching on
    var _numbObserver = null;
    // id and op instead would let a completing send delete a *newer*
     function watchTocNumbers() {
    // operation for the same annotation that replaced it mid-flight.
        var toc = document.querySelector('.vector-toc');
     function dequeue( op ) {
        if (!toc || _numbObserver || !window.MutationObserver) return;
      var i = outbox.indexOf( op );
        var pending = false;
      if ( i === -1 ) return;
        _numbObserver = new MutationObserver(function () {
      outbox.splice( i, 1 );
            if (pending) return;
      mirror();
            pending = true;
            setTimeout(function () { pending = false; stripTocNumbers(); }, 50);
        });
        _numbObserver.observe(toc, { childList: true, subtree: true, characterData: true });
     }
     }


     function expandTocSections() {
     function send( op ) {
        if (_isNoTocPage()) return;
      var params = { action: 'granthaannotationedit', op: op.op, id: op.id, format: 'json' };
         var toc = document.querySelector('.vector-toc');
      if ( op.op === 'save' ) {
         if (!toc) return;
        params.page = op.page;
        toc.querySelectorAll('.vector-toc-list-item-collapsed').forEach(function (li) {
        params.type = op.type;
            li.classList.remove('vector-toc-list-item-collapsed');
         params.quote = op.quote || '';
        });
        params.text = op.text || '';
         if ( op.ts ) params.ts = op.ts;
      }
      return ready().then( function ( a ) { return a.postWithToken( 'csrf', params ); } );
     }
     }


     function injectTocDocNav() {
     /* Drain the outbox oldest-first, stopping at the first failure so
        if (_isNoTocPage()) return;
    * ordering is preserved and a server that is down is not hammered. */
        var toc = document.querySelector('.vector-toc');
    function flush() {
        if (!toc) return;
      if ( !loggedIn ) return $.Deferred().resolve().promise();
        if (document.getElementById('gr-toc-doc-nav')) return;
      // Return the flush already running rather than a fresh resolved
        var artPath = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
      // promise: load() waits on this before reading, and handing it an
        var pageTitle = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
      // instantly-resolved promise would let the read race the writes.
        var teekaPage = document.querySelector('.gr-teeka-page');
      if ( flushing ) return flushing;
        var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || '') : '';
      if ( !outbox.length ) return $.Deferred().resolve().promise();
        if (!primarySlug) { primarySlug = pageTitle.split('/')[0]; }
        if (!primarySlug) return;
        function wikiUrl(slug) {
            if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug);
            return artPath.replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/'));
        }
        var docTitleEl = document.querySelector('.gr-doc-title');
        var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1';
        var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1';
        var showMoolaPage = !teekaPage && hasMoolaPage;
        var showMoolaBack = !!teekaPage;
        var showUllekha = hasUllekhaPage || !!teekaPage;
        if (!showMoolaPage && !showMoolaBack && !showUllekha) return;
        var nav = document.createElement('div');
        nav.id = 'gr-toc-doc-nav';
        nav.setAttribute('class', 'toc-main-links');
        function makeBtn(href, label) {
            var a = document.createElement('a');
            a.href = href;
            a.setAttribute('class', 'toc-main-link-item');
            var lspan = document.createElement('span');
            lspan.setAttribute('data-deva', label);
            lspan.textContent = (currentScript && currentScript !== 'deva') ? transliterateText(label, currentScript) : label;
            translatableSpans.push(lspan);
            a.appendChild(lspan);
            return a;
        }
        if (showMoolaPage) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
        if (showMoolaBack) nav.appendChild(makeBtn(wikiUrl(primarySlug), 'मूल'));
        if (showUllekha) nav.appendChild(makeBtn(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
        // Under the document's own title in the page, the same as on a
        // phone — not tucked into the contents panel, where these were
        // easy to miss. The contents panel is only the fallback.
        var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
        if (docTitle && docTitle.parentNode) { docTitle.parentNode.insertBefore(nav, docTitle.nextSibling); return; }
        var h1 = document.getElementById('firstHeading') || document.querySelector('.mw-first-heading');
        if (h1 && h1.parentNode) { h1.parentNode.insertBefore(nav, h1.nextSibling); return; }
        var tocContents = toc.querySelector('.vector-toc-contents');
        if (tocContents) toc.insertBefore(nav, tocContents);
        else toc.appendChild(nav);
    }


    var _headingObserver = null;
      var d = $.Deferred();
    var _tocActiveId = null;
      flushing = d.promise();
 
      ( function step() {
    // NEW: moved out of attachHeadingObserver's own local scope so they
         if ( !outbox.length ) { flushing = null; d.resolve(); return; }
    // can be reused both by the scroll observer AND by a direct trigger
         var op = outbox[ 0 ];
    // for search-result clicks — was previously only reachable from
        send( op ).done( function () {
    // inside attachHeadingObserver's own IntersectionObserver callback.
          dequeue( op );
    // _tocActiveId (was _activeId, local to attachHeadingObserver) is
          // Guard against a queue that cannot shrink, which would loop
    // module-level now for the same reason — both callers need to share
          // re-POSTing the same row for ever. Checked by identity, not by
    // the same "what's currently active" state, not track it separately.
          // length: a note written while this request was in flight makes
    function getTocLink(id) {
          // the queue longer, and comparing lengths stopped the drain with
         var toc = document.querySelector('.vector-toc');
          // that newer write still unsent.
        if (!toc) return null;
          if ( outbox.indexOf( op ) !== -1 ) { flushing = null; d.resolve(); return; }
         return toc.querySelector('a[href="#' + CSS.escape(id) + '"]');
          step();
    }
         } ).fail( function ( code ) {
    function getTocLi(id) {
          // Verdicts the server will never change its mind about. Left at
        var a = getTocLink(id);
          // the head of the queue they block every later write on this page
        return a ? a.closest('.vector-toc-list-item') : null;
          // for ever, so they are dropped and the drain carries on.
    }
          if ( TERMINAL[ code ] ) {
    // Highlighting is class-only (styled in Common.css §6):
             dequeue( op );
    //   .gr-toc-current  — the one entry being read, on the <a> itself
             step();
    //   .gr-toc-in-path  — its parent sections, marked on the <li>
             return;
    // Previously this set inline colours on the active item AND on every
          }
    // element inside it, so a section with sub-sections lit up whole.
          // Anything else — offline, 5xx, session expired — is temporary.
    function clearActiveToc() {
          // Stop here so ordering is kept and a struggling server is not
        var toc = document.querySelector('.vector-toc');
          // hammered; the next write, `online`, or page load retries.
        if (!toc) return;
          flushing = null;
        toc.querySelectorAll('.gr-toc-current').forEach(function (el) { el.classList.remove('gr-toc-current'); });
          d.resolve();
        toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
         } );
    }
      }() );
    function ownLink(li) {
      return flushing;
         return li.querySelector(':scope > .vector-toc-link') || li.querySelector('.vector-toc-link');
    }
    function isShown(el) {
        return !!(el && (el.offsetParent || (el.getClientRects && el.getClientRects().length)));
    }
    // Open a collapsed section so a given entry can be seen. Vector marks a
    // top-level item open with vector-toc-list-item-expanded plus
    // aria-expanded on its toggle, so this matches what its own button does.
    function expandTocFor(li) {
        var top = li.closest ? li.closest('.vector-toc-level-1') : null;
        if (!top) return;
        if (!top.classList.contains('vector-toc-list-item-expanded')) {
             top.classList.add('vector-toc-list-item-expanded');
             var tg = top.querySelector('.vector-toc-toggle');
             if (tg) tg.setAttribute('aria-expanded', 'true');
        }
        var anc = li.parentNode;
        while (anc && anc.classList) {
            anc.classList.remove('vector-toc-list-item-collapsed');
            if (anc.classList.contains('vector-toc')) break;
            anc = anc.parentNode;
        }
    }
 
    // expand=true when the reader picked the entry (TOC click, search
    // result, #hash): open its section so the exact entry is shown.
    // expand=false while scrolling: never open or close anything — if the
    // entry is hidden inside a collapsed section, the section itself is
    // highlighted instead, so the reader still sees where they are.
    function setActiveToc(id, expand) {
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        var shownNow = toc.querySelector('.gr-toc-current');
        if (_tocActiveId === id && !expand && shownNow && isShown(shownNow)) return;
        _tocActiveId = id;
        clearActiveToc();
        if (!id) return;
        var li = getTocLi(id);
        if (!li) return;
        if (expand) expandTocFor(li);
 
        var lnk = ownLink(li);
        var hostLi = li;
        if (lnk && !isShown(lnk)) {          // hidden in a collapsed section
            var p = li.parentNode;
            while (p && p !== toc) {
                if (p.classList && p.classList.contains('vector-toc-list-item')) {
                    var pl = ownLink(p);
                    if (pl && isShown(pl)) { lnk = pl; hostLi = p; break; }
                }
                p = p.parentNode;
            }
         }
        if (lnk) lnk.classList.add('gr-toc-current');
        // Sections above it get the quieter "you are in here" treatment.
        var anc = hostLi.parentNode;
        while (anc && anc !== toc) {
            if (anc.classList && anc.classList.contains('vector-toc-list-item')) anc.classList.add('gr-toc-in-path');
            anc = anc.parentNode;
        }
        keepTocEntryVisible(lnk || hostLi);
     }
     }


    // Scroll the TOC panel so the active entry is visible. This used to
     function write( op ) {
    // measure the whole <li>, which for a section with sub-sections is as
      if ( !loggedIn ) return;  // nothing to send, and nothing to accumulate
    // tall as the entire branch — so "centre it" landed somewhere inside
      enqueue( op );
    // the NEXT section. It now measures the link itself, and finds the
      flush();
    // element that actually scrolls rather than assuming one.
     function keepTocEntryVisible(el) {
        if (!el) return;
        var sc = el.parentNode;
        while (sc && sc !== document.body && sc.nodeType === 1) {
            var oy = window.getComputedStyle(sc).overflowY;
            if ((oy === 'auto' || oy === 'scroll') && sc.scrollHeight > sc.clientHeight + 4) break;
            sc = sc.parentNode;
        }
        if (!sc || sc === document.body || sc.nodeType !== 1) return;  // nothing scrolls: leave the page alone
        var er = el.getBoundingClientRect(), cr = sc.getBoundingClientRect();
        if (er.top >= cr.top + 8 && er.bottom <= cr.bottom - 8) return; // already in view
        sc.scrollTop += (er.top - cr.top) - Math.max((sc.clientHeight - er.height) / 2, 0);
     }
     }


     // NEW: given any element in the article content, finds the nearest
     /* ── public ────────────────────────────────────────────────────── */
    // heading (with an id) that precedes it in document order, using
    // compareDocumentPosition — robust regardless of nesting depth.
    // Verified against a realistic multi-section fragment, including
    // the "before any heading" and "right after a chapter heading, no
    // sub-heading yet" edge cases, before wiring this in.
    function findNearestHeadingId(target) {
        var content = document.querySelector('.mw-parser-output');
        if (!content) return null;
        var headings = content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]');
        var nearest = null;
        for (var i = 0; i < headings.length; i++) {
            var h = headings[i];
            var pos = h.compareDocumentPosition(target);
            if (pos & Node.DOCUMENT_POSITION_FOLLOWING) {
                nearest = h.id;
            } else {
                break;
            }
        }
        return nearest;
    }
 
    // NEW: exposed so the search-highlight IIFE (below) can directly
    // trigger the TOC to expand and highlight the right section right
    // after a search-result scroll lands — rather than relying solely on
    // the IntersectionObserver to catch up on its own timing.
    window.grHighlightTocForElement = function (el) {
        if (!el) return;
        var id = findNearestHeadingId(el);
        if (id) pinActive(id);
    };


     // ── Which section am I reading? ────────────────────────────────
     /* What has been written locally since the last answer from the server.
    // Was an IntersectionObserver with a shrunken root band. Two faults
    * The outbox alone is not enough to reconcile with: a note written while
    // came out of that, both reported as "the highlight is wrong":
    * the initial read was in flight may well have finished its own POST
    //  • right after jumping to a heading, that heading sits ABOVE the
    * first, and would then be gone from the outbox by the time the older
    //    band, so nothing was "visible" and the fallback picked the
    * answer arrives — which would take it off the screen and out of the
    //    PREVIOUS heading — clicking a sub-section highlighted its parent;
    * cache. These two maps outlive the outbox entry and are cleared only
    //  • once every heading was outside the band, no intersection ever
    * when a read has actually been folded in. */
    //    changed again, so the observer stopped firing and the highlight
     var localSaves = {}, localDeletes = {};
    //    froze wherever it was.
    // This measures instead: the active section is the last heading above
    // a reading line just under the sticky toolbar. It updates on every
    // scroll frame and can't go stale.
     var _tocHeadings = [];
    var _tocTops = [];
    var _tocMeasuredHeight = 0;
    // A clicked / jumped-to heading is "pinned": it stays the active entry
    // until the reader actually scrolls away from it. Without this, the
    // anchor jump leaves the heading below the reading line (the sticky
    // bar and scroll-margin push it down the viewport), so the measurement
    // picked the heading ABOVE it and the highlight jumped back one.
    var _pin = null;                                  // {id, y, until}


     function measureHeadings() {
     function save( type, rec ) {
        var y = window.pageYOffset || document.documentElement.scrollTop || 0;
      localSaves[ rec.id ] = { type: type, rec: rec };
        _tocTops = _tocHeadings.map(function (h) { return h.getBoundingClientRect().top + y; });
      delete localDeletes[ rec.id ];
        _tocMeasuredHeight = document.documentElement.scrollHeight;
      write( {
    }
        op: 'save', id: rec.id, page: pageTitle, type: type,
    function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
         quote: rec.quote || '', text: type === 'bookmark' ? rec.name : rec.text,
    // The line down the page that decides "which section am I reading":
         ts: rec.ts
    // just below whatever is stuck to the top, plus a quarter of the
      } );
    // screen, so a heading sitting near the top of the viewport counts as
    // the one being read rather than the one before it.
    function readingLine() {
         var bar = document.getElementById('gr-static-bar');
        var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0;
         var vh = window.innerHeight || document.documentElement.clientHeight || 800;
        return scrollY() + barBottom + Math.max(Math.round(vh * 0.25), 80);
     }
     }
    // Scroll something into a comfortable reading position and KEEP it
     function remove( id ) {
    // there. A plain scrollIntoView lands short or long whenever the page
      localDeletes[ id ] = true;
    // reflows just after the jump — a web font finishing, a commentary
      delete localSaves[ id ];
    // being interleaved, the match itself being wrapped in a highlight —
      write( { op: 'delete', id: id } );
    // which is why search results came to rest slightly above or below
    // the text. This aims just under whatever is stuck to the top, then
    // re-checks until the page has settled, and gives up the moment the
    // reader scrolls, so it never fights them.
     function grDesiredTop() {
        var bar = document.getElementById('gr-static-bar');
        var barBottom = bar ? Math.max(bar.getBoundingClientRect().bottom, 0) : 0;
        var vh = window.innerHeight || document.documentElement.clientHeight || 800;
        return barBottom + Math.max(Math.round(vh * 0.22), 72);
     }
     }
    window.grScrollToElement = function (el) {
        if (!el || !el.getBoundingClientRect) return;
        var cancelled = false, ticks = 0, lastY = null, iv = null;
        function onUser() { cancelled = true; }
        function cleanup() {
            if (iv) clearInterval(iv);
            window.removeEventListener('wheel', onUser);
            window.removeEventListener('touchstart', onUser);
            window.removeEventListener('keydown', onUser);
        }
        window.addEventListener('wheel', onUser, { passive: true });
        window.addEventListener('touchstart', onUser, { passive: true });
        window.addEventListener('keydown', onUser);
        function delta() { return el.getBoundingClientRect().top - grDesiredTop(); }
        try { window.scrollBy({ top: delta(), left: 0, behavior: 'smooth' }); }
        catch (e) { window.scrollBy(0, delta()); }
        iv = setInterval(function () {
            ticks++;
            if (cancelled || ticks > 12 || !document.body.contains(el)) { cleanup(); return; }
            var y = scrollY();
            if (lastY !== null && Math.abs(y - lastY) < 1) {    // the scroll has come to rest
                var d = delta();
                if (Math.abs(d) > 6) window.scrollBy(0, d);      // reflow moved it: correct at once
                else { cleanup(); return; }
            }
            lastY = y;
        }, 200);
    };


     // Called when the reader picks a heading (TOC click, search result,
     /* Cache what is on screen, so the next load of this page paints from it
    // or a #hash in the URL) — that heading wins until they scroll on.
    * before the network answers.
    function pinActive(id) {
    *
        if (!id) return;
    * The cache records whose it is, for both signed-in and anonymous
        _pin = { id: id, y: scrollY(), until: Date.now() + 900 };
    * readers. The keys are per page, not per user, and always have been, so
        setActiveToc(id, true);          // open its section if collapsed
    * without an owner two people sharing a machine would see each other's
        scheduleRecheck(950);
    * notes — and worse, the next person to sign in would have the previous
    }
    * one's notes uploaded into their account by migrate(). Recording null
    var _recheck = null;
    * for "nobody was signed in" is what makes those two cases tellable
     function scheduleRecheck(ms) {
    * apart from genuinely pre-upgrade data. */
        if (_recheck) return;
     function cache() {
        _recheck = setTimeout(function () { _recheck = null; updateActiveFromScroll(); }, ms);
      ls( NT_LS_KEY, JSON.stringify( { u: whoami(), v: _notes } ) );
    }
      ls( BM_LS_KEY, JSON.stringify( { u: whoami(), v: _bookmarks } ) );
    function updateActiveFromScroll() {
        if (!_tocHeadings.length) return;
        if (_pin) {
            var y = scrollY();
            if (Date.now() < _pin.until) {              // still settling after the jump
                _pin.y = y;
                setActiveToc(_pin.id, true);
                scheduleRecheck(_pin.until - Date.now() + 30);
                return;
            }
            if (Math.abs(y - _pin.y) < 60) {            // reader hasn't moved off it
                setActiveToc(_pin.id);
                return;
            }
            _pin = null;                                // they've scrolled on
        }
        // Interleaving a commentary or changing the font size moves things;
        // re-measure when the page's height has changed.
        if (document.documentElement.scrollHeight !== _tocMeasuredHeight) measureHeadings();
        var line = readingLine(), lo = 0, hi = _tocTops.length - 1, idx = 0;
        while (lo <= hi) {                               // last heading at or above the line
            var mid = (lo + hi) >> 1;
            if (_tocTops[mid] <= line + 1) { idx = mid; lo = mid + 1; } else { hi = mid - 1; }
        }
        setActiveToc(_tocHeadings[idx].id);
     }
     }


     function attachHeadingObserver() {
     function cached( key ) {
        if (_isNoTocPage()) return;
      var raw = readJson( key, null );
        if (_headingObserver) return;
      if ( !raw ) return [];
        var content = document.querySelector('.mw-parser-output');
      // A bare array predates this version; only migrate() may claim it.
        if (!content) return;
      if ( Array.isArray( raw ) ) return [];
        if (!document.querySelector('.vector-toc')) return;
      return ( raw.u === whoami() && Array.isArray( raw.v ) ) ? raw.v : [];
        // Only headings the contents lists — a heading with no entry (the
        // commentary list, say) would otherwise leave nothing highlighted.
        _tocHeadings = Array.prototype.slice.call(content.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]'))
            .filter(function (h) { return !!getTocLink(h.id); });
        if (!_tocHeadings.length) return;
        measureHeadings();
        // Time-based throttle rather than a requestAnimationFrame latch: if
        // one frame callback is ever skipped (background tab, a jump during
        // load) a latch stays stuck and the highlight stops updating for
        // good — which is one of the ways this went stale before.
        var lastRun = 0, trailing = null;
        function onScroll() {
            var now = Date.now();
            if (now - lastRun >= 80) { lastRun = now; updateActiveFromScroll(); }
            else if (!trailing) {
                trailing = setTimeout(function () { trailing = null; lastRun = Date.now(); updateActiveFromScroll(); }, 80);
            }
        }
        window.addEventListener('scroll', onScroll, { passive: true });
        window.addEventListener('resize', function () { measureHeadings(); onScroll(); }, { passive: true });
        if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
            document.fonts.ready.then(function () { measureHeadings(); onScroll(); });
        }
        _headingObserver = { onScroll: onScroll };      // marks "already wired"
        var hash = '';
        try { hash = decodeURIComponent((location.hash || '').slice(1)); } catch (e) { hash = (location.hash || '').slice(1); }
        if (hash && document.getElementById(hash)) pinActive(hash);
        else updateActiveFromScroll();
     }
     }


     // Clicking an entry highlights it straight away, rather than waiting
     function fromCache() {
    // for the scroll observer to catch up after the jump.
      _notes = cached( NT_LS_KEY );
    function attachTocClickHandler(toc) {
      _bookmarks = cached( BM_LS_KEY );
        if (toc.getAttribute('data-gr-click-wired')) return;
        toc.setAttribute('data-gr-click-wired', '1');
        toc.addEventListener('click', function (e) {
            var a = e.target.closest ? e.target.closest('.vector-toc-link') : null;
            if (!a) return;
            var href = a.getAttribute('href') || '';
            if (href.charAt(0) !== '#') return;
            var id;
            try { id = decodeURIComponent(href.slice(1)); } catch (err) { id = href.slice(1); }
            if (id) pinActive(id);
        });
     }
     }


     function setupToc() {
     /* ── Claiming ────────────────────────────────────────────────────
        if (_isNoTocPage()) return;
    * Hands over whatever this browser holds with no owner: the bare array
        var toc = document.querySelector('.vector-toc');
    * the old gadget wrote, and the anonymous cache this one writes. Both
        if (!toc) return;
    * mean "notes taken on this browser with nobody signed in", which is
        removeTocBeginning();
    * what a reader who browsed logged out and then registered has.
        removeTocTeekaEntry();
    *
        stripTocNumbers();
    * There is no "already migrated" flag, deliberately. A flag has to be
        watchTocNumbers();
    * scoped, and every scoping is wrong somewhere: per user alone marked
        renameTocTitle();
    * every page but the first as done; per user and page was set on the
        expandTocSections();
    * first visit even when there was nothing to hand over, so notes taken
        injectTocDocNav();
    * anonymously *afterwards* could never be claimed and were overwritten
        attachTocClickHandler(toc);
    * on the next sign-in.
        attachHeadingObserver();
    *
    * What actually marks the job done is cache(), which replaces the
    * ownerless value under the same key with an owned one. After a claim
    * there is nothing left to claim, so a note deleted later on another
    * device cannot be resurrected by a stale array — and if the browser
    * refuses to store, re-claiming is harmless anyway, because the ids are
    * unchanged and the server's save is an upsert. */
    /* What a signing-in user may claim as their own: the bare array the old
    * gadget wrote, and the anonymous cache this one writes. Both mean "this
    * browser's notes, with nobody signed in" — which is what someone who
    * read logged out and then registered has. Without the second case,
    * signing in read them as unclaimable, then overwrote them with the
    * server's empty answer and they were gone for good. */
    function legacy( key ) {
      var raw = readJson( key, null );
      if ( Array.isArray( raw ) ) return raw;
      if ( raw && raw.u === null && Array.isArray( raw.v ) ) return raw.v;
      return [];
     }
     }
 
     function migrate() {
     function init() {
      if ( !loggedIn ) return false;
        var HIDE_IDS = ['vector-appearance', 'vector-appearance-pinned-container', 'vector-appearance-unpinned-container'];
      var found = false;
        function removeHiddenEls() {
      legacy( NT_LS_KEY ).forEach( function ( n ) {
            HIDE_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); });
        if ( !n || !n.id ) return;
            var pt = document.getElementById('vector-page-tools') || document.querySelector('.vector-page-tools-pinned-container');
         // The old gadget imposed no length limit, so a note from before the
            if (pt) pt.querySelectorAll('[aria-controls="vector-appearance"]').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
        // upgrade can be past what the server accepts. Left in, it would sit
         }
         // at the head of the queue; dropped here, it stays on screen and in
         removeHiddenEls();
         // the cache, just not synced.
         (function detectTeekaMode() {
        if ( byteLength( n.text || '' ) > MAX_NOTE_BYTES ) return;
            var tp = document.querySelector('.gr-teeka-page');
        found = true;
            if (!tp) return;
        enqueue( { op: 'save', id: n.id, page: pageTitle, type: 'note',
            var primary = tp.getAttribute('data-primary') || '';
                  quote: n.quote || '', text: n.text || '', ts: n.ts } );
            var artPath = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1';
      } );
            var mainUrl = artPath.replace('$1', primary);
      legacy( BM_LS_KEY ).forEach( function ( b ) {
            var refParam = window.location.search.match(/[?&]ref=([01])/);
        if ( !b || !b.id ) return;
            if (refParam) { document.body.classList.add(refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone'); return; }
        found = true;
            var ref = document.referrer || '';
        enqueue( { op: 'save', id: b.id, page: pageTitle, type: 'bookmark',
            document.body.classList.add((ref && primary && ref.indexOf(mainUrl) !== -1) ? 'gr-ref-mode' : 'gr-standalone');
                  quote: b.quote || '', text: b.name || '', ts: b.ts } );
        }());
      } );
        if (window.MutationObserver) {
      return found;
            var hideObs = new MutationObserver(function (mutations) {
                var dirty = false;
                mutations.forEach(function (m) { if (m.addedNodes.length) dirty = true; });
                if (dirty) removeHiddenEls();
            });
            hideObs.observe(document.body, { childList: true, subtree: false });
            setTimeout(function () { hideObs.disconnect(); }, 6000);
        }
        var content = document.querySelector('.mw-parser-output');
        var alreadyTagged = content && content.querySelector('[data-deva]');
        if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
        else {
            document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
        }
        var saved = (function () { try { return localStorage.getItem(LS_SCRIPT_KEY); } catch (e) { return null; } }());
        if (saved && saved !== 'deva') { applyScript(saved); } else { currentScript = 'deva'; }
        watchNewContent();
        setTimeout(setupToc, 200);
     }
     }


     window.addEventListener('gr-script-change', function (e) {
     /* ── Reconciliation ──────────────────────────────────────────────
        var script = e && e.detail && e.detail.script;
    * The server's answer describes the moment the request was sent. A note
        if (script) applyScript(script);
    * written while it was in the air, or a delete, is in the outbox and not
    });
    * in that answer — replacing state wholesale would take it off the
 
    * screen and out of the cache, and the reader would watch their note
    var _translitBusy = false;
    * disappear. So anything still pending is re-applied on top. */
    function tagNewContent(container) {
     function reconcile( notes, marks ) {
        if (!container || !container.nodeType) return;
      function apply( list, type ) {
        _translitBusy = true;
        var out = list.filter( function ( r ) {
        try {
          return !localDeletes[ r.id ] && !localSaves[ r.id ];
            var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
         } );
            var nodes = [];
         Object.keys( localSaves ).forEach( function ( id ) {
            while (walker.nextNode()) nodes.push(walker.currentNode);
          var s = localSaves[ id ];
            nodes.forEach(function (node) {
          if ( s.type === type ) out.push( s.rec );
                var p = node.parentNode;
         } );
                if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
         return out;
                if (p.closest && (p.closest('.gr-controls') || p.closest('.mw-editsection')
      }
                                  || p.closest('.gr-note-marker'))) return;
      return { notes: apply( notes, 'note' ), marks: apply( marks, 'bookmark' ) };
                var orig = node.textContent;
                if (!orig.trim()) return;
                var span = document.createElement('span');
                span.setAttribute('data-deva', orig);
                span.textContent = currentScript !== 'deva' ? transliterateText(orig, currentScript) : orig;
                p.replaceChild(span, node);
                translatableSpans.push(span);
            });
        } finally { _translitBusy = false; }
    }
    window.addEventListener('gr-new-content', function (e) {
        tagNewContent(e && e.detail && e.detail.container);
    });
 
    // Safety net: anything else that adds text to the page later — an
    // interleaved commentary, a sub-commentary, an edit preview — is picked
    // up and put into the reader's chosen script, rather than being left in
    // Devanagari because it wasn't there when the page was first tagged.
     function watchNewContent() {
        if (!window.MutationObserver) return;
        var content = document.querySelector('.mw-parser-output');
        if (!content || content.getAttribute('data-gr-translit-watch')) return;
        content.setAttribute('data-gr-translit-watch', '1');
        var queued = [], timer = null;
        function flush() {
            timer = null;
            var items = queued; queued = [];
            items.forEach(function (n) { if (document.body.contains(n)) tagNewContent(n); });
        }
        new MutationObserver(function (muts) {
            if (_translitBusy) return;
            muts.forEach(function (m) {
                Array.prototype.forEach.call(m.addedNodes, function (n) {
                    var el = n.nodeType === 1 ? n : (n.nodeType === 3 ? n.parentNode : null);
                    if (!el || el.nodeType !== 1) return;
                    if (el.hasAttribute && el.hasAttribute('data-deva')) return;
                    queued.push(el);
                });
            });
            if (queued.length && !timer) timer = setTimeout(flush, 60);
         }).observe(content, { childList: true, subtree: true });
    }
 
    try {
         var _grBC = new BroadcastChannel('gr-script');
        _grBC.onmessage = function (e) {
            var script = e && e.data && e.data.script;
            if (script) { currentScript = script; var sel = document.querySelector('.gr-script-sel'); if (sel) sel.value = script; applyScript(script); }
        };
    } catch (e) { }
 
    if (window.mw) {
        mw.hook('wikipage.content').add(function () {
            setTimeout(function () {
                var content = document.querySelector('.mw-parser-output');
                var alreadyTagged = content && content.querySelector('[data-deva]');
                if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
                else {
                    // tagTocEntry, not a re-tag from textContent: an entry
                    // keeps its Devanagari in a span *inside* .vector-toc-text,
                    // so .vector-toc-text itself never carries data-deva and a
                    // :not([data-deva]) sweep matched every entry on every
                    // fire. It then stored the text as it stood — Tamil, once
                    // the reader had switched — as that entry's "original", so
                    // the next switch had no Devanagari left to work from and
                    // the contents panel stayed in the previous script while
                    // the body moved on. tagTocEntry leaves a tagged entry be.
                    document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
                }
                if (currentScript !== 'deva') applyScript(currentScript);
                setupToc();
            }, 150);
        });
    }
 
    if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); }
    else { init(); }
 
}());  /* ← end of main IIFE */
 
 
// ── Inject "Help" and "About" links into the header ─────────────────
(function () {
    function wikiHref(title) {
        if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
         var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
         return ap.replace('$1', title);
    }
    function makeHeaderLink(id, href, label) {
        var a = document.createElement('a');
        a.id = id; a.href = href; a.textContent = label;
        a.className = 'gr-header-link';  // styled in Common.css §37
        return a;
    }
    function injectHeaderLinks() {
        if (document.getElementById('gr-about-link')) return;
        var headerEnd = document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header');
        if (!headerEnd) return;
        var helpLink = makeHeaderLink('gr-help-link', wikiHref('My_wiki:Help'), 'Help');
        var aboutLink = makeHeaderLink('gr-about-link', wikiHref('My_wiki:About'), 'About');
 
        var userName = window.mw ? mw.config.get('wgUserName') : null;
        var isAnon = !userName || (window.mw && mw.config.get('wgUserId') === null);
 
        var authEl = null;
        if (isAnon) {
            if (!document.getElementById('gr-header-login')) {
                authEl = makeHeaderLink('gr-header-login', wikiHref('Special:UserLogin'), 'Login');
            }
        } else if (!document.getElementById('gr-header-user')) {
            authEl = buildHeaderUserDropdown(userName);
        }
 
        var ul = document.querySelector('.vector-user-links') || document.querySelector('#pt-userpage');
        if (ul && ul.parentNode === headerEnd) {
            if (authEl) headerEnd.insertBefore(authEl, ul);
            headerEnd.insertBefore(aboutLink, authEl || ul);
            headerEnd.insertBefore(helpLink, aboutLink);
        } else {
            headerEnd.appendChild(helpLink);
            headerEnd.appendChild(aboutLink);
            if (authEl) headerEnd.appendChild(authEl);
        }
     }
     }


     function buildHeaderUserDropdown(userName) {
    /* Fill _notes and _bookmarks. The cache is in place synchronously so the
        var wrap = document.createElement('div');
    * first paint is instant; the server's answer lands later. */
        wrap.id = 'gr-header-user';
     function load() {
        wrap.style.cssText = 'position:relative;display:inline-block;margin-right:4px;';
      outbox = readJson( qKey(), [] );
      if ( !Array.isArray( outbox ) ) outbox = [];


        var trigger = document.createElement('button');
      if ( !loggedIn ) {
         trigger.id = 'gr-header-user-trigger';
         // localStorage is the store, not a cache. Accept the owner-stamped
         trigger.type = 'button';
         // shape and, once, the bare array the old gadget wrote.
         trigger.setAttribute('aria-haspopup', 'true');
         _notes = cached( NT_LS_KEY );
         trigger.setAttribute('aria-expanded', 'false');
         _bookmarks = cached( BM_LS_KEY );
         trigger.style.cssText = [
         if ( !_notes.length ) _notes = legacy( NT_LS_KEY );
            'display:inline-flex', 'align-items:center', 'gap:5px',
         if ( !_bookmarks.length ) _bookmarks = legacy( BM_LS_KEY );
            'color:rgba(255,255,255,0.88)', 'font-size:0.88em',
         return $.Deferred().resolve( false ).promise();
            'font-family:system-ui,sans-serif', 'font-weight:500',
      }
            'background:transparent', 'border:none', 'cursor:pointer',
            'padding:4px 10px', 'border-radius:4px', 'white-space:nowrap',
            'transition:color 0.15s,background 0.15s'
        ].join(';');
         trigger.innerHTML =
            '<span>' + userName.replace(/</g, '&lt;') + '</span>' +
            '<span id="gr-header-caret" style="font-size:0.8em;transition:transform 0.2s;">\u25be</span>';
        trigger.addEventListener('mouseover', function () { this.style.color = '#fff'; this.style.background = 'rgba(255,255,255,0.12)'; });
         trigger.addEventListener('mouseout', function () { if (menu.style.display === 'none') { this.style.color = 'rgba(255,255,255,0.88)'; this.style.background = 'transparent'; } });


         var menu = document.createElement('div');
      if ( migrate() ) {
         menu.id = 'gr-header-user-menu';
         // Show what this browser already had straight away. migrate() has
         menu.setAttribute('role', 'menu');
        // just claimed it for whoever is signed in now, so what is on screen
         menu.style.cssText = [
        // and what is being sent up are the same set — and a first run that
            'display:none', 'position:absolute', 'top:100%', 'right:0', 'margin-top:4px',
        // happens to be offline still shows the reader their notes rather
            'min-width:150px', 'background:#fff', 'border-radius:6px',
         // than an empty panel.
            'box-shadow:0 4px 16px rgba(0,0,0,0.18)', 'overflow:hidden', 'z-index:1000'
        _notes = legacy( NT_LS_KEY );
         ].join(';');
         _bookmarks = legacy( BM_LS_KEY );
         cache();
      } else {
         fromCache();
      }


        var itemStyle = 'display:block;padding:10px 16px;font-size:0.9em;color:#2c1810;text-decoration:none;font-family:system-ui,sans-serif;background:#fff;';
      var d = $.Deferred();
        function makeMenuItem(href, label, id) {
      var rows = [];
            var a = document.createElement('a');
            a.href = href; a.setAttribute('role', 'menuitem');
            a.textContent = label; a.style.cssText = itemStyle;
            if (id) a.id = id;
            a.addEventListener('mouseover', function () { this.style.background = '#f5efe9'; });
            a.addEventListener('mouseout', function () { this.style.background = '#fff'; });
            return a;
        }
 
        menu.appendChild(makeMenuItem(wikiHref('Special:Profile'), 'Profile'));
        menu.appendChild(makeMenuItem('#', 'Log out', 'gr-header-logout'));
 
        function setOpen(open) {
            menu.style.display = open ? 'block' : 'none';
            trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
            var caret = document.getElementById('gr-header-caret');
            if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
            if (open) { trigger.style.color = '#fff'; trigger.style.background = 'rgba(255,255,255,0.12)'; }
            else { trigger.style.color = 'rgba(255,255,255,0.88)'; trigger.style.background = 'transparent'; }
        }


        trigger.addEventListener('click', function (e) {
      // 500 is the server's maximum page size, and it orders newest first —
            e.stopPropagation();
      // so a reader with more than that on one page would silently lose
            setOpen(menu.style.display === 'none');
      // their *oldest* annotations from the panel and from the cache.
         });
      // Follow the continuation until it stops rather than assuming nobody
        document.addEventListener('click', function (e) {
      // will get there.
            if (!wrap.contains(e.target)) setOpen(false);
      function fetchFrom( offset ) {
         });
        return ready().then( function ( a ) {
          var params = {
            action: 'granthaannotations', page: pageTitle,
            limit: 500, formatversion: 2, format: 'json'
          };
          if ( offset ) params[ 'continue' ] = offset;
          return a.get( params );
         } ).then( function ( data ) {
          var block = data && data.granthaannotations;
          var got = block && block.annotations;
          if ( Array.isArray( got ) ) rows = rows.concat( got );
          // The module reports its own continuation, inside its own result.
          var next = block && block[ 'continue' ];
          // Guard against a server that keeps handing back the same offset.
          if ( next !== undefined && next !== null && String( next ) !== String( offset ) &&
              rows.length < 5000 ) {
            return fetchFrom( next );
          }
          return rows;
         } );
      }


        menu.querySelector('#gr-header-logout').addEventListener('click', function (e) {
      flush().always( function () {
            e.preventDefault();
        fetchFrom( null ).done( function () {
            if (window.mw && mw.user && mw.user.tokens) {
          var notes = [], marks = [];
                var t = mw.user.tokens.get('csrfToken');
          rows.forEach( function ( r ) {
                if (t) {
            if ( r.type === 'bookmark' ) {
                    var script = (mw.util && mw.util.wikiScript) ? mw.util.wikiScript() : '/index.php';
              marks.push( { id: r.id, name: r.text, quote: r.quote, ts: r.ts } );
                    var form = document.createElement('form');
                    form.method = 'post';
                    form.action = script + '?title=Special:UserLogout';
                    form.style.display = 'none';
                    form.innerHTML =
                        '<input type="hidden" name="wpEditToken" value="' + String(t).replace(/"/g, '&quot;') + '">' +
                        '<input type="hidden" name="title" value="Special:UserLogout">' +
                        '<input type="hidden" name="returnto" value="Main Page">';
                    document.body.appendChild(form);
                    form.submit();
                    return;
                }
            }
            if (window.mw && mw.Api) {
                new mw.Api().postWithToken('csrf', { action: 'logout' })
                    .done(function () { location.href = '/Main_Page'; })
                    .fail(function () { location.href = '/index.php?title=Special:UserLogout'; });
             } else {
             } else {
                location.href = '/index.php?title=Special:UserLogout';
              notes.push( { id: r.id, ts: r.ts, quote: r.quote, text: r.text } );
            }
        });
 
        wrap.appendChild(trigger);
        wrap.appendChild(menu);
        return wrap;
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectHeaderLinks);
    else injectHeaderLinks();
}());
 
 
// ── Main page: by-Grantha / by-Author toggle ──────────────────────
(function () {
    function grHomeView(v) {
        var gView = document.getElementById('gr-view-grantha');
        var aView = document.getElementById('gr-view-author');
        var gBtn = document.getElementById('gr-toggle-grantha');
        var aBtn = document.getElementById('gr-toggle-author');
        if (!gView || !aView || !gBtn || !aBtn) return;
        gView.style.display = (v === 'grantha') ? '' : 'none';
        aView.style.display = (v === 'author') ? '' : 'none';
        gBtn.className = 'gr-toggle-btn' + (v === 'grantha' ? ' gr-toggle-active' : '');
        aBtn.className = 'gr-toggle-btn' + (v === 'author' ? ' gr-toggle-active' : '');
        try { localStorage.setItem('gr_home_view', v); } catch (e) { }
    }
}());
 
 
// ── Ullekha reference link handler ─────────────────────────────────
(function () {
    // Uses the shared, reflow-proof scroller when it is available.
    function grScrollTo(el) {
        if (!el) return;
        if (window.grScrollToElement) window.grScrollToElement(el);
        else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
 
    function highlightOnArrival() {
        var search = window.location.search;
        if (!search) return;
        var m = search.match(/[?&]hlUllekha=([^&]+)/);
        if (!m) return;
        var needle;
        try { needle = decodeURIComponent(m[1]); } catch (e) { return; }
        if (!needle || needle.length < 4) return;
        function doHighlight() {
            var content = document.querySelector('.mw-parser-output');
            if (!content) return;
            var snippet = needle.slice(0, 40);
            var found = false;
            var spans = content.querySelectorAll('[data-deva]');
            for (var i = 0; i < spans.length && !found; i++) {
                var spanEl = spans[i];
                var orig = spanEl.getAttribute('data-deva') || '';
                if (orig.indexOf(snippet) === -1) continue;
                var idx = orig.indexOf(snippet);
                var hlText = orig.slice(idx, Math.min(idx + needle.length, orig.length));
                var mark = document.createElement('mark');
                mark.className = 'gr-ullekha-highlight';
                mark.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
                mark.textContent = hlText;
                var parent = spanEl.parentNode;
                if (!parent) continue;
                var before = document.createTextNode(orig.slice(0, idx));
                var after = document.createTextNode(orig.slice(idx + hlText.length));
                parent.insertBefore(before, spanEl); parent.insertBefore(mark, spanEl); parent.insertBefore(after, spanEl); parent.removeChild(spanEl);
                setTimeout(function () { grScrollTo(mark); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark); }, 100);
                found = true;
             }
             }
            if (!found) {
          } );
                var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
          // The server returns newest first; the panel reverses for display,
                while (walker.nextNode() && !found) {
          // so hold them oldest-first the way the local arrays always were.
                    var node = walker.currentNode;
          var merged = reconcile( notes.reverse(), marks.reverse() );
                    var txt = node.textContent || '';
          _notes = merged.notes;
                    if (txt.indexOf(snippet) === -1) continue;
          _bookmarks = merged.marks;
                    var idx2 = txt.indexOf(snippet);
          // Folded in, so the local record of what has changed since can
                    var mark2 = document.createElement('mark');
          // start again. Anything still unsent stays in the outbox.
                    mark2.className = 'gr-ullekha-highlight';
          localSaves = {}; localDeletes = {};
                    mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
          cache();
                    mark2.textContent = txt.slice(idx2, Math.min(idx2 + needle.length, txt.length));
          d.resolve( true );
                    var p = node.parentNode;
        } ).fail( function () {
                    p.insertBefore(document.createTextNode(txt.slice(0, idx2)), node);
          // Offline, or the extension is not installed. The cache already
                    p.insertBefore(mark2, node);
          // loaded, and anything written meanwhile is queued.
                    p.insertBefore(document.createTextNode(txt.slice(idx2 + mark2.textContent.length)), node);
          d.resolve( false );
                    p.removeChild(node);
        } );
                    setTimeout(function () { grScrollTo(mark2); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark2); }, 100);
      } );
                    found = true;
      return d.promise();
                }
            }
        }
        doHighlight();
     }
     }
    function wireUllekhaLinks() {
        document.querySelectorAll('.gr-ullekha-ref-link').forEach(function (wrap) {
            var anchor = wrap.getAttribute('data-anchor') || '';
            var hl = wrap.getAttribute('data-hl') || '';
            var a = wrap.querySelector('a');
            if (!a) return;
            var base = a.href.split('#')[0];
            var encoded = encodeURIComponent(hl);
            a.href = base + (hl ? '?hlUllekha=' + encoded : '') + (anchor ? '#' + anchor : '');
        });
    }
    if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { highlightOnArrival(); wireUllekhaLinks(); }); }
    else { highlightOnArrival(); wireUllekhaLinks(); }
}());


/* ── Search result highlight ──────────────────────────────────── */
     if ( loggedIn && window.addEventListener ) {
(function () {
      window.addEventListener( 'online', function () { flush(); } );
 
     function storeQueryForLink(url, query) {
        try {
            var a = document.createElement('a');
            a.href = url;
            sessionStorage.setItem('gr_search_hl', JSON.stringify({
                query: query,
                pathname: a.pathname
            }));
        } catch (e) { }
     }
     }


     function applyHighlight() {
     return { load: load, save: save, remove: remove, cache: cache, flush: flush,
        var stored;
            isRemote: function () { return loggedIn; } };
        try {
  }() );
            stored = JSON.parse(sessionStorage.getItem('gr_search_hl') || 'null');
        } catch (e) { return; }
        if (!stored || !stored.query) return;


        var currentPath = window.location.pathname;
  var $fab, $mobileBar, $panel, $backdrop;
        var storedPath = stored.pathname || '';
  var $ntComposer, $ntInput, $ntSubmit;
  var $bmComposer, $bmInput, $bmSubmit;
  var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
  // CHANGED: added $tabFootnotes / $paneFootnotes alongside the existing
  // Notes/Bookmarks tab+pane refs, for the Footnotes tab. REMOVED: the
  // earlier $tabSandarbha/$paneSandarbha — that feature was replaced by
  // the real Ullekha citation-index mechanism (see quickedit.js), which
  // works entirely through common.js's own site-wide logic and needs no
  // panel integration here at all.
  var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes;


        function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); }
  function buildDom() {
        if (storedPath && normPath(storedPath) !== normPath(currentPath)) {
    $fab = $( [
            try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
      '<div id="gra-fab" role="toolbar" aria-label="Feedback / Notes / Bookmark">',
            return;
      '  <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Note">',
        }
      '    <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Note</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Mark">',
      '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Mark</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Feedback">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Feedback</span>',
      '  </button>',
      '  <button class="gra-fab-btn" id="gra-fab-search" type="button" aria-label="Search">',
      '    <span class="gra-icon gra-icon-search" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Search</span>',
      '  </button>',
      ' <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">',
      '    <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>',
      '    <span class="gra-fab-btn-label">Close</span>',
      '  </button>',
      '</div>',
    ].join('') );
    $('body').append($fab);


        var query = stored.query.trim();
    $mobileBar = $('<div id="gra-mobile-bar"></div>');
        if (!query) return;
    $('body').append($mobileBar);


        try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
    $fbComposer = $( [
      '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
      '  <div class="gra-composer-header">',
      '    <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
      '    <strong>Feedback</strong>',
      '    <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>',
      '  </div>',
      '  <div class="gra-fb-quote-label">Selected text:</div>',
      '  <div class="gra-fb-quote" id="gra-fb-quote"></div>',
      '  <div class="gra-fb-field-label">Issue type</div>',
      '  <select class="gra-fb-select" id="gra-fb-issue">',
      '    <option value="">— Choose —</option>',
      '    <option value="wrong_text">Formatting error</option>',
      '    <option value="reference_issue">Reference issue</option>',
      '    <option value="spelling_mistake">Spelling mistake</option>',
      '    <option value="other">Other</option>',
      '  </select>',
      '  <div class="gra-fb-field-label">Details (optional)</div>',
      '  <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>',
      '  <div class="gra-fb-field-label">Your email (optional)</div>',
      '  <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">',
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-fb-submit" disabled>Send</button>',
      '  </div>',
      '  <div class="gra-fb-status" id="gra-fb-status"></div>',
      '</div>',
    ].join('') );
    $('body').append($fbComposer);


        var delays = [0, 200, 600];
    $ntComposer = $( [
        delays.forEach(function (ms) {
      '<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">',
            setTimeout(function () {
      '  <div class="gra-composer-user">',
                if (document.querySelector('.gr-search-hl')) return;
      '    <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
                highlightText(query);
      '    <div class="gra-composer-uname">' + esc(currentUser || 'Notes') + '</div>',
            }, ms);
      '  </div>',
        });
      '  <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>',
    }
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>',
      '  </div>',
      '</div>',
    ].join('') );
    $('body').append($ntComposer);


     // Uses the shared, reflow-proof scroller when it is available.
     $bmComposer = $( [
     function grScrollTo(el) {
      '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
        if (!el) return;
      '  <div class="gra-bm-composer-label">',
        if (window.grScrollToElement) window.grScrollToElement(el);
      '    <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
        else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      '    Save bookmark',
    }
      '  </div>',
      '  <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">',
      '  <div class="gra-composer-actions">',
      '    <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
      '    <button class="gra-btn-submit" id="gra-bm-submit">Save</button>',
      '  </div>',
      '</div>',
     ].join('') );
    $('body').append($bmComposer);


     function highlightText(query) {
     $panel = $( [
        var content = document.querySelector('#mw-content-text .mw-parser-output');
      '<div id="gra-panel" role="complementary" aria-label="Notes">',
        if (!content) return;
      '  <div id="gra-panel-head">',
      '    <div id="gra-panel-title"></div>',
      '    <button id="gra-panel-close" title="Close">✕</button>',
      '  </div>',
      '  <div id="gra-tabs">',
      '    <button class="gra-tab gra-tab-active" id="gra-tab-notes">',
      '      <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes',
      '    </button>',
      '    <button class="gra-tab" id="gra-tab-bookmarks">',
      '      <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks',
      '    </button>',
      '    <button class="gra-tab" id="gra-tab-footnotes">',
      '      <span class="gra-icon gra-icon-footnote" aria-hidden="true"></span> Footnotes',
      '    </button>',
      '  </div>',
      '  <div id="gra-panel-body">',
      '    <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>',
      '   <div class="gra-pane" id="gra-pane-bookmarks"></div>',
      '    <div class="gra-pane" id="gra-pane-footnotes"></div>',
      '  </div>',
      '</div>',
    ].join('') );
    $('body').append($panel);


        var raw = query.replace(/^"|"$/g, '').trim();
    $backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>');
        if (!raw) return;
    $('body').append($backdrop);


        var patterns = [];
    var $toggle = $( [
        patterns.push(escapeRegex(raw));
      '<button id="gra-toggle" aria-label="Notes">',
        raw.split(/\s+/).forEach(function (w) {
      '  <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>',
            if (w.length >= 2) patterns.push(escapeRegex(w));
      '  <span id="gra-toggle-badge" aria-live="polite"></span>',
        });
      '</button>',
    ].join('') );
    $('body').append($toggle);
    $toggle.on('click', function() {
      $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
    });


        var matched = false;
    $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
        for (var pi = 0; pi < patterns.length; pi++) {
    $tabNotes    = $('#gra-tab-notes');
            var re;
    $tabBookmarks = $('#gra-tab-bookmarks');
            try { re = new RegExp('(' + patterns[pi] + ')', 'gi'); }
    $tabFootnotes = $('#gra-tab-footnotes');
            catch (e) { continue; }
    $paneNotes    = $('#gra-pane-notes');
            var count = wrapMatches(content, re);
    $paneBookmarks= $('#gra-pane-bookmarks');
            if (count > 0) { matched = true; break; }
    $paneFootnotes= $('#gra-pane-footnotes');
        }
    $ntInput      = $('#gra-nt-input');
    $ntSubmit    = $('#gra-nt-submit');
    $bmInput      = $('#gra-bm-input');
    $bmSubmit    = $('#gra-bm-submit');
    $fbIssueType  = $('#gra-fb-issue');
    $fbText      = $('#gra-fb-text');
    $fbEmail      = $('#gra-fb-email');
    $fbSubmit    = $('#gra-fb-submit');
    $fbQuote      = $('#gra-fb-quote');
  }


        if (!matched) return;
  function captureSelection() {
    var sel = window.getSelection();
    if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
    var range = sel.getRangeAt(0);
    var text  = sel.toString().trim();
    if (!text || text.length < 2) return false;
    var contentEl = document.querySelector(CONTENT_SEL);
    if (!contentEl) return false;
    var ancestor = range.commonAncestorContainer;
    if (ancestor.nodeType === 3) ancestor = ancestor.parentNode;
    if (!ancestor || !contentEl.contains(ancestor)) return false;
    var _editorEl = document.getElementById('se-surface') ||
                    document.querySelector('.se-outer');
    if ( _editorEl && _editorEl.contains(ancestor) ) return false;
    if ( document.body.classList.contains('gra-editing') ) return false;
    _selText  = text;
    _selRect  = range.getBoundingClientRect();
    try { _selRange = range.cloneRange(); }
    catch(e){ _selRange = null; }
    return true;
  }


        var first = document.querySelector('.gr-search-hl');
  function reCaptureFromDOM() {
        if (first) {
    if (!_selText) return false;
            grScrollTo(first);
    var contentEl = document.querySelector(CONTENT_SEL);
            first.classList.add('gr-search-hl-pulse');
    if (!contentEl) return false;
            setTimeout(function () { first.classList.remove('gr-search-hl-pulse'); }, 2000);
    var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim());
            // NEW: directly trigger the TOC to expand and highlight the
    if (!found) return false;
            // right section, rather than relying solely on the scroll
    _selRange = found;
            // observer to catch up on its own. This is the actual fix —
    return true;
            // confirmed the helper against a realistic multi-section
  }
            // fragment before wiring it in here.
            if (window.grHighlightTocForElement) window.grHighlightTocForElement(first);
        }


        showDismissBar(query);
  function tryShowActions() {
     }
    if ($fbComposer && $fbComposer.hasClass('gra-composer-visible')) return;
     if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return;
    if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return;
    if (!captureSelection()) { hideActions(); return; }
    _fabSelVer = _selVersion;
    showFab(_selRect);
  }


    function escapeRegex(s) {
  function showFab(rect) {
        return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (!rect) return;
    var fabW, fabH, top, left;
    if (_mobile) {
      $fab.css({ position: '', top: '', left: '', visibility: '' })
          .addClass('gra-fab-visible gra-fab-mobile-docked');
      return;
     }
     }
    fabW = 46; fabH = 126;
    top  = rect.top + (rect.height / 2) - (fabH / 2);
    left = rect.right + 10;
    if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10;
    top  = clamp(top,  8, window.innerHeight - fabH - 8);
    left = clamp(left, 8, window.innerWidth  - fabW - 8);
    $fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible');
  }


    function wrapMatches(root, re) {
  function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); }
        var count = 0;
  function hideActions() { hideFab(); }
        var walker = document.createTreeWalker(
            root, NodeFilter.SHOW_TEXT, {
            acceptNode: function (node) {
                var p = node.parentElement;
                if (!p) return NodeFilter.FILTER_REJECT;
                var tag = p.tagName.toUpperCase();
                if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT;
                if (p.classList.contains('gr-search-hl')) return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            }
        }, false
        );


        var nodes = [];
  function wrapSelection(id, cssClass) {
        var node;
    var range = _selRange;
        while ((node = walker.nextNode())) nodes.push(node);
    _selRange = null;
 
    if (!range) return null;
        nodes.forEach(function (textNode) {
    try {
            var val = textNode.nodeValue;
      if (!document.contains(range.startContainer) ||
            if (!re.test(val)) return;
          !document.contains(range.endContainer)) return null;
            re.lastIndex = 0;
    } catch(e) { return null; }
 
    function makeSpan() {
            var frag = document.createDocumentFragment();
      var sp = document.createElement('span');
            var last = 0;
      sp.className = cssClass;
            var m;
      sp.setAttribute('data-gra-id', id);
            while ((m = re.exec(val)) !== null) {
      return sp;
                if (m.index > last) {
                    frag.appendChild(document.createTextNode(val.slice(last, m.index)));
                }
                var span = document.createElement('span');
                span.className = 'gr-search-hl';
                span.textContent = m[0];
                frag.appendChild(span);
                last = m.index + m[0].length;
                count++;
            }
            if (last < val.length) {
                frag.appendChild(document.createTextNode(val.slice(last)));
            }
            textNode.parentNode.replaceChild(frag, textNode);
        });
 
        return count;
     }
     }
    try {
      var span = makeSpan();
      range.surroundContents(span);
      if (span.parentNode) return span;
    } catch(e) {}
    try {
      var frag = range.extractContents();
      var sp2  = makeSpan();
      sp2.appendChild(frag);
      range.insertNode(sp2);
      if (sp2 && sp2.parentNode) return sp2;
    } catch(e2) {}
    return null;
  }


    function showDismissBar(query) {
  function openFeedbackComposer() {
        var existing = document.getElementById('gr-hl-bar');
    hideActions();
        if (existing) existing.remove();
    $fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : ''));
    $fbIssueType.val('');
    $fbText.val('');
    $fbSubmit.prop('disabled', true);
    $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
    if (currentUserEmail) $fbEmail.val(currentUserEmail);
    else $fbEmail.val('');
    if (!_mobile) $fbComposer.css({top:'', left:'', transform:''});
    $fbComposer.addClass('gra-composer-visible');
    $backdrop.addClass('gra-backdrop-visible');
    setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0);
  }


        var count = document.querySelectorAll('.gr-search-hl').length;
  function closeFeedbackComposer() {
        if (!count) return;
    $fbComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
    _selRange = null; _selText = ''; _selRect = null;
  }


        var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
  function submitFeedback() {
        var bar = document.createElement('div');
    var issueType = $fbIssueType.val();
        bar.id = 'gr-hl-bar';
    var details  = $fbText.val().trim();
 
    var email    = $fbEmail.val().trim();
        if (isMob) {
    var quote    = $fbQuote.text();
            bar.className = 'gr-search-hl-mobile';
    if (!issueType) return;
 
    $fbSubmit.prop('disabled', true).text('Sending…');
            bar.innerHTML =
    $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
                '<button id="gr-hl-results" class="gr-search-bar-btn">' +
    var issueLabels = {
                '<span class="gra-icon gra-icon-search" aria-hidden="true"></span>' +
      wrong_text: 'Formatting error', reference_issue: 'Reference issue',
                '<span>' + count + ' match' + (count === 1 ? '' : 'es') + '</span></button>' +
      spelling_mistake: 'Spelling mistake', other: 'Other'
                '<button id="gr-hl-prev" class="gr-search-bar-btn">' +
    };
                '<span class="gra-icon gra-icon-prev" aria-hidden="true"></span>' +
    var payload = new FormData();
                '<span>Prev</span></button>' +
    payload.append('issue_type',   issueLabels[issueType] || issueType);
                '<button id="gr-hl-next" class="gr-search-bar-btn">' +
    payload.append('page',         pageTitle.replace(/_/g,' '));
                '<span class="gra-icon gra-icon-next" aria-hidden="true"></span>' +
    payload.append('url',           window.location.href);
                '<span>Next</span></button>' +
    payload.append('selected_text', quote);
                '<button id="gr-hl-dismiss" class="gr-search-bar-btn">' +
    payload.append('details',      details || '');
                '<span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>' +
    payload.append('user_email',    email || currentUserEmail || '');
                '<span>Close</span></button>';
    payload.append('wiki_user',    currentUser || 'anonymous');
        } else {
    fetch('/feedback.php', {method:'POST', body:payload})
            bar.style.cssText = [
      .then(function(r){ return r.json(); })
                'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200',
      .then(function(data){
                'background:#b5451b', 'color:#fff', 'padding:10px 16px',
        if (data && data.ok) showFeedbackSuccess();
                'display:flex', 'align-items:center', 'justify-content:space-between',
        else showFeedbackError(data && data.error ? data.error : 'Could not send.');
                'font-family:system-ui,sans-serif', 'font-size:14px',
      })
                'box-shadow:0 -2px 8px rgba(0,0,0,0.2)'
      .catch(function(){ showFeedbackError('Network error. Please try again.'); });
            ].join(';');
  }
 
            var nav = document.createElement('div');
            nav.style.cssText = 'display:flex;align-items:center;gap:12px;';
            nav.innerHTML =
                '<span>🔍 <strong>' + escHtml(query) + '</strong> — ' + count + ' match' + (count === 1 ? '' : 'es') + '</span>' +
                '<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↑ Prev</button>' +
                '<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↓ Next</button>' +
                '<button id="gr-hl-results" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">← Results</button>';
            bar.appendChild(nav);


            var db = document.createElement('button');
  function showFeedbackSuccess() {
            db.id = 'gr-hl-dismiss'; db.textContent = '✕ Close';
    $fbSubmit.prop('disabled', false).text('Send');
            db.style.cssText = 'background:rgba(255,255,255,0.15);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;';
    $('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok');
            bar.appendChild(db);
    setTimeout(closeFeedbackComposer, 2500);
        }
  }
  function showFeedbackError(msg) {
    $fbSubmit.prop('disabled', false).text('Send');
    $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
  }


        document.body.appendChild(bar);
  function openNoteComposer() {
    hideActions();
    if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' });
    $ntComposer.addClass('gra-composer-visible');
    $backdrop.addClass('gra-backdrop-visible');
    setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
  }


        var hlEls = Array.from(document.querySelectorAll('.gr-search-hl'));
  function closeNoteComposer() {
        var current = 0;
    $ntComposer.removeClass('gra-composer-visible');
    $backdrop.removeClass('gra-backdrop-visible');
    $ntInput.val('');
    $ntSubmit.prop('disabled', true);
    _selRange = null; _selText = ''; _selRect = null;
  }


        function goTo(idx) {
  function submitNote() {
            hlEls.forEach(function (el) { el.classList.remove('gr-search-hl-current'); });
    var text = $ntInput.val().trim();
            current = ((idx % hlEls.length) + hlEls.length) % hlEls.length;
    if (!text) return;
            hlEls[current].classList.add('gr-search-hl-current');
    if (byteLength(text) > MAX_NOTE_BYTES) {
            grScrollTo(hlEls[current]);
      alert('This note is too long to save — please shorten it.');
            // NEW: same TOC sync on Prev/Next navigation within the
      return;
            // results, not just the initial jump.
            if (window.grHighlightTocForElement) window.grHighlightTocForElement(hlEls[current]);
        }
 
        function dismiss() { clearHighlights(); bar.remove(); }
 
        var nb = document.getElementById('gr-hl-next');
        var pb = document.getElementById('gr-hl-prev');
        var rb = document.getElementById('gr-hl-results');
        var db2 = document.getElementById('gr-hl-dismiss');
        if (nb) nb.onclick = function () { goTo(current + 1); };
        if (pb) pb.onclick = function () { goTo(current - 1); };
        if (db2) db2.onclick = dismiss;
        if (rb) rb.onclick = function () {
            bar.remove(); clearHighlights();
            if (window.showSearchDialog) window.showSearchDialog(query);
        };
     }
     }
    var id    = uid();
    var ts    = nowIso();
    var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
    var span  = wrapSelection(id, 'gra-note-highlight');
    if (span) span.setAttribute('data-gra-quote', quote);
    var rec = {id:id, ts:ts, quote:quote, text:text};
    _notes.push(rec);
    Store.cache();
    Store.save('note', rec);
    renderNoteCards(); updateBadge();
    closeNoteComposer();
    openPanel('notes');
  }


    function clearHighlights() {
  function openBookmarkComposer() {
        document.querySelectorAll('.gr-search-hl').forEach(function (span) {
    hideActions();
            var p = span.parentNode; if (!p) return;
    if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' });
            while (span.firstChild) p.insertBefore(span.firstChild, span);
    $bmComposer.addClass('gra-composer-visible');
            p.removeChild(span);
    $backdrop.addClass('gra-backdrop-visible');
        });
    setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
    }
  }


    function escHtml(s) {
  function closeBookmarkComposer() {
        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    $bmComposer.removeClass('gra-composer-visible');
     }
     $backdrop.removeClass('gra-backdrop-visible');
 
    $bmInput.val('');
    function injectHighlightCSS() {
    _selRange = null; _selText = ''; _selRect = null;
        if (document.getElementById('gr-hl-css')) return;
  }
        var s = document.createElement('style');
        s.id = 'gr-hl-css';
        s.textContent = [
            '.gr-search-hl{background:#fff176;color:#1a1a1a;border-radius:2px;padding:0 1px;box-shadow:0 0 0 1px rgba(181,69,27,0.25);}',
            '.gr-search-hl-current{background:#ffb300!important;box-shadow:0 0 0 2px #b5451b!important;}',
            '@keyframes gr-hl-pulse{0%{background:#ffb300;}50%{background:#fff176;}100%{background:#fff176;}}',
            '.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}',
        ].join('');
        document.head.appendChild(s);
    }


     injectHighlightCSS();
  function submitBookmark() {
 
     var name  = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1));
     if (document.readyState === 'loading') {
    // A bookmark's name is stored in the same column as a note's text and
        document.addEventListener('DOMContentLoaded', applyHighlight);
    // is capped the same way.
    } else {
     if (byteLength(name) > MAX_NOTE_BYTES) {
        applyHighlight();
      alert('This bookmark name is too long to save — please shorten it.');
      return;
     }
     }
    var id    = uid();
    var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
    if (!_selRange && _selText) reCaptureFromDOM();
    var span  = wrapSelection(id, 'gra-bookmark-highlight');
    if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
    var rec = {id:id, name:name, quote:quote, ts:nowIso()};
    _bookmarks.push(rec);
    Store.cache();
    Store.save('bookmark', rec);
    renderBookmarkCards(); updateBadge();
    closeBookmarkComposer();
    openPanel('bookmarks');
  }


     if (window.mw) {
  function deleteBookmark(id) {
        mw.hook('wikipage.content').add(function () {
     _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
            setTimeout(applyHighlight, 100);
    var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
        });
    if (span && span.parentNode) {
      var p = span.parentNode;
      while (span.firstChild) p.insertBefore(span.firstChild, span);
      p.removeChild(span);
     }
     }
    Store.cache(); Store.remove(id); renderBookmarkCards(); updateBadge();
  }


     window.grStoreSearchHL = storeQueryForLink;
  function openPanel(tab) {
     _activeTab = tab || _activeTab;
    switchTab(_activeTab);
    $panel.addClass('gra-panel-open');
    $backdrop.addClass('gra-backdrop-visible');
  }
  function closePanel() {
    $panel.removeClass('gra-panel-open');
    $backdrop.removeClass('gra-backdrop-visible');
  }
  // CHANGED: added the footnotes case, alongside notes/bookmarks.
  function switchTab(tab) {
    _activeTab = tab;
    $tabNotes.toggleClass('gra-tab-active', tab==='notes');
    $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
    $tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes');
    $paneNotes.toggleClass('gra-pane-active', tab==='notes');
    $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
    $paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes');
    if (tab==='notes') renderNoteCards();
    else if (tab==='bookmarks') renderBookmarkCards();
    else renderFootnoteCards();
  }


}());
  function renderNoteCards() {
 
     if (!_notes.length) {
/* ═══════════════════════════════════════════════════════════════
      $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>');
  Mobile addon — only runs on Minerva (mobile) skin
      return;
  ═══════════════════════════════════════════════════════════════ */
(function () {
     if (!document.body.classList.contains('skin-minerva')) return;
    function injectMenuLinks() {
        if (document.getElementById('gr-mob-menu-items')) return;
        var navDrawer = document.querySelector('.navigation-drawer');
        if (!navDrawer) return;
        var wrap = document.createElement('div'); wrap.id = 'gr-mob-menu-items'; wrap.className = 'gr-mob-menu';
        function makeItem(href, label) { var a = document.createElement('a'); a.href = href; a.className = 'gr-mob-item'; a.textContent = label; return a; }
        wrap.appendChild(makeItem('/Main_Page', 'Home'));
        wrap.appendChild(makeItem('/My_wiki:Help', 'Help'));
        wrap.appendChild(makeItem('/My_wiki:About', 'About'));
        var userName = window.mw ? mw.config.get('wgUserName') : null;
        if (userName) {
            wrap.appendChild(makeItem((window.mw && mw.util && mw.util.getUrl) ? mw.util.getUrl('Special:Profile') : '/Special:Profile', 'Profile'));
            var la = document.querySelector('a[href*="action=logout"]');
            wrap.appendChild(makeItem(la ? la.href : '/index.php?title=Special:UserLogout', 'Log out'));
        }
        else wrap.appendChild(makeItem('/index.php?title=Special:UserLogin', 'Log in'));
        var pageLeft = document.getElementById('mw-mf-page-left');
        if (pageLeft) {
            // Hide Minerva's own menu lists rather than deleting them: Minerva's
            // initMobile.js looks up .toggle-list__list inside this panel and
            // throws ("Cannot read properties of null (reading 'parentNode')")
            // when it is gone, which also stops the rest of Minerva's setup.
            Array.prototype.forEach.call(pageLeft.children, function (c) { c.classList.add('gr-mf-native-menu'); });
            pageLeft.style.removeProperty('display');
            pageLeft.appendChild(wrap);
        }
        else navDrawer.appendChild(wrap);
     }
     }
     function injectMoolaUllekhaLinks() {
     var html = '';
        if (document.getElementById('gr-mob-doc-nav')) return;
    _notes.slice().reverse().forEach(function(n){
        var pageName = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
      html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
        if (pageName === 'Main_Page' || !pageName) return;
            + '<div class="gra-card-header">'
        function wikiUrl(slug) { if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(slug); return ((window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1').replace('$1', encodeURIComponent(slug).replace(/%2F/g, '/')); }
            + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>'
        var teekaPage = document.querySelector('.gr-teeka-page');
            + '<div class="gra-card-meta">'
        var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || pageName.split('/')[0]) : pageName.split('/')[0];
            + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
        var docTitleEl = document.querySelector('.gr-doc-title');
            + '</div>'
        var hasMoolaPage = docTitleEl && docTitleEl.getAttribute('data-has-moola') === '1';
            + '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
        var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute('data-has-ullekha') === '1';
            + '</div>'
        var showMoolam = !!teekaPage || hasMoolaPage, showUllekha = hasUllekhaPage || !!teekaPage;
            + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
        if (!showMoolam && !showUllekha) return;
            + '<div class="gra-card-text">'+esc(n.text)+'</div>'
        var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
            + '</div>';
        nav.className = 'toc-main-links';        // styled in Common.css §24
        function makeLink(href, label) { var a = document.createElement('a'); a.href = href; a.textContent = label; a.className = 'toc-main-link-item'; return a; }
        if (teekaPage) nav.appendChild(makeLink(wikiUrl(primarySlug), 'मूल'));
        else if (hasMoolaPage) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Moola'), 'मूलम्'));
        if (showUllekha) nav.appendChild(makeLink(wikiUrl(primarySlug + '/Ullekha'), 'उल्लेख'));
        // Right under the document's own title, which is what a reader on a
        // phone actually sees as the title; the skin's h1 is the fallback.
        var docTitle = document.querySelector('#mw-content-text .gr-doc-title');
        var h1 = document.getElementById('firstHeading') || document.querySelector('.page-heading, h1.firstHeading, .mw-first-heading');
        if (docTitle && docTitle.parentNode) docTitle.parentNode.insertBefore(nav, docTitle.nextSibling);
        else if (h1 && h1.parentNode) h1.parentNode.insertBefore(nav, h1.nextSibling);
        else { var ct = document.getElementById('mw-content-text'); if (ct) ct.insertBefore(nav, ct.firstChild); }
    }
    function boot() { injectMenuLinks(); injectMoolaUllekhaLinks();}
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
    if (window.mw) mw.hook('wikipage.content');
}());
 
(function () {
    'use strict';
    if (mw.config.get('wgPageName') !== 'Main_Page') return;
    mw.loader.using('mediawiki.util').done(function () {
        $(function () { applyHomeToggleOffset(); window.addEventListener('resize', applyHomeToggleOffset, { passive: true }); setTimeout(applyHomeToggleOffset, 300); setTimeout(applyHomeToggleOffset, 800); });
     });
     });
     function applyHomeToggleOffset() {
     $paneNotes.html(html);
        var bar = document.getElementById('gr-static-bar'); if (!bar) return;
  }
        var barBottom = Math.round(bar.getBoundingClientRect().bottom);
        [document.getElementById('gr-home'), document.getElementById('gr-home-toggle'), document.querySelector('#mw-content-text .mw-parser-output > .gr-home, #mw-content-text .mw-parser-output > *:first-child')].forEach(function (el) { if (el) el.style.scrollMarginTop = (barBottom + 4) + 'px'; });
        var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
        if (isMob) { var toggleEl = document.getElementById('gr-home-toggle'); if (toggleEl) { var tr = toggleEl.getBoundingClientRect(); if (tr.top < barBottom) { var cp = parseInt(window.getComputedStyle(document.body).paddingTop, 10) || 0; document.body.style.paddingTop = (cp + (barBottom - tr.top) + 4) + 'px'; } } }
    }
    $(function () {
        var $toggle = $('#gr-home-toggle'), $viewG = $('#gr-view-grantha'), $viewA = $('#gr-view-author'), $btnG = $('#gr-toggle-grantha'), $btnA = $('#gr-toggle-author');
        if (!$toggle.length || !$viewG.length || !$viewA.length) return; if ($toggle.data('gr-wired')) return; $toggle.data('gr-wired', true);
        function showView(which) { if (which === 'grantha') { $viewG.show(); $viewA.hide(); $btnG.addClass('gr-toggle-active'); $btnA.removeClass('gr-toggle-active'); } else { $viewA.show(); $viewG.hide(); $btnA.addClass('gr-toggle-active'); $btnG.removeClass('gr-toggle-active'); } try { localStorage.setItem('grantha_home_tab', which); } catch (e) { } }
        $btnG.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('grantha'); });
        $btnA.on('click keydown', function (e) { if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; showView('author'); });
        try { var saved = localStorage.getItem('grantha_home_tab'); if (saved === 'author') showView('author'); else showView('grantha'); } catch (e) { showView('grantha'); }
    });
}());
// ── Replace MediaWiki user links with a single username dropdown (Profile + Logout) ──
(function () {
    function wikiHref(title) {
        if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
        var ap = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
        return ap.replace('$1', title);
    }
}());
/* ═══════════════════════════════════════════════════════════════
  Commentary interleaving — discovers a document's /Vyakhya or
  /Bhashya commentary subpages, lets a reader select which ones to
  show interleaved with the moola text, and augments any existing
  link to a commentary (e.g. a "वादावली" list) with its own inline
  checkbox. Exposes window.grTeekaInterleave so readerToolbar.js's
  "Commentaries" dropdown can stay in sync without duplicating this
  logic — same pattern as window.grHighlightTocForElement above.
  ═══════════════════════════════════════════════════════════════ */
(function () {
 
    var LS_TEEKA_KEY = 'grantha_reader_teeka_in_moola';


    // Distinct light background per commentary, for when multiple
  function deleteNote(id) {
    // different teekas are interleaved together and need to stay
     _notes = _notes.filter(function(n){ return n.id !== id; });
    // visually distinguishable. CHANGED: was a 30-color palette
     var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
    // transcribed from a screenshot - confirmed directly that at least
     if (span && span.parentNode) {
    // one entry (#F6C500, a saturated gold) wasn't actually light at
      var p = span.parentNode;
     // all, which is very likely what read as "really dark" in practice.
      while (span.firstChild) p.insertBefore(span.firstChild, span);
    // Replaced with 10 colors in the site's own warm palette (matching
      p.removeChild(span);
    // the maroon/cream tones already used throughout Common.css -
    // #b5451b, #fdf0eb, #f0cfc4), all kept in the ~94-97% lightness
     // range so they stay easy to read against dark text regardless of
    // which one a given teeka lands on. First entry is #fcf5f3 - the
    // exact background .teeka-block itself already uses on a native
    // teeka page (verified at 97.1% lightness), per direction, so the
    // most common single-commentary case matches the site's own
    // existing look exactly rather than introducing a new shade for it.
    var TEEKA_COLOR_PALETTE = [
        '#fcf5f3', '#F5EFE6', '#F0F5EC', '#EEF3F6', '#F6EFF3',
        '#F3F0F8', '#F8F3EA', '#EEF6F2', '#F6EEEE', '#F2F0EC'
    ];
    // Stable djb2-style hash - same teeka slug always maps to the SAME
     // color everywhere on the site (not re-randomized per page load or
    // fetch order) - verified directly: a reader learning "this shade =
    // भावप्रकाशः" needs that to hold site-wide.
    function teekaColorForSlug(slug) {
        var h = 5381;
        for (var i = 0; i < slug.length; i++) h = ((h * 33) ^ slug.charCodeAt(i)) >>> 0;
        return TEEKA_COLOR_PALETTE[h % TEEKA_COLOR_PALETTE.length];
     }
     }
    Store.cache(); Store.remove(id); renderNoteCards(); updateBadge();
  }


    function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
  function renderBookmarkCards() {
     function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
     if (!_bookmarks.length) {
 
      $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>');
    function fetchAllSubpages(rootTitle) {
      return;
        var deferred = $.Deferred(), subpages = [], nsNum = mw.config.get('wgNamespaceNumber'), localName = mw.config.get('wgTitle');
        function ask(apfrom) {
            var params = { action: 'query', list: 'allpages', apprefix: localName + '/', apnamespace: nsNum, aplimit: 'max', format: 'json' };
            if (apfrom) params.apcontinue = apfrom;
            $.ajax({ url: mw.util.wikiScript('api'), data: params, dataType: 'json' }).then(function (data) {
                var pages = (data.query && data.query.allpages) || [];
                pages.forEach(function (p) { subpages.push(p.title); });
                if (data.continue && data.continue.apcontinue) ask(data.continue.apcontinue);
                else deferred.resolve(subpages);
            }).fail(function () { deferred.resolve(subpages); });
        }
        ask(null);
        return deferred.promise();
     }
     }
    var html = '';
    _bookmarks.slice().reverse().forEach(function(b){
      html += '<div class="gra-bookmark-card" data-gra-id="'+esc(b.id)+'">'
            + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>'
            + '<div class="gra-bookmark-info">'
            + '<div class="gra-bookmark-name">'+esc(b.name)+'</div>'
            + (b.quote ? '<div class="gra-bookmark-quote">'+esc(b.quote)+'</div>' : '')
            + '</div>'
            + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>'
            + '</div>';
    });
    $paneBookmarks.html(html);
  }


    function fetchTeekaJson(title) {
  // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal
        return $.ajax({
  // localStorage annotations, they're real saved page content (QuickEdit
            url: mw.util.wikiScript('api'),
  // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved
            data: { action: 'query', titles: 'JSON:' + title, prop: 'revisions', rvslots: 'main', rvprop: 'content', format: 'json', formatversion: 2 },
  // HTML). So this reads straight off the live DOM on every open, no
            dataType: 'json'
  // fetch, no storage — verified the scan + both-direction lookup logic
        }).then(function (data) {
  // against realistic saved-footnote HTML before wiring this in (6/6 pass).
            var pages = (data.query && data.query.pages) || [];
  function renderFootnoteCards() {
            var page = pages[0];
    var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item');
            if (!page || page.missing) return null;
    if (!items.length) {
            var content = page.revisions && page.revisions[0] && page.revisions[0].slots.main.content;
      $paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>');
            if (!content) return null;
      return;
            try { var parsed = JSON.parse(content); parsed._title = title; return parsed; }
            catch (e) { return null; }
        }).catch(function () { return null; });
     }
     }
    var html = '';
    Array.prototype.forEach.call(items, function (li) {
      var numEl = li.querySelector('.gra-qe-footnote-num');
      var quoteEl = li.querySelector('.gra-qe-footnote-quote');
      var textEl = li.querySelector('.gra-qe-footnote-text');
      var num = numEl ? numEl.textContent.trim() : '';
      // CHANGED: was showing the NUMBER as the card's primary line and the
      // footnote's own note text as secondary — nowhere did it show WHICH
      // text in the document the footnote actually belongs to, which was
      // the whole point of being able to identify one footnote from
      // another. Now shows the referenced quote as primary (matching how
      // Notes/Bookmarks already identify themselves by quote, not by an
      // arbitrary id), with the number as a small prefix and the
      // footnote's own note text as secondary. Falls back to the note
      // text alone if a footnote predates this change and has no quote
      // span at all, rather than showing nothing.
      var quote = quoteEl ? quoteEl.textContent : '';
      var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent);
      var primary = quote ? (num + ' ' + quote) : (num + ' ' + text);
      var id = li.getAttribute('data-gra-id') || '';
      html += '<div class="gra-bookmark-card gra-footnote-card" data-gra-id="'+esc(id)+'">'
            + '<span class="gra-icon gra-icon-footnote" aria-hidden="true"></span>'
            + '<div class="gra-bookmark-info">'
            + '<div class="gra-bookmark-name">'+esc(primary)+'</div>'
            + (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '')
            + '</div>'
            + '</div>';
    });
    $paneFootnotes.html(html);
  }


     // Finds this document's commentary subpages - filters to the site's
  function scrollToHighlight(id) {
     // own established commentary-path convention (Vyakhya/Bhashya are
     var el = document.querySelector('[data-gra-id="'+id+'"]');
     // shown; Moola/Ullekha are never commentary content).
     if (!el) return;
    function findCommentarySubpages(rootTitle) {
     el.scrollIntoView({behavior:'smooth', block:'center'});
        return $.Deferred(function (d) {
    el.classList.add('gra-hl-active');
            fetchAllSubpages(rootTitle).then(function (all) {
    setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
                d.resolve(all.filter(function (t) {
  }
                    return t !== rootTitle && (/\/vyakhya(\/|$)/i.test(t) || /\/bhashya(\/|$)/i.test(t));
                }));
            });
        }).promise();
    }


    // Builds moola-verse-id -> [{teekaTitle, teekaSlug, text, blockId}].
  function wireEvents() {
    // Handles TWO possible parent-linking shapes so this works across
    // every commentary document on the site, not just ones from any one
    // generation pipeline: (1) DIRECT - a type:"teeka" block's own
    // "parent" already IS the moola verse id; (2) TWO-HOP - it points to
    // a type:"verse" block WITHIN THE SAME teeka doc (a quoted-verse
    // reference), and THAT block's own parent is the moola verse id.
    // Deliberately skips anything whose parent is "UNVERIFIED" or
    // missing, rather than guessing.
    // CHANGED: also builds a slug -> color-palette-index map here, based
    // on teekaDocs' OWN ORDER (the order commentaries were actually
    // discovered/fetched for this page) rather than hashing each slug
    // independently. Confirmed directly this was the actual issue: hash-
    // based assignment gives every teeka a color, but nothing guarantees
    // the FIRST one shown on a page lands on palette slot 0 (#fcf5f3,
    // the site's own real .teeka-block default) - it gets whatever slot
    // its hash happens to produce. Positional assignment makes "first
    // shown = the exact default color" a guarantee, not a coincidence,
    // at the cost of a specific teeka's color now depending on where it
    // appears in a given page's own list rather than being fixed
    // site-wide - a reasonable trade given the direction received.
    var teekaColorIndexBySlug = {};
    function buildTeekaInMoolaIndex(teekaDocs) {
        var index = {};
        teekaColorIndexBySlug = {};
        var colorIdx = 0;
        teekaDocs.forEach(function (td) {
            if (!td || !td.blocks || !td.doc) return;
            var slug = td.doc.slug || td._title;
            if (!(slug in teekaColorIndexBySlug)) {
                teekaColorIndexBySlug[slug] = colorIdx % TEEKA_COLOR_PALETTE.length;
                colorIdx++;
            }
            var blockById = {};
            td.blocks.forEach(function (b) { blockById[b.id] = b; });
            td.blocks.forEach(function (b) {
                if (b.type !== 'teeka') return;
                var moolaId = null;
                if (b.parent && b.parent !== 'UNVERIFIED' && !blockById[b.parent]) {
                    moolaId = b.parent;
                } else {
                    var verseRef = blockById[b.parent];
                    if (verseRef && (verseRef.type === 'verse' || verseRef.type === 'verse-ref') && verseRef.parent && verseRef.parent !== 'UNVERIFIED') {
                        moolaId = verseRef.parent;
                    }
                }
                if (!moolaId) return;
                if (!index[moolaId]) index[moolaId] = [];
                index[moolaId].push({
                    teekaTitle: td.doc.title || td._title,
                    teekaSlug: slug,
                    text: (b.lines || []).join(' '),
                    blockId: b.id
                });
            });
        });
        return index;
    }


     function injectTeekaInMoola(index) {
     /* Suppress native context menu inside article content (Android/desktop) */
        var injectedAny = false;
    document.addEventListener('contextmenu', function(e) {
        Object.keys(index).forEach(function (verseId) {
      var tag = e.target.tagName;
            var $verseEl = $(document.getElementById(verseId));
      if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
            if (!$verseEl.length) return;
      var c = document.querySelector(CONTENT_SEL);
            var entries = index[verseId];
      if (c && c.contains(e.target)) e.preventDefault();
            var $wrap = $('<div>').addClass('gr-injected-teeka-group');
    }, { passive: false });
            entries.forEach(function (entry) {
                // CHANGED: was teekaColorForSlug(slug) (hash-based) -
                // now looks up the position assigned in
                // buildTeekaInMoolaIndex, falling back to the hash only
                // if this slug is somehow missing from that map (should
                // not normally happen, but safer than an undefined color).
                var colorIdx = teekaColorIndexBySlug[entry.teekaSlug];
                var color = (colorIdx !== undefined) ? TEEKA_COLOR_PALETTE[colorIdx] : teekaColorForSlug(entry.teekaSlug);
                var $entry = $('<div>').addClass('teeka-block')
                    .attr('data-teeka-slug', entry.teekaSlug)
                    .attr('data-teeka-block-id', entry.blockId)
                    .css('background', color);
                $('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry);
                $('<div>').addClass('teeka-body').html(entry.text).appendTo($entry);
                $wrap.append($entry);
            });
            $verseEl.after($wrap);
            // Tell the script switcher about the new text, so an interleaved
            // commentary is transliterated like the rest of the page instead
            // of staying in Devanagari.
            try {
                window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: $wrap[0] } }));
            } catch (e) { }
            injectedAny = true;
        });
        return injectedAny;
    }
    function removeTeekaInMoola() { $('.gr-injected-teeka-group').remove(); }


     // CRITICAL FIX: confirmed directly this feature was corrupting saved
     /* Desktop mouseup */
     // page content. QuickEdit is a contenteditable-based editor operating
     $(document).on('mouseup', function(e){
    // on the page's LIVE DOM - since this feature injects real elements
      if (e.button !== 0) return;
    // (interleaved teeka blocks, inline checkboxes wrapping links) into
      if (_mobile) return;
    // that same DOM, QuickEdit's save/serialization pipeline had no way
      setTimeout(tryShowActions, 20);
    // to distinguish them from genuine content, and was saving them back
    });
    // into the page's actual wikitext as literal escaped HTML text. This
    // teardown strips every trace of this feature's DOM changes -
    // removing injected teeka blocks entirely, and for inline checkboxes,
    // specifically UNWRAPPING each link back to its own original
    // position (not just deleting the wrapper, which would also delete
    // the link itself) - restoring the DOM to exactly what the page's
    // real, saved content actually is, before any editor can see it.
    function teardownTeekaInterleave() {
        removeTeekaInMoola();
        $('.gr-teeka-cb-wrap').each(function () {
            var $wrap = $(this);
            var $link = $wrap.find('a');
            if ($link.length) $wrap.before($link);
            $wrap.remove();
        });
        // CHANGED: also strips the gr-teeka-list-item class added to any
        // <li> for bullet-removal - confirmed via direct test this was
        // missed initially, which would have left a class attribute
        // change in the saved content even after the wrapper/checkbox
        // itself was correctly removed. Also removes the class
        // ATTRIBUTE entirely (not just the class name) once empty -
        // classList.remove() alone leaves class="" behind, which a
        // byte-for-byte test caught as still not matching the true
        // original markup (which never had a class attribute at all).
        $('.gr-teeka-list-item').each(function () {
            this.classList.remove('gr-teeka-list-item');
            if (!this.className) this.removeAttribute('class');
        });
    }


     // Watches for the exact DOM signal that editing has actually begun -
     /* Separate timers so mobile + desktop never clobber each other */
     // Common.css targets .mw-body-content[contenteditable="true"]
     var _selTimer    = null;  /* desktop debounce */
    // directly, confirming this is the real, load-order-independent
     var _mobShowTimer = null; /* mobile show-on-touchend */
    // trigger QuickEdit itself relies on, rather than trying to
    // intercept every possible UI path that could invoke it (edit
    // button, keyboard shortcut, or anything else) individually and
    // risk missing one.
     function watchForEditMode() {
        var target = document.querySelector('.mw-body-content') || document.body;
        if (!target || !window.MutationObserver) return;
        var wasEditing = false;
        var obs = new MutationObserver(function (mutations) {
            mutations.forEach(function (m) {
                if (m.attributeName !== 'contenteditable') return;
                var isEditingNow = m.target.getAttribute('contenteditable') === 'true';
                if (isEditingNow && !wasEditing) {
                    teardownTeekaInterleave();
                    wasEditing = true;
                } else if (!isEditingNow && wasEditing) {
                    // Editing ended without a page reload (e.g. cancelled) -
                    // restore using the already-fetched data cached in
                    // memory, no need to re-fetch anything from the server.
                    if (teekaIndex) renderInterleave();
                    if (lastAugmentArgs) {
                        var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
                        if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
                    }
                    wasEditing = false;
                }
            });
        });
        obs.observe(target, { attributes: true, attributeFilter: ['contenteditable'], subtree: true });
    }


     function normalizeWikiPath(p) {
     /* Mobile: show fab quickly after finger lifts (selection settled).
        try { p = decodeURIComponent(p); } catch (e) { }
      180ms feels instant while still letting the range stabilise. */
         return p.replace(/_/g, ' ').replace(/\/+$/, '');
    document.addEventListener('touchend', function(e) {
     }
      if (!_mobile) return;
      if ($fab[0] && $fab[0].contains(e.target)) return;
      clearTimeout(_mobShowTimer);
      _mobShowTimer = setTimeout(function() {
         var sel = window.getSelection();
        if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
        tryShowActions();
      }, 180);
     }, { passive: true });


     // ── State ──────────────────────────────────────────────────────
     /* Mobile: only HIDE the fab when selection is cleared while it's visible.
     var selectedTeekaSlugs = {};
      (Reposition isn't needed now that the bar is docked, and re-running
    try { JSON.parse(readLS(LS_TEEKA_KEY) || '[]').forEach(function (s) { selectedTeekaSlugs[s] = true; }); } catch (e) { }
      showFab here was causing the lag/flicker.) */
    var teekaIndex = null;
     document.addEventListener('selectionchange', function() {
    var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
      if (!_mobile) return;
    var lastAugmentArgs = null; // {titles, docs} - cached to restore inline checkboxes after a cancelled edit without re-fetching
      if (!$fab.hasClass('gra-fab-visible')) return;
    var checkboxesBySlug = {};
      var sel = window.getSelection();
    var readyCallbacks = [];
      if (!sel || sel.isCollapsed || !sel.toString().trim()) {
    var changeCallbacks = [];
        clearTimeout(_mobShowTimer);
     var visibilityCallbacks = [];
        hideActions();
    var isReady = false;
      }
    var inlineListVisible = null; // null = no inline checkboxes exist at all on this page
     });


     function registerCheckbox(slug, $cb) { (checkboxesBySlug[slug] = checkboxesBySlug[slug] || []).push($cb); }
     /* selectionchange debounced (desktop only) */
    document.addEventListener('selectionchange', function() {
      if (_mobile) return;
      _selVersion++;
      clearTimeout(_selTimer);
      var v = _selVersion;
      _selTimer = setTimeout(function(){
        if (v !== _selVersion) return;
        if (_fabSelVer === v) return;
        tryShowActions();
      }, 600);
    });


     function renderInterleave() {
     /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */
        removeTeekaInMoola();
    $fab[0].addEventListener('touchstart', function(e) {
        if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return;
      _fabTouched = true;
        var filtered = {};
      /* Don't propagate to document handler */
        Object.keys(teekaIndex).forEach(function (verseId) {
      e.stopPropagation();
            var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; });
    }, { passive: true });
            if (entries.length) filtered[verseId] = entries;
        });
        var injectedAny = injectTeekaInMoola(filtered);
        if (!injectedAny) console.warn('[grTeekaInterleave] Selected commentaries loaded, but none of their verse ids matched any verse-block on THIS page.');
    }


     function toggleSlug(slug, checked) {
     /* Click outside → hide actions (blocked if fab was touched) */
        if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug];
    $(document).on('mousedown touchstart', function(e){
        writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs)));
      if (_fabTouched) { _fabTouched = false; return; }
        (checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); });
      var t = e.target;
        renderInterleave();
      if ($fab[0]       && $fab[0].contains(t))       return;
        changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); });
      if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
    }
      if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
 
      if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
    // Augments existing <a> links pointing to a known commentary subpage
      hideActions();
    // with an inline checkbox, wherever the link already appears -
    });
    // matched by real href rather than any assumed list markup, since
    // that markup isn't something this script controls.
    // CHANGED: wraps the checkbox AND the link together in one flex
    // container, rather than just wrapping the checkbox alone next to
    // an untouched sibling link - confirmed directly (screenshot, twice
    // now) that aligning a wrapped checkbox against ambient text-
    // baseline metrics alone still leaves a small vertical offset,
    // especially with the site's custom Devanagari font's own metrics.
    // Flexbox align-items:center between the checkbox and the link's
    // own line box is a more reliable way to center them against EACH
    // OTHER directly, rather than each independently against the
    // surrounding line. The link is moved (not cloned) into the
    // wrapper at its original DOM position, so its href/click behavior
    // and the surrounding "\u2014 author" text's sibling order are both
    // preserved exactly as before.
    function augmentExistingTeekaLinks(subpageTitles, docs) {
        var slugByPath = {};
        subpageTitles.forEach(function (title, i) {
            var doc = docs[i];
            if (!doc) return;
            slugByPath[normalizeWikiPath(mw.util.getUrl(title))] = doc.doc.slug || doc._title;
        });
        var $contentLinks = $('#mw-content-text a[href], .mw-parser-output a[href]');
        var augmented = [];
        $contentLinks.each(function () {
            var href = this.getAttribute('href') || '';
            var path;
            try { path = normalizeWikiPath(new URL(href, window.location.href).pathname); }
            catch (e) { return; }
            var slug = slugByPath[path];
            if (!slug || this.closest('.gr-injected-teeka-group')) return;
            var li = this.closest('li');
            if (li) li.classList.add('gr-teeka-list-item');
            var $anchor = $(this);
            var $wrap = $('<span>').addClass('gr-teeka-cb-wrap');
            var $cb = $('<input>').attr({ type: 'checkbox' }).addClass('gr-teeka-inline-checkbox').prop('checked', !!selectedTeekaSlugs[slug]);
            $anchor.before($wrap);
            $wrap.append($cb);
            $wrap.append($anchor);
            registerCheckbox(slug, $cb);
            $cb.on('change', function () { toggleSlug(slug, $cb.is(':checked')); });
            augmented.push({ slug: slug, el: $cb[0] });
        });
        var missedTitles = subpageTitles.filter(function (t, i) {
            var d = docs[i]; if (!d) return false;
            var s = d.doc.slug || d._title;
            return !augmented.some(function (a) { return a.slug === s; });
        });
        if (missedTitles.length) console.warn('[grTeekaInterleave] No matching link found on this page for: ' + missedTitles.join(', ') + ' - the toolbar dropdown still covers them.');
        return augmented;
    }


     // Tracks whether ANY inline checkbox is currently on-screen, and
     /* ── FAB buttons — use touchend for mobile, click for desktop ── */
    // notifies subscribers only when that overall state actually flips -
     function fabAction(btnId, action) {
     // verified in isolation before wiring in here. Drives whether
      var el = document.getElementById(btnId);
    // readerToolbar.js's "Commentaries" control needs to be shown as a
      if (!el) return;
    // fallback (inline list scrolled out of view) or can stay hidden
      /* touchend: fires before document touchstart clears _selRange */
    // (reader can already see and use the inline checkboxes).
      el.addEventListener('touchend', function(e) {
    function observeInlineVisibility(checkboxEls) {
        e.preventDefault();
        if (!checkboxEls.length || !window.IntersectionObserver) return;
        e.stopPropagation();
        var visibleSet = new Set();
        if (!_selRange && !reCaptureFromDOM()) return;
        var obs = new IntersectionObserver(function (entries) {
        action();
            entries.forEach(function (entry) {
      }, { passive: false });
                var id = entry.target.__grTeekaId;
      /* click: for desktop */
                if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id);
      el.addEventListener('click', function(e) {
            });
        e.preventDefault();
            var anyVisible = visibleSet.size > 0;
         e.stopPropagation();
            if (anyVisible !== inlineListVisible) {
         if (!_selRange && !reCaptureFromDOM()) return;
                inlineListVisible = anyVisible;
        action();
                visibilityCallbacks.forEach(function (cb) { cb(anyVisible); });
      });
            }
         }, { rootMargin: '-60px 0px 0px 0px', threshold: 0 });
         checkboxEls.forEach(function (el, i) { el.__grTeekaId = 'cb' + i; obs.observe(el); });
     }
     }


     function init() {
     fabAction('gra-fab-note',     openNoteComposer);
        // Runs unconditionally, before any of the early-return checks
    fabAction('gra-fab-bookmark', openBookmarkComposer);
        // below - harmless as a no-op teardown on pages where nothing
    fabAction('gra-fab-feedback', openFeedbackComposer);
        // ever gets injected, but critical to have active on every page
        // this feature COULD have injected into, regardless of which
        // exact code path led here.
        watchForEditMode();
        var ns = mw.config.get('wgNamespaceNumber');
        var pageName = mw.config.get('wgPageName') || '';
        var isMainPg = (pageName === 'Main_Page' || pageName === 'Main Page');
        var isTeekaPage = !!document.querySelector('.gr-teeka-page[data-interleaved="1"], .gr-parent-ref-block');
        if (ns !== 0 || isMainPg || isTeekaPage) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }


        var rootTitle = mw.config.get('wgTitle');
    document.getElementById('gra-fab-search').addEventListener('touchend', function(e) {
        findCommentarySubpages(rootTitle).then(function (subpages) {
      e.preventDefault(); e.stopPropagation();
            if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
      var q = _selText;
            $.when.apply($, subpages.map(fetchTeekaJson)).then(function () {
      hideActions();
                var fetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
      _selRange = null; _selText = ''; _selRect = null;
                // Pair title+doc together FIRST, then filter as one unit -
      if (q && window.showSearchDialog) { window.showSearchDialog(q); }
                // filtering `docs` separately from `subpages` and passing
    }, { passive: false });
                // both (now misaligned) arrays elsewhere was a confirmed
     document.getElementById('gra-fab-search').addEventListener('click', function(e) {
                // real bug in an earlier version of this feature.
      e.preventDefault(); e.stopPropagation();
                var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; })
      var q = _selText;
                    .filter(function (p) {
      hideActions();
                        if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title);
      _selRange = null; _selText = ''; _selRect = null;
                        return !!p.doc;
      if (q && window.showSearchDialog) { window.showSearchDialog(q); }
                    });
      else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); }
                var topDocs = pairs.map(function (p) { return p.doc; });
     });
 
                // NEW: sub-commentaries (tippanis) - a teeka doc declares
                // its own children via doc.related (an array of slugs,
                // matching the same field this session's own generator
                // already writes into new JSON docs). Only the ASK here
                // is to show these as sub-categories in the options list
                // and gate their visibility on the parent being checked
                // - so this only needs one more level of fetching, not
                // unbounded recursion.
                var relatedSlugs = [];
                topDocs.forEach(function (d) { (d.doc.related || []).forEach(function (s) { relatedSlugs.push(s); }); });
                var uniqueRelated = relatedSlugs.filter(function (s, i) { return relatedSlugs.indexOf(s) === i; });
 
                function finishInit(subDocsBySlug) {
                    var allDocs = topDocs.slice();
                    Object.keys(subDocsBySlug).forEach(function (slug) { if (subDocsBySlug[slug]) allDocs.push(subDocsBySlug[slug]); });
                    teekaIndex = buildTeekaInMoolaIndex(allDocs);
                    availableList = topDocs.map(function (d) {
                        var subItems = (d.doc.related || [])
                            .map(function (s) { return subDocsBySlug[s]; })
                            .filter(Boolean)
                            .map(function (sd) { return { slug: sd.doc.slug || sd._title, title: sd.doc.title || sd._title }; });
                        return { slug: d.doc.slug || d._title, title: d.doc.title || d._title, subItems: subItems };
                    });
                    // Cached at module level so watchForEditMode can
                    // correctly re-run link augmentation after a
                    // cancelled edit, without needing to re-fetch
                    // anything from the server.
                    lastAugmentArgs = { titles: pairs.map(function (p) { return p.title; }), docs: topDocs };
                    var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
                    if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
                    else inlineListVisible = false; // no inline checkboxes exist - toolbar control should always show as the only access point
                    if (Object.keys(selectedTeekaSlugs).length) renderInterleave();
                    isReady = true;
                    readyCallbacks.forEach(function (cb) { cb(availableList); });
                    if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); });
                }
 
                if (!uniqueRelated.length) { finishInit({}); return; }
                $.when.apply($, uniqueRelated.map(fetchTeekaJson)).then(function () {
                    var subFetched = arguments.length === 1 ? [arguments[0]] : Array.prototype.slice.call(arguments);
                    var subDocsBySlug = {};
                    uniqueRelated.forEach(function (slug, i) {
                        if (!subFetched[i]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
                        subDocsBySlug[slug] = subFetched[i];
                    });
                    finishInit(subDocsBySlug);
                });
            });
        });
    }
 
    window.grTeekaInterleave = {
        teardown: teardownTeekaInterleave,
        onReady: function (cb) { if (isReady) cb(availableList); else readyCallbacks.push(cb); },
        onChange: function (cb) { changeCallbacks.push(cb); },
        onVisibilityChange: function (cb) { visibilityCallbacks.push(cb); if (inlineListVisible !== null) cb(inlineListVisible); },
        getSelected: function () { return Object.keys(selectedTeekaSlugs); },
        toggle: function (slug, checked) { toggleSlug(slug, checked); },
        registerCheckbox: registerCheckbox
    };
 
     if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
 
}());
 
 
/* ═══════════════════════════════════════════════════════════════
  Devanagari page titles, site-wide  (window.grTitles)
  ───────────────────────────────────────────────────────────────
  One source of truth for "what is this page called": the Main Page
  catalogue, where every published work and commentary is listed as
  [[Slug|देवनागरी नाम]]. update_main_page.py keeps that list current,
  so a newly pushed commentary gets its Devanagari name everywhere
  without anyone editing a hard-coded list.
 
  Used by:
    • readerToolbar.js search results (grDocDisplayTitle & co. ask
      grTitles first, then fall back to their own GR_DOC_TITLES map)
    • the Updates page
    • this block itself, which relabels any link on the page whose
      text is still the English slug (e.g. the "Vadavali · Bhavavivaranam"
      page-nav line), the mobile page heading, and the browser tab title.
 
  The catalogue is cached in localStorage for CACHE_HOURS and refreshed
  in the background, so lookups are synchronous on almost every view.
  ═══════════════════════════════════════════════════════════════ */
(function () {
    'use strict';
 
    var CATALOGUE_PAGE = 'Main_Page';
    var CACHE_KEY = 'gr_titles_v1';
    var CACHE_HOURS = 1;
    var DEVA = /[ऀ-ॿ]/;
 
    var full = {};     // "Katha/Vyakhya/VyasatirthaVivarana" → "श्रीव्यासतीर्थविरचितविवरणम्"
    var leaf = {};    // "VyasatirthaVivarana" → same; null when two pages share a leaf
    var waiting = [];
    var loaded = false;
 
    function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
    function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
    function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
     function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
    function script(name) {
        if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
        return (cfg('wgScriptPath') || '') + '/' + name + '.php';
    }


     function add(title, label, override) {
     /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */
        title = norm(title); label = String(label || '').replace(/<[^>]*>/g, '').trim();
    (function () {
        if (!title || !label || !DEVA.test(label)) return;
      var dismissEl = document.getElementById('gra-fab-dismiss');
        if (override || !full[title]) full[title] = label;
      if (!dismissEl) return;
         var l = title.split('/').pop();
      function doDismiss(e) {
         if (!(l in leaf) || override) leaf[l] = label;
         e.preventDefault(); e.stopPropagation();
         else if (leaf[l] !== label) leaf[l] = null;   // ambiguous leaf: don't guess
         hideActions();
    }
         _selRange = null; _selText = ''; _selRect = null;
    function parseCatalogue(txt) {
         if (window.getSelection) {
        var out = {}, re = /\[\[([^\]|#]+)\|([^\]]+)\]\]/g, m;
          var s = window.getSelection();
         while ((m = re.exec(txt || ''))) {
          if (s && s.removeAllRanges) s.removeAllRanges();
            var k = norm(m[1]);
            if (!out[k]) out[k] = m[2].replace(/<[^>]*>/g, '').trim();
         }
         }
        return out;
      }
    }
      dismissEl.addEventListener('touchend', doDismiss, { passive: false });
    function useCatalogue(map) {
      dismissEl.addEventListener('click', doDismiss);
        full = {}; leaf = {};
    }());
        Object.keys(map).forEach(function (k) { add(k, map[k]); });
        addCurrentPage();
    }
    // The page being viewed names itself in .gr-doc-title — covers a page
    // that isn't on the Main Page yet.
    function addCurrentPage() {
        var el = document.querySelector('#mw-content-text .gr-doc-title');
        var me = norm(cfg('wgPageName'));
        if (el && me && !full[me]) add(me, el.textContent);
    }


     // ── public API ────────────────────────────────────────────────
     /* Feedback composer */
     function get(title) { return full[norm(title)] || null; }
     $fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); });
    function getLeaf(seg) { var v = leaf[norm(seg)]; return v || null; }
     $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer);
     function whenReady(cb) { if (loaded) cb(); else waiting.push(cb); }
    $fbSubmit.on('click', submitFeedback);
     function all() { var o = {}; for (var k in full) o[k] = full[k]; return o; }
     $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); });
    window.grTitles = { get: get, leaf: getLeaf, all: all, whenReady: whenReady, parse: parseCatalogue };


     function finish() {
     /* Note composer */
        loaded = true;
    $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); });
        var cbs = waiting; waiting = [];
    $('#gra-nt-cancel').on('click', closeNoteComposer);
        cbs.forEach(function (cb) { try { cb(); } catch (e) { } });
    $ntSubmit.on('click', submitNote);
        relabel();
    $ntInput.on('keydown', function(e){
        if (window.mw && mw.hook) mw.hook('gr.titles').fire(window.grTitles);
      if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote();
     }
      if (e.key==='Escape') closeNoteComposer();
     });


     // ── relabel what's on screen ──────────────────────────────────
     /* Bookmark composer */
     function articleTitleFromHref(href) {
     $('#gra-bm-cancel').on('click', closeBookmarkComposer);
        var u;
    $bmSubmit.on('click', submitBookmark);
        try { u = new URL(href, location.href); } catch (e) { return null; }
     $bmInput.on('keydown', function(e){
        if (u.origin !== location.origin) return null;
      if (e.key==='Enter') submitBookmark();
        var t = u.searchParams.get('title');
      if (e.key==='Escape') closeBookmarkComposer();
        if (!t) {
    });
            var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0];
            if (u.pathname.indexOf(ap) !== 0) return null;
            t = u.pathname.slice(ap.length);
        }
        try { t = decodeURIComponent(t); } catch (e) { }
        return norm(t);
     }
    function relabel(root) {
        root = root || document;
        var links = root.querySelectorAll('#mw-content-text a[href], .gr-sticky-title a[href], #mw-panel a[href], .vector-toc a[href]');
        Array.prototype.forEach.call(links, function (a) {
            if (a.children.length) return;                        // leave rich links alone
            var text = norm(a.textContent);
            if (!text || DEVA.test(text)) return;                  // already Devanagari
            var target = articleTitleFromHref(a.getAttribute('href')) || norm(a.getAttribute('title'));
            if (!target) return;
            var tail = target.split('/').pop();
            if (text !== target && text !== tail) return;          // only replace a bare slug
            var label = get(target) || (text === tail ? getLeaf(tail) : null);
            if (label) a.textContent = label;
        });
        // Page heading (shown on mobile) and the browser tab
        var me = norm(cfg('wgPageName'));
        var myLabel = get(me);
        if (!myLabel) return;
        var h = document.querySelector('#firstHeading .mw-page-title-main') || document.getElementById('firstHeading');
        if (h && !h.children.length && norm(h.textContent) === me) h.textContent = myLabel;
        var wgTitle = norm(cfg('wgTitle'));
        if (wgTitle && document.title.indexOf(wgTitle) === 0) document.title = myLabel + document.title.slice(wgTitle.length);
        else if (document.title.indexOf(me) === 0) document.title = myLabel + document.title.slice(me.length);
    }


     // ── load ─────────────────────────────────────────────────────
     /* Panel */
     function fetchFresh() {
     $('#gra-panel-close').on('click', closePanel);
        return fetch(script('index') + '?title=' + CATALOGUE_PAGE + '&action=raw', { credentials: 'same-origin' })
     $backdrop.on('click touchend', function(e){
            .then(function (r) { return r.ok ? r.text() : Promise.reject(); })
      e.preventDefault();
            .then(function (txt) {
      if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
                var map = parseCatalogue(txt);
      else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
                lsSet(CACHE_KEY, JSON.stringify({ at: Date.now(), map: map }));
      else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
                return map;
      else closePanel();
            });
     }
    function boot() {
        var cached = null;
        try { cached = JSON.parse(lsGet(CACHE_KEY) || 'null'); } catch (e) { }
        if (cached && cached.map) { useCatalogue(cached.map); finish(); }
        var stale = !cached || (Date.now() - cached.at > CACHE_HOURS * 3600000);
        if (!stale) return;
        fetchFresh().then(function (map) {
            useCatalogue(map);
            if (!loaded) finish(); else relabel();
        }).catch(function () { if (!loaded) { addCurrentPage(); finish(); } });
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
    // Content that arrives later (previews, lazy sections, search panes)
    if (window.mw && mw.hook) mw.hook('wikipage.content').add(function ($c) {
        if (loaded) { addCurrentPage(); relabel($c && $c[0] ? $c[0] : document); }
     });
     });
}());
    $tabNotes.on('click', function(){ switchTab('notes'); });
    $tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
    // NEW: third tab.
    $tabFootnotes.on('click', function(){ switchTab('footnotes'); });


    $paneNotes.on('click', '.gra-note-card', function(e){
      if ($(e.target).hasClass('gra-note-del')) return;
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });
    $paneNotes.on('click', '.gra-note-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteNote(id);
    });
    $paneBookmarks.on('click', '.gra-bookmark-card', function(e){
      if ($(e.target).hasClass('gra-bookmark-del')) return;
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });
    $paneBookmarks.on('click', '.gra-bookmark-del', function(e){
      e.stopPropagation();
      var id = $(this).attr('data-del-id');
      if (id) deleteBookmark(id);
    });
    // CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's
    // redesign — plain highlight span, no href/marker element anymore), so
    // this can reuse the EXACT same scrollToHighlight(id) helper Notes/
    // Bookmarks already use below, instead of custom href-parsing logic
    // that broke every time the marker format changed.
    $paneFootnotes.on('click', '.gra-footnote-card', function(){
      var id = $(this).attr('data-gra-id');
      if (id) { closePanel(); scrollToHighlight(id); }
    });


/* ═══════════════════════════════════════════════════════════════
    $(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
  Header links "Library" and "Updates", and the Updates page
      var id = $(this).attr('data-gra-id');
  ───────────────────────────────────────────────────────────────
      openPanel('notes');
  Desktop (Vector): "Library" and "Updates" added before "Help".
      setTimeout(function(){
  Mobile (Minerva): the same two items in the menu panel, after "Home".
         var $card = $paneNotes.find('[data-gra-id="'+id+'"]');
  "Updates" opens the page My_wiki:Updates, which has two parts:
         if ($card.length) {
 
          $card.addClass('gra-card-active');
  1. What's new — filled in automatically from the wiki's edit history
          $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
      into <div id="gr-updates-feed">. Nothing to maintain by hand:
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
        • New      a page created in the last WINDOW_DAYS days
        • Updated  an existing page whose edits in that window add up to
                  at least UPDATE_MIN_BYTES (so typo fixes don't show)
      Titles are the Devanagari names from window.grTitles (Main Page catalogue).
      Test pages, helper subpages (/Ullekha, /Moola, /C1 …) and deleted
      pages are left out.
  2. Announcements & events — a card editors fill by hand with
      {{Bulletin|type=…|date=…|title=…|text=…}} lines on that page.
 
  Styling: MediaWiki:Common.css §37 (no inline styles here).
  ═══════════════════════════════════════════════════════════════ */
(function () {
    'use strict';
 
    var LIBRARY_URL = 'https://dvaita-grantha-hierarchy-library.ai.studio/';
    var LIBRARY_LABEL = 'Library';
    var UPDATES_LABEL = 'Updates';
    var UPDATES_PAGE = 'My_wiki:Updates';  // the page the "Updates" link opens
    var WINDOW_DAYS = 90;          // how far back the list looks
    var UPDATE_MIN_BYTES = 1500;  // edits smaller than this in total aren't "updates"
    var MAX_ITEMS = 100;          // rows shown on the page
    var CACHE_MINUTES = 10;        // per-tab cache so every page view isn't an API call
    var CATALOGUE_PAGE = 'Main_Page';
    var SKIP_TITLE = /^(Main Page|Test|Sandbox)|(^|\/)(Ullekha|Moola|C\d+)$|(^|\/)(Test|Teeka|Tippani)\d*$/i;
    var HIDE = [];                // exact titles never to list, e.g. ['Sumadhwavijaya/Vyakhya/पदार्थदीपिका']
 
    var SEEN_KEY = 'gr_updates_seen';
    var CACHE_KEY = 'gr_updates_cache_v3';
    var isMobile = document.body.classList.contains('skin-minerva');
 
    // ── helpers ────────────────────────────────────────────────
    function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
    function wikiHref(title) {
        if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
        var ap = cfg('wgArticlePath') || '/wiki/$1';
        return ap.replace('$1', encodeURIComponent(title.replace(/ /g, '_')).replace(/%2F/g, '/').replace(/%3A/g, ':'));
    }
    function script(name) {
        if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
        return (cfg('wgScriptPath') || '') + '/' + name + '.php';
    }
    function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
    function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
    function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
    function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
    function el(tag, cls, text) {
         var n = document.createElement(tag);
        if (cls) n.className = cls;
        if (text != null) n.textContent = text;
        return n;
    }
    function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
 
    var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
    function absDate(d) { return MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); }
    // Today · 1 day ago · 5 days ago (under a week) · Sep 16, 2026
    function relDate(d, now) {
        now = now || new Date();
        var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
        var b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
        var days = Math.round((b - a) / 86400000);
        if (days <= 0) return 'Today';
        if (days === 1) return '1 day ago';
        if (days < 7) return days + ' days ago';
        return absDate(d);
    }
 
    // ── data ───────────────────────────────────────────────────
    function getJSON(url) {
         return fetch(url, { credentials: 'same-origin' }).then(function (r) {
            if (!r.ok) throw new Error('HTTP ' + r.status);
            return r.json();
        });
    }
    function api(params) {
        var q = ['format=json', 'formatversion=2'];
        for (var k in params) q.push(k + '=' + encodeURIComponent(params[k]));
        return getJSON(script('api') + '?' + q.join('&'));
    }
 
    // Devanagari names come from window.grTitles (the Main Page catalogue,
    // cached) — the same names search and links use.
    function fetchCatalogue() {
        return new Promise(function (resolve) {
            if (window.grTitles) window.grTitles.whenReady(function () { resolve(window.grTitles.all()); });
            else resolve({});
        });
    }
 
    // Every main-namespace change in the window, oldest→newest pages grouped.
    function fetchChanges() {
        var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z');
        var all = [];
        function page(cont) {
            var p = {
                action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0,
                rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: 500, rcend: since
            };
            if (cont) p.rccontinue = cont;
            return api(p).then(function (j) {
                all = all.concat((j.query && j.query.recentchanges) || []);
                // Two pages (1,000 changes) is plenty for a notifications list.
                if (j['continue'] && j['continue'].rccontinue && all.length < 1000) return page(j['continue'].rccontinue);
                return all;
            });
         }
         }
        return page(null);
      }, 100);
     }
     });
 
    $(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
    function summarise(changes, catalogue) {
      var id = $(this).attr('data-gra-id');
        var byTitle = {};
      openPanel('bookmarks');
        changes.forEach(function (c) {
      setTimeout(function(){
            var t = norm(c.title);
         var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
            if (SKIP_TITLE.test(t) || HIDE.indexOf(t) !== -1) return;
         // CHANGED: was missing the gra-card-active flash entirely (only
            var e = byTitle[t] || (byTitle[t] = { title: t, created: null, last: null, bytes: 0 });
         // notes and footnotes had it) — a separate inconsistency from
            var ts = new Date(c.timestamp);
         // the CSS gap below. Now matches the other two exactly.
            if (c.type === 'new') e.created = ts;
         if ($card.length) {
            else e.bytes += Math.abs((c.newlen || 0) - (c.oldlen || 0));
          $card.addClass('gra-card-active');
            if (!e.last || ts > e.last) e.last = ts;
          $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
        });
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
        var out = [];
        Object.keys(byTitle).forEach(function (t) {
            var e = byTitle[t];
            var listed = !!catalogue[t];
            // Pages the catalogue doesn't know about still count if they look like
            // real content: a top-level work or a commentary under /Vyakhya/.
            if (!listed && t.indexOf('/') !== -1 && !/\/Vyakhya\/[^/]+$/.test(t)) return;
            if (e.created) out.push({ title: t, kind: 'new', when: e.created });
            else if (e.bytes >= UPDATE_MIN_BYTES) out.push({ title: t, kind: 'update', when: e.last });
        });
        out.forEach(function (x) { x.listed = !!catalogue[x.title]; x.label = labelFor(x.title, catalogue); x.sub = subFor(x.title, catalogue); });
         return out.sort(function (a, b) { return b.when - a.when; });
    }
    function labelFor(t, cat) {
        if (cat[t]) return cat[t];
        var parts = t.split('/');
         return parts[parts.length - 1];
    }
    // Second line: which work a commentary belongs to.
    function subFor(t, cat) {
         var m = /^([^/]+)\/Vyakhya\//.exec(t);
         if (!m) return '';
        return cat[m[1]] ? cat[m[1]] + ' — टीका' : 'टीका';
    }
 
    // Drop pages created in the window and deleted since.
    function dropMissing(items) {
         if (!items.length) return Promise.resolve(items);
        var batches = [];
        for (var i = 0; i < items.length; i += 50) batches.push(items.slice(i, i + 50));
        return Promise.all(batches.map(function (b) {
            return api({ action: 'query', prop: 'info', inprop: 'displaytitle', titles: b.map(function (x) { return x.title; }).join('|') })
                .then(function (j) {
                    var gone = {}, shown = {};
                    ((j.query && j.query.pages) || []).forEach(function (p) {
                        if (p.missing) gone[norm(p.title)] = true;
                        var dt = (p.displaytitle || '').replace(/<[^>]*>/g, '').trim();
                        if (dt && norm(dt) !== norm(p.title)) shown[norm(p.title)] = dt;
                    });
                    return b.filter(function (x) {
                        // A DISPLAYTITLE only helps pages the catalogue doesn't name.
                        if (shown[x.title] && !x.listed) x.label = shown[x.title];
                        return !gone[x.title];
                    });
                }).catch(function () { return b; });
        })).then(function (r) { return [].concat.apply([], r); });
    }
 
    function loadUpdates() {
        var cached = ssGet(CACHE_KEY);
        if (cached) {
            try {
                var c = JSON.parse(cached);
                if (Date.now() - c.at < CACHE_MINUTES * 60000) {
                    return Promise.resolve(c.items.map(function (x) { x.when = new Date(x.when); return x; }));
                }
            } catch (e) { }
         }
         }
        return Promise.all([fetchChanges(), fetchCatalogue()]).then(function (r) {
      }, 100);
            return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10));
     });
        }).then(function (items) {
     // CHANGED: was .gra-qe-footnote-ref a with e.preventDefault() (it used
            items = items.slice(0, MAX_ITEMS);
     // to be a real link). Now a plain highlight span, same as notes/
            ssSet(CACHE_KEY, JSON.stringify({ at: Date.now(), items: items }));
    // bookmarks — no href to prevent, id read directly via data-gra-id,
            return items;
     // and the handler is now identical in shape to the two right above it.
        });
     $(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){
     }
      var id = $(this).attr('data-gra-id');
    function lastSeen() { return +lsGet(SEEN_KEY) || 0; }
      openPanel('footnotes');
    function markSeen() { lsSet(SEEN_KEY, String(Date.now())); }
      setTimeout(function(){
 
        var $card = $paneFootnotes.find('[data-gra-id="'+id+'"]');
     // ── Updates page: the automatic list ─────────────────────────
         if ($card.length) {
    //    Filled into <div id="gr-updates-feed"> on UPDATES_PAGE.
          $card.addClass('gra-card-active');
    //    All styling lives in Common.css §37.
          $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
    var KIND = { 'new': ['New', 'gr-upd-new'], 'update': ['Updated', 'gr-upd-update'] };
          setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000);
 
    // Today · This week · This month · then one group per month ("August 2026")
    var MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
    function groupName(d, now) {
        var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
        var b = new Date(now.getFullYear(), now.getMonth(), now.getDate());
        var days = Math.round((b - a) / 86400000);
        if (days <= 0) return 'Today';
        if (days < 7) return 'This week';
        if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) return 'Earlier this month';
        return MONTHS_LONG[d.getMonth()] + ' ' + d.getFullYear();
    }
 
     function renderFeed(box, items, seenBefore) {
        while (box.firstChild) box.removeChild(box.firstChild);
        if (items === null) { box.appendChild(el('p', 'gr-upd-empty', 'Loading…')); return; }
        if (!items.length) { box.appendChild(el('p', 'gr-upd-empty', 'No new or updated pages in the last ' + WINDOW_DAYS + ' days.')); return; }
        var now = new Date(), group = null, list = null;
        items.forEach(function (x) {
            var g = groupName(x.when, now);
            if (g !== group) {
                group = g;
                // A div, not an h3: the site's heading rules would restyle an h3.
                var h = el('div', 'gr-upd-group', g);
                h.setAttribute('role', 'heading'); h.setAttribute('aria-level', '3');
                box.appendChild(h);
                list = el('div', 'gr-upd-list');
                list.setAttribute('role', 'list');
                box.appendChild(list);
            }
            var li = el('div', 'gr-upd-item' + (seenBefore && x.when.getTime() > seenBefore ? ' gr-upd-unseen' : ''));
            li.setAttribute('role', 'listitem');
            var main = el('div', 'gr-upd-main');
            var a = el('a', 'gr-upd-title', x.label);
            a.href = wikiHref(x.title);
            main.appendChild(a);
            var sub = el('div', 'gr-upd-sub');
            sub.appendChild(el('span', 'gr-upd-tag ' + KIND[x.kind][1], KIND[x.kind][0]));
            if (x.sub) sub.appendChild(el('span', 'gr-upd-work', x.sub));
            main.appendChild(sub);
            li.appendChild(main);
            var d = el('time', 'gr-upd-date', relDate(x.when, now));
            d.setAttribute('datetime', x.when.toISOString());
            d.title = absDate(x.when);
            li.appendChild(d);
            list.appendChild(li);
        });
    }
 
     // ── Updates page: the bulletin card ─────────────────────────
    //    Items are written on the page with {{Bulletin|…}}; this only
    //    formats their dates, marks past events, and shows an empty note.
     function decorateBulletin() {
        var card = document.querySelector('.gr-bulletin');
        if (!card) return;
        var now = new Date(), today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
        var items = card.querySelectorAll('.gr-bulletin-item');
        Array.prototype.forEach.call(items, function (it) {
            var raw = it.getAttribute('data-date') || '';
            var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
            var d = m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
            var slot = it.querySelector('.gr-bulletin-date');
            if (d && slot) { slot.textContent = absDate(d); slot.setAttribute('title', raw); }
            // Past events stay listed, dimmed; hide them with until= on the item.
            if (d && it.getAttribute('data-type') === 'event' && d < today) it.classList.add('gr-bulletin-past');
            var until = /^(\d{4})-(\d{2})-(\d{2})$/.exec((it.getAttribute('data-until') || '').trim());
            if (until && new Date(+until[1], +until[2] - 1, +until[3]) < today) it.classList.add('gr-bulletin-expired');
         });
        var visible = card.querySelectorAll('.gr-bulletin-item:not(.gr-bulletin-expired)').length;
        card.classList.toggle('gr-bulletin-is-empty', visible === 0);
        var layout = card.closest('.gr-updates-layout');
        if (layout) layout.classList.toggle('gr-no-bulletin', visible === 0);
    }
 
    function initUpdatesPage() {
        var box = document.getElementById('gr-updates-feed');
        decorateBulletin();
        if (!box || box.getAttribute('data-ready')) return;
        box.setAttribute('data-ready', '1');
        var seenBefore = lastSeen();
        renderFeed(box, null);
        loadUpdates().then(function (items) {
            renderFeed(box, items, seenBefore);
            markSeen();
        }).catch(function () { renderFeed(box, []); });
    }
 
    // ── header links ─────────────────────────────────────────────
    function updatesHref() { return wikiHref(UPDATES_PAGE); }
 
    // Which wiki page a link points at ("My wiki:Help"), or null.
    function linkTarget(a) {
        var href = a.getAttribute('href') || '';
        if (!href || href.charAt(0) === '#') return null;  // e.g. the Log out button
        var u;
        try { u = new URL(href, location.href); } catch (e) { return null; }
        if (u.origin !== location.origin) return null;
        var t = u.searchParams.get('title');
        if (!t) {
            var ap = (cfg('wgArticlePath') || '/wiki/$1').split('$1')[0];
            if (u.pathname.indexOf(ap) !== 0) return null;
            t = u.pathname.slice(ap.length);
         }
         }
        try { t = decodeURIComponent(t); } catch (e) { }
      }, 100);
        return norm(t);
     });
     }
    // Highlight whichever header / menu link is the page being viewed —
    // Help, About, Updates, Profile … not only the ones this block adds.
    function markCurrent() {
        var me = norm(cfg('wgPageName'));
        if (!me) return;
        var links = document.querySelectorAll('.gr-header-link, #gr-header-user-menu a, .gr-mob-item');
        Array.prototype.forEach.call(links, function (a) {
            var on = a.tagName === 'A' && linkTarget(a) === me;
            a.classList.toggle('is-current', on);
            if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
        });
    }


     function injectDesktop() {
     // NEW: cross-document links from QuickEdit's Link picker. No real
        if (document.getElementById('gr-library-link')) return true;
    // href is ever saved (confirmed directly: MediaWiki's sanitizer
        var help = document.getElementById('gr-help-link');
    // escapes <a href> into visible literal text on this wiki, even for
        var host = help ? help.parentNode :
    // a full absolute URL) — the target lives in data-gr-href instead,
            (document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header'));
    // and this handler is what actually performs the navigation.
        if (!host) return false;
    $(CONTENT_SEL).on('click', '.gr-crosslink', function(){
      var href = $(this).attr('data-gr-href');
      if (!href) return;
      var newWin = window.open(href, '_blank');
      if (newWin) newWin.opener = null; // more reliable cross-browser than relying on window.open's features string
    });


        var lib = el('a', 'gr-header-link', LIBRARY_LABEL);
    $(document).on('keydown', function(e){
        lib.id = 'gr-library-link';
      if (e.key !== 'Escape') return;
        lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
      if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
        lib.title = 'Grantha catalogue (opens in a new tab)';
      else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
      else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
      else closePanel();
    });
  }


        var upd = el('a', 'gr-header-link', UPDATES_LABEL);
  // Re-anchors note highlights from _notes, the way bookmarks have always
        upd.id = 'gr-updates-link';
  // re-anchored from _bookmarks. This used to read a third store —
        upd.href = updatesHref();
  // NT_LS_KEY + '_hl' — holding {id, quote} pairs that _notes already had,
  // and which nothing kept in step once notes could also arrive from the
  // server. The old key is left alone rather than deleted; it is simply no
  // longer consulted.
  function restoreNoteHighlights() {
    _notes.forEach(function(h){
      if (!h.quote || !h.id) return;
      if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return;
      var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
      if (!needle) return;
      var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
      if (!range) return;
      var sp = document.createElement('span');
      sp.className = 'gra-note-highlight';
      sp.setAttribute('data-gra-id', h.id);
      try { range.surroundContents(sp); } catch(e){}
    });
  }


        if (help) { host.insertBefore(lib, help); host.insertBefore(upd, help); }
  function restoreBookmarkHighlights() {
        else { host.appendChild(lib); host.appendChild(upd); }
    _bookmarks.forEach(function(b){
        markCurrent();
      if (!b.quote) return;
        return true;
      if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return;
    }
      var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
 
      if (!needle) return;
    function injectMobile() {
      var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
        if (document.getElementById('gr-mob-library')) return true;
      if (!found) return;
        var menu = document.getElementById('gr-mob-menu-items');
      var sp = document.createElement('span');
        if (!menu) return false;
      sp.className = 'gra-bookmark-highlight';
 
      sp.setAttribute('data-gra-id', b.id);
        var lib = el('a', 'gr-mob-item', LIBRARY_LABEL);
      sp.setAttribute('data-gra-name', b.name);
        lib.id = 'gr-mob-library';
      try { found.surroundContents(sp); } catch(e){}
        lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
    });
 
  }
        var upd = el('a', 'gr-mob-item', UPDATES_LABEL);
        upd.id = 'gr-mob-updates';
        upd.href = updatesHref();
 
        var home = null;
        for (var i = 0; i < menu.children.length; i++) {
            if (/^\s*Home\s*$/.test(menu.children[i].textContent)) { home = menu.children[i]; break; }
        }
        var ref = home ? home.nextSibling : menu.firstChild;
        menu.insertBefore(lib, ref);
        menu.insertBefore(upd, ref);
        markCurrent();
        return true;
    }


    // ── boot: the Help/About and mobile-menu code creates the anchors on
  function findTextInContent(root, needle) {
    //    DOMContentLoaded, so wait briefly for them rather than assume order.
    if (!root || !needle) return null;
    function boot() {
    var text = root.textContent || '';
        initUpdatesPage();
    var idx  = text.indexOf(needle);
        var inject = isMobile ? injectMobile : injectDesktop;
    if (idx < 0) return null;
        if (inject()) return;
    var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
        var tries = 0;
    var pos = 0, node, startNode, startOffset, endNode, endOffset;
        var obs = window.MutationObserver ? new MutationObserver(function () { if (inject()) obs.disconnect(); }) : null;
    while ((node = iter.nextNode())) {
        if (obs) obs.observe(document.body, { childList: true, subtree: true });
      var len = node.nodeValue.length;
        (function poll() {
      if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; }
            if (inject()) { if (obs) obs.disconnect(); return; }
      var endIdx = idx + needle.length;
            if (++tries < 40) setTimeout(poll, 250); else if (obs) obs.disconnect();
      if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; }
        }());
      pos += len;
     }
     }
     if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
     if (!startNode || !endNode) return null;
    else boot();
    try {
    // Page previews and live-preview re-render content; re-run the page part then.
      var r = document.createRange();
     if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { initUpdatesPage(); });
      r.setStart(startNode, startOffset);
      r.setEnd(endNode, endOffset);
      return r;
     } catch(e){ return null; }
  }


    window.grUpdates = { relDate: relDate, summarise: summarise, groupName: groupName };
  // REMOVED: injectAnnotationCss(), which JS-injected a defensive
}());
  // .gra-card-active/.gra-hl-active rule here as a guess, since the real
  // gr_annotations.css wasn't available at the time. Now that it is,
  // confirmed precisely: .gra-hl-active already had working CSS for
  // notes and footnotes (that JS rule was unneeded there, though it did
  // fill a real gap for bookmarks' .gra-hl-active, which had none at
  // all) — and .gra-card-active genuinely only had a rule for
  // .gra-note-card, nothing for bookmark or footnote cards, confirming
  // that diagnosis exactly. The JS version also risked overriding the
  // existing, working note-card style with !important. Fixed directly
  // in gr_annotations.css instead — see that file.


  $(function() {
    _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    window.addEventListener('resize', function(){
      _mobile = window.innerWidth < 768 || 'ontouchstart' in window;
    });
    buildDom();
    wireEvents();


/* ═══════════════════════════════════════════════════════════════
    function paint() {
  Main Page cards: item count beside each title, and a
      try { restoreNoteHighlights(); } catch(e){}
  .gr-home-card-wide class on long lists. Styling: Common.css §25.
      try { restoreBookmarkHighlights(); } catch(e){}
  ═══════════════════════════════════════════════════════════════ */
      if ($panel.hasClass('gra-panel-open')) switchTab(_activeTab);
(function () {
      updateBadge();
    'use strict';
    var WIDE_AT = 14;  // cards with this many items or more span two columns
    function decorate() {
        var cards = document.querySelectorAll('.gr-home .gr-home-card');
        Array.prototype.forEach.call(cards, function (card) {
            var title = card.querySelector('.gr-home-card-title');
            var n = card.querySelectorAll('li').length;
            if (!n) return;
            if (n >= WIDE_AT) card.classList.add('gr-home-card-wide');
            if (!title || title.querySelector('.gr-home-card-count')) return;
            var label = document.createElement('span');
            while (title.firstChild) label.appendChild(title.firstChild);
            title.appendChild(label);
            var c = document.createElement('span');
            c.className = 'gr-home-card-count';
            c.textContent = String(n);
            title.appendChild(c);
        });
     }
     }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
    else decorate();
}());


    // The cache is in place the moment load() returns, so the first paint
    // still happens on the old 500 ms timer and feels unchanged. The
    // server's answer arrives later and paints again — highlights it adds
    // are skipped if already present, so the second pass only fills in what
    // the cache did not have: a note made on the phone, or in another
    // browser.
    var loaded = Store.load();
    setTimeout(paint, 500);
    loaded.always(paint);
  });


// ── Footnote markers and their cards ────────────────────────────────
  // Small count on the toggle, so a reader can tell there is something in
/* QuickEdit already stores footnotes: it wraps the annotated words in
  // the panel without opening it — worth having now that notes can arrive
  <span class="gra-qe-footnote-highlight" data-gra-id="…"> and appends a
  // from another device.
  <div class="gra-qe-footnotes"><ol> at the foot of the document, one
  function updateBadge() {
  <li data-gra-id="…"> per note carrying the quoted words and the note.
    var n = _notes.length + _bookmarks.length;
 
     var $b = $('#gra-toggle-badge');
  That is the whole apparatus, but reading it meant scrolling to the end of
    if (!$b.length) return;
  a long document and scrolling back. This turns the same data into what
    $b.text(n > 99 ? '99+' : String(n)).css('display', n ? 'block' : 'none');
  Setutila shows: a small superscript after the annotated words, and a card
  }
  at the point of reading. Nothing is stored differently and QuickEdit is
  untouched — the list is still written, still saved, and still printed; it
  is only taken off the screen, where the markers replace it.
 
  The note text is transliterated with the rest of the page: the card's body
  is tagged data-deva and announced through gr-new-content, which the script
  switcher already listens for. */
(function () {
    'use strict';
 
    var HL = '.gra-qe-footnote-highlight';
    var LIST = '.gra-qe-footnotes';
    var TYPES = {                      // data-gra-type on the <li>, if set
        pathantara: 'पाठान्तरम्',
        source: 'मूलम्',
        note: 'टिप्पणी'
    };
 
    var card = null, openFor = null;
 
    function notesFromList() {
        var out = {};
        document.querySelectorAll(LIST + ' .gra-qe-footnote-item').forEach(function (li) {
            var id = li.getAttribute('data-gra-id');
            if (!id) return;
            var textEl = li.querySelector('.gra-qe-footnote-text');
            out[id] = {
                text: (textEl ? textEl.textContent : li.textContent).trim(),
                quote: (li.querySelector('.gra-qe-footnote-quote') || {}).textContent || '',
                type: li.getAttribute('data-gra-type') || 'note'
            };
        });
        return out;
    }
 
    function buildCard() {
        if (card) return card;
        card = document.createElement('div');
        card.className = 'gr-note-card';
        card.setAttribute('role', 'dialog');
        card.hidden = true;
        card.innerHTML =
            '<div class="gr-note-card-head">' +
            '<span class="gr-note-card-kind"></span>' +
            '<button type="button" class="gr-note-card-close" aria-label="Close">×</button>' +
            '</div><div class="gr-note-card-body"></div>';
        card.querySelector('.gr-note-card-close').addEventListener('click', close);
        document.body.appendChild(card);
        return card;
    }
 
    function close() {
        if (!card || card.hidden) return;
        card.hidden = true;
        if (openFor) {
            openFor.setAttribute('aria-expanded', 'false');
            openFor.classList.remove('gr-note-marker-open');
        }
        openFor = null;
    }
 
    // Anchored under the marker, nudged back inside the viewport rather than
    // flipped: these sit in running text, and a card that jumps above the line
    // is harder to connect to the word it belongs to than one that shifts left.
    function place(marker) {
        var r = marker.getBoundingClientRect();
        var w = card.offsetWidth, h = card.offsetHeight;
        var pad = 10;
        var left = r.left + r.width / 2 - w / 2;
        left = Math.max(pad, Math.min(left, window.innerWidth - w - pad));
        var top = r.bottom + 8;
        if (top + h > window.innerHeight - pad && r.top - h - 8 > pad) top = r.top - h - 8;
        card.style.left = Math.round(left) + 'px';
        card.style.top = Math.round(top) + 'px';
    }
 
    function open(marker, note, num) {
        buildCard();
        if (openFor === marker && !card.hidden) { close(); return; }
        close();
        card.querySelector('.gr-note-card-kind').textContent =
            (TYPES[note.type] || TYPES.note) + '  [' + num + ']';
        var body = card.querySelector('.gr-note-card-body');
        // A bare text node, deliberately. gr-new-content's handler wraps it,
        // records it as translatable and applies whatever script is showing —
        // tagging it here instead would make the handler skip it as already
        // done, and the note would sit in Devanagari on a Telugu page.
        body.textContent = note.text;
        card.hidden = false;
        place(marker);
        try {
            window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: body } }));
        } catch (e) { }
        marker.setAttribute('aria-expanded', 'true');
        marker.classList.add('gr-note-marker-open');
        openFor = marker;
     }
 
    function decorate() {
        var notes = notesFromList();
        var n = 0;
        document.querySelectorAll('#mw-content-text ' + HL).forEach(function (hl) {
            var id = hl.getAttribute('data-gra-id');
            var note = id && notes[id];
            if (!note || !note.text) return;         // orphan highlight: leave it alone
            n++;
            if (hl.nextElementSibling &&
                hl.nextElementSibling.classList.contains('gr-note-marker')) {
                hl.nextElementSibling.textContent = '[' + n + ']';
                return;
            }
            var m = document.createElement('sup');
            m.className = 'gr-note-marker';
            m.textContent = '[' + n + ']';
            m.setAttribute('role', 'button');
            m.setAttribute('tabindex', '0');
            m.setAttribute('aria-expanded', 'false');
            var num = n;
            function show(e) { e.preventDefault(); e.stopPropagation(); open(m, note, num); }
            m.addEventListener('click', show);
            m.addEventListener('keydown', function (e) {
                if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(m, note, num); }
            });
            // The annotated words open it as well. On a phone the marker is a
            // small target, and the words are what the reader is looking at.
            hl.addEventListener('click', show);
            hl.parentNode.insertBefore(m, hl.nextSibling);
        });
        var list = document.querySelector(LIST);
        if (list) list.classList.toggle('gr-notes-listed', n > 0);
    }
 
    document.addEventListener('click', function (e) {
        if (!card || card.hidden) return;
        if (!card.contains(e.target)) close();
    });
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
    window.addEventListener('resize', function () { if (openFor) place(openFor); });
    window.addEventListener('scroll', function () { if (openFor) place(openFor); }, true);


    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
}() );
    else decorate();
    if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { setTimeout(decorate, 150); });
}());