Jump to content

MediaWiki:Common.js: Difference between revisions

From Anandamakaranda
No edit summary
Tags: Reverted Mobile edit Mobile web edit
No edit summary
 
(89 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/* MediaWiki:Common.js — grantha.io  (v7)
/* MediaWiki:Common.js */
*
* Changes vs v6:
*  1. BUG FIX: Main IIFE was never properly closed — About link block was
*    pasted inside it without the closing }() ); which broke the module
*    boundary and killed the by-Author toggle. Each block is now its
*    own self-contained IIFE.
*  2. TOC: "Beginning" link removed.
*  3. TOC: Heading label → "विषयसूची".
*  4. TOC: मूल / उल्लेख nav links injected at top of TOC panel.
*  5. TOC: All subsections expanded by default.
*  6. TOC: Only the active item is bold; all others are normal weight.
*  7. About link URL derived from wgArticlePath — works under any
*    sub-path install (e.g. /My_wiki/). Points to "My_wiki:About".
*  8. TOC customisations skipped on Main_Page and About pages.
*  9. Uses Vector's native TOC with IntersectionObserver-based active highlight.
*/


( function () {
(function () {


  var LS_SCRIPT_KEY = 'grantha_reader_script';
    var LS_SCRIPT_KEY = 'grantha_reader_script';
  var currentScript = 'deva';
    var currentScript = 'deva';


  // ── IAST transliteration ────────────────────────────────────────
    function devanagariToTamil(text) {
  function devanagariToIAST( text ) {
        var CONSONANTS = {
    var CONSONANTS = {
            'क': 'க', 'ख': 'க', 'ग': 'க', 'घ': 'க', 'ङ': 'ங',
      'क':'k','ख':'kh','ग':'g','घ':'gh','ङ':'ṅ',
            'च': 'ச', 'छ': 'ச', 'ज': 'ஜ', 'झ': 'ஜ', 'ञ': 'ஞ',
      'च':'c','छ':'ch','ज':'j','झ':'jh','ञ':'ñ',
            'ट': 'ட', 'ठ': 'ட', 'ड': 'ட', 'ढ': 'ட', 'ण': 'ண',
      'ट':'ṭ','ठ':'ṭh','ड':'ḍ','ढ':'ḍh','ण':'ṇ',
            'त': 'த', 'थ': 'த', 'द': 'த', 'ध': 'த', 'न': 'ந',
      'त':'t','थ':'th','द':'d','ध':'dh','न':'n',
            'प': 'ப', 'फ': 'ப', 'ब': 'ப', 'भ': 'ப', 'म': 'ம',
      'प':'p','फ':'ph','ब':'b','भ':'bh','म':'m',
            'य': 'ய', 'र': 'ர', 'ल': 'ல', 'ळ': 'ள', 'व': 'வ',
      'य':'y','र':'r','ल':'l','ळ':'ḷ','व':'v',
            'श': 'ஶ', 'ष': 'ஷ', 'स': 'ஸ', 'ह': 'ஹ'
      'श':'ś','ष':'ṣ','स':'s','ह':'h'
        };
    };
        var DIACRITICS = {
    var DIACRITICS = {
            'ा': 'ா', 'ि': 'ி', 'ी': 'ீ', 'ु': 'ு', 'ू': 'ூ',
      'ा':'ā','ि':'i','ी':'ī','ु':'u','ू':'ū',
            'े': 'ே', 'ै': 'ை', 'ो': 'ோ', 'ौ': 'ௌ'
      'ृ':'ṛ','ॄ':'ṝ','े':'e','ै':'ai','ो':'o','ौ':'au'
        };
    };
        var VOWELS = {
    var VOWELS = {
            'अ': 'அ', 'आ': 'ஆ', 'इ': 'இ', 'ई': 'ஈ', 'उ': 'உ', 'ऊ': 'ஊ',
      'अ':'a','आ':'ā','इ':'i','ई':'ī','उ':'u','ऊ':'ū',
            'ए': 'ஏ', 'ऐ': 'ஐ', 'ओ': 'ஓ', 'औ': 'ஔ', 'ऽ': 'ऽ'
      'ऋ':'ṛ','ॠ':'ṝ','ए':'e','ऐ':'ai','ओ':'o','औ':'au','ऽ':"'"
        };
    };
        var MISC = {
    var MISC = {
            'ं':'ம்', 'ः':':', 'ँ':'ம்', 'ॐ':'ௐ',
      'ं':'ṃ','ः':'ḥ','ँ':'m̐','ॐ':'oṃ',
            '०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
      '०':'0','१':'1','२':'2','३':'3','४':'4',
            '५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
      '५':'5','६':'6','७':'7','८':'8','९':'9'
        };
    };
        var HALANTA = '्';
    var HALANTA = '्';
        var chars = Array.from(text), result = '', i = 0;
    var chars = Array.from( text );
        while (i < chars.length) {
    var result = '';
            var ch = chars[i], next = chars[i + 1];
    var i = 0;
            if (CONSONANTS[ch]) {
    while ( i < chars.length ) {
                var base = CONSONANTS[ch];
      var ch   = chars[ i ];
                if (next === HALANTA) { result += base + '்'; i += 2; }
      var next = chars[ i + 1 ];
                else if (next === 'ृ') { result += base + '்ரி'; i += 2; }
      if ( CONSONANTS[ ch ] ) {
                else if (next === 'ॄ') { result += base + '்ரீ'; i += 2; }
        var base = CONSONANTS[ ch ];
                else if (DIACRITICS[next]) { result += base + DIACRITICS[next]; i += 2; }
        if ( next === HALANTA )         { result += base;               i += 2; }
                else { result += base; i++; }  // inherent 'a'
        else if ( DIACRITICS[ next ] )   { result += base + DIACRITICS[ next ]; i += 2; }
            }
        else if ( next === 'ं' || next === 'ः' ) { result += base + 'a' + MISC[ next ]; i += 2; }
            else if (ch === 'ऋ') { result += 'ரி'; i++; }
        else                             { result += base + 'a';         i++;   }
            else if (ch === 'ॠ') { result += 'ரீ'; i++; }
      } else if ( VOWELS[ ch ] )         { result += VOWELS[ ch ];       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 if ( MISC[ ch ] )             { result += MISC[ ch ];         i++; }
            else { result += ch; i++; }
      else                               { result += ch;                 i++; }
        }
        return result;
     }
     }
    return result;
  }


  // ── Character maps for Kannada / Tamil ─────────────────────────
    function devanagariToIAST(text) {
  var SCRIPT_MAP = {
        var CONSONANTS = {
    kn: {
            'क': 'k', 'ख': 'kh', 'ग': 'g', 'घ': 'gh', 'ङ': 'ṅ',
      'अ':'ಅ','आ':'ಆ','इ':'ಇ','ई':'ಈ','उ':'ಉ','ऊ':'ಊ','ऋ':'ಋ',
            'च': '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', 'ऽ': "'"
    },
        };
    ta: {
        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++; }
      '०':'0','१':'1','२':'2','३':'3','४':'4',
            }
      '५':'5','६':'6','७':'7','८':'8','९':'9'
            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 PRE = [
    var SCRIPT_MAP = {
    [ /ङ्क/g, 'ंक' ], [ /ङ्ख/g, 'ंख' ], [ /ङ्ग/g, 'ंग' ], [ /ङ्घ/g, 'ंघ' ],
        kn: {
    [ /ञ्च/g, 'ंच' ], [ /ञ्ज/g, 'ंज' ], [ /ण्ट/g, 'ंट' ], [ /ण्ड/g, 'ंड' ],
            'अ': 'ಅ', 'आ': 'ಆ', 'इ': 'ಇ', 'ई': 'ಈ', 'उ': 'ಉ', 'ऊ': 'ಊ', 'ऋ': 'ಋ', 'ॠ': 'ೠ',
    [ /न्त/g, 'ंत' ], [ /न्द/g, 'ंद' ], [ /म्ब/g, 'ंब' ], [ /म्भ/g, 'ंभ' ]
            'ए': 'ಏ', 'ऐ': 'ಐ', 'ओ': 'ಓ', 'औ': 'ಔ', 'ऽ': 'ಽ',
  ];
            'क': 'ಕ', 'ख': 'ಖ', 'ग': 'ಗ', 'घ': 'ಘ', 'ङ': 'ಙ',
            'च': 'ಚ', 'छ': 'ಛ', 'ज': 'ಜ', 'झ': 'ಝ', 'ञ': 'ಞ',
            'ट': 'ಟ', 'ठ': 'ಠ', 'ड': 'ಡ', 'ढ': 'ಢ', 'ण': 'ಣ',
            'त': 'ತ', 'थ': 'ಥ', 'द': 'ದ', 'ध': 'ಧ', 'न': 'ನ',
            'प': 'ಪ', 'फ': 'ಫ', 'ब': 'ಬ', 'भ': 'ಭ', 'म': 'ಮ',
            'य': 'ಯ', 'र': 'ರ', 'ल': 'ಲ', 'ळ': 'ಳ', 'व': 'ವ',
            'श': 'ಶ', 'ष': 'ಷ', 'स': 'ಸ', 'ह': 'ಹ',
            'ा': 'ಾ', 'ि': 'ಿ', 'ी': 'ೀ', 'ु': 'ು', 'ू': 'ೂ',
            'ृ': 'ೃ', 'ॄ': 'ೄ', 'े': 'ೇ', 'ै': 'ೈ', 'ो': 'ೋ', 'ौ': 'ೌ',
            'ं': 'ಂ', 'ः': 'ಃ', '्': '್', 'ँ': 'ಁ',
            '०': '೦', '१': '೧', '२': '೨', '३': '೩', '४': '೪',
            '५': '೫', '६': '೬', '७': '೭', '८': '೮', '९': '೯'
        },
        te: {
            'अ': 'అ', 'आ': 'ఆ', 'इ': 'ఇ', 'ई': 'ఈ', 'उ': 'ఉ', 'ऊ': 'ఊ', 'ऋ': 'ఋ', 'ॠ': 'ౠ',
            'ए': 'ఏ', 'ऐ': 'ఐ', 'ओ': 'ఓ', 'औ': 'ఔ', 'ऽ': 'ఽ',
            'क': 'క', 'ख': 'ఖ', 'ग': 'గ', 'घ': 'ఘ', 'ङ': 'ఙ',
            'च': 'చ', 'छ': 'ఛ', 'ज': 'జ', 'झ': 'ఝ', 'ञ': 'ఞ',
            'ट': 'ట', 'ठ': 'ఠ', 'ड': 'డ', 'ढ': 'ఢ', 'ण': 'ణ',
            'त': 'త', 'थ': 'థ', 'द': 'ద', 'ध': 'ధ', 'न': 'న',
            'प': 'ప', 'फ': 'ఫ', 'ब': 'బ', 'भ': 'భ', 'म': 'మ',
            'य': 'య', 'र': 'ర', 'ल': 'ల', 'ळ': 'ళ', 'व': 'వ',
            'श': 'శ', 'ष': 'ష', 'स': 'స', 'ह': 'హ',
            'ा': 'ా', 'ि': 'ి', 'ी': 'ీ', 'ु': 'ు', 'ू': 'ూ',
            'ृ': 'ృ', 'ॄ': 'ౄ', 'े': 'ే', 'ै': 'ై', 'ो': 'ో', 'ौ': 'ౌ',
            'ं': 'ం', 'ः': 'ః', '्': '్', 'ँ': 'ఁ',
            '०': '౦', '१': '౧', '२': '౨', '३': '౩', '४': '౪',
            '५': '౫', '६': '౬', '७': '౭', '८': '౮', '९': '౯'
        }
    };


  function transliterateText( text, script ) {
     var PRE = [
     if ( script === 'en' ) return devanagariToIAST( text );
        [/ङ्क/g, 'ंक'], [/ङ्ख/g, 'ंख'], [/ङ्ग/g, 'ंग'], [/ङ्घ/g, 'ंघ'],
    var map = SCRIPT_MAP[ script ];
        [/ञ्च/g, 'ंच'], [/ञ्ज/g, 'ंज'], [/ण्ट/g, 'ंट'], [/ण्ड/g, 'ंड'],
    if ( !map ) return text;
        [/न्त/g, 'ंत'], [/न्द/g, 'ंद'], [/म्ब/g, 'ंब'], [/म्भ/g, 'ंभ']
    var t = text;
     ];
    PRE.forEach( function ( p ) { t = t.replace( p[ 0 ], p[ 1 ] ); } );
    return Array.from( t ).map( function ( ch ) {
      return map[ ch ] !== undefined ? map[ ch ] : ch;
     } ).join( '' );
  }


  // ── Tag all transliteratable text nodes once per page ───────────
    function transliterateText(text, script) {
  var translatableSpans = [];
        if (script === 'en') return devanagariToIAST(text);
        if (script === 'ta') return devanagariToTamil(text);
        var map = SCRIPT_MAP[script];
        if (!map) return text;
        var t = text;
        PRE.forEach(function (p) { t = t.replace(p[0], p[1]); });
        return Array.from(t).map(function (ch) {
            return map[ch] !== undefined ? map[ch] : ch;
        }).join('');
    }


  function tagTextNodes() {
     var translatableSpans = [];
     var content = document.querySelector( '.mw-parser-output' );
    if ( content ) {
      var walker = document.createTreeWalker( content, NodeFilter.SHOW_TEXT );
      var nodes  = [];
      while ( walker.nextNode() ) nodes.push( walker.currentNode );


      nodes.forEach( function ( node ) {
    function tagTextNodes() {
        var p = node.parentNode;
        var content = document.querySelector('.mw-parser-output');
        if ( !p ) return;
        if (content) {
        if ( p.hasAttribute && p.hasAttribute( 'data-deva' ) ) return;
            var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
        if ( p.closest ) {
            var nodes = [];
          if ( p.closest( '.gr-controls' )   ) return;
            while (walker.nextNode()) nodes.push(walker.currentNode);
          if ( p.closest( '.mw-editsection' ) ) return;
            nodes.forEach(function (node) {
          // #gr-toc-doc-nav buttons manage their own data-deva spans in makeBtn
                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);
            });
         }
         }
         var orig = node.textContent;
         document.querySelectorAll('.vector-toc .vector-toc-text').forEach(tagTocEntry);
        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( function ( span ) {
     // A contents entry is "1.2 शीर्षकम्": the number lives in its own
      if ( span.hasAttribute( 'data-deva' ) ) return;
    // .vector-toc-numb span, which Common.css hides. Tagging the whole
      var orig = span.textContent;
    // .vector-toc-text meant transliteration replaced its contents with
      if ( !orig.trim() ) return;
    // one flat string — number included — so switching to Tamil or any
      span.setAttribute( 'data-deva', orig );
    // other script made the numbering appear. Only the label is tagged.
      translatableSpans.push( span );
    function tagTocEntry(textEl) {
     } );
        if (!textEl || textEl.hasAttribute('data-deva')) return;
  }
        var label = textEl.querySelector('[data-deva]');
        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);
     }


  // ── Apply a script to all tagged spans ─────────────────────────
    // Which script is on screen, published to CSS. Common.css §2b uses it to
  function applyScript( script ) {
    // swap the reading area onto --font-indic while a transliteration is
     currentScript = script;
    // showing: most rules here name Adishila directly, which has no Tamil,
     translatableSpans.forEach( function ( span ) {
    // Kannada or Telugu letters, so without this the switched text fell back
      if ( !span.parentNode ) return;
     // to whatever serif the visitor's machine happened to have — the reason
      var orig = span.getAttribute( 'data-deva' );
     // it looked uneven from one line to the next.
      if ( !orig ) return;
    function markScriptOnRoot(script) {
      span.textContent = ( script === 'deva' )
        try {
        ? orig
            var el = document.documentElement;
        : transliterateText( orig, script );
            if (!el) return;
    } );
            if (!script || script === 'deva') el.removeAttribute('data-gr-script');
  }
            else el.setAttribute('data-gr-script', script);
        } catch (e) { }
    }


  // ── Pages where sidebar TOC should not be modified ──────────────
    function applyScript(script) {
  function _isNoTocPage() {
        currentScript = script;
    var pn = ( window.mw && mw.config && mw.config.get( 'wgPageName' ) ) || '';
        markScriptOnRoot(script);
    return pn === 'Main_Page' || /^[A-Za-z0-9_]+:About$/.test( pn );
        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);
        });
    }


  // ── TOC: rename "Contents" → "विषयसूची" ────────────────────────
    function _isNoTocPage() {
function renameTocTitle() {
        var pn = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
  if ( _isNoTocPage() ) return;
        return pn === 'Main_Page' || /^[A-Za-z0-9_]+:About$/.test(pn);
    }


  var toc = document.querySelector('.vector-toc');
    function renameTocTitle() {
  if ( !toc ) return;
        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);
    }


  var titleEl =
    function removeTocBeginning() {
    toc.querySelector('.vector-toc-title') ||
        if (_isNoTocPage()) return;
    toc.querySelector('.vector-pinnable-header-label');
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        var el = toc.querySelector('#vector-toc-beginning');
        if (!el) {
            var items = toc.querySelectorAll('.vector-toc-list-item');
            for (var i = 0; i < items.length; i++) {
                var a = items[i].querySelector('a');
                if (a) {
                    var href = a.getAttribute('href') || '';
                    if (href.indexOf('#') === -1) { el = items[i]; break; }
                }
            }
        }
        if (el && el.parentNode) el.parentNode.removeChild(el);
    }


  if ( !titleEl ) return;
    // 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);
        });
    }


  var LABEL = 'विषयसूची';
    // Section numbers ("2.1.1") are removed from the contents outright.
    // Hiding them with CSS was not enough: the script switcher replaces an
    // entry's contents with one flat string, which turned the hidden number
    // into ordinary text — so it reappeared in Tamil, Kannada and the rest.
    // They are also wrong here, since this contents list leaves entries out.
    var NUMBERED = /^\s*\d+(?:\.\d+)*[.)]?\s+([\s\S]*\S)\s*$/;
    function stripTocNumbers() {
        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.
    var _numbObserver = null;
    function watchTocNumbers() {
        var toc = document.querySelector('.vector-toc');
        if (!toc || _numbObserver || !window.MutationObserver) return;
        var pending = false;
        _numbObserver = new MutationObserver(function () {
            if (pending) return;
            pending = true;
            setTimeout(function () { pending = false; stripTocNumbers(); }, 50);
        });
        _numbObserver.observe(toc, { childList: true, subtree: true, characterData: true });
    }


  /* If already inserted, just refresh text */
    function expandTocSections() {
  var span = titleEl.querySelector('.gr-toc-title');
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        toc.querySelectorAll('.vector-toc-list-item-collapsed').forEach(function (li) {
            li.classList.remove('vector-toc-list-item-collapsed');
        });
    }


  if ( !span ) {
    function injectTocDocNav() {
    titleEl.innerHTML = '';
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        if (document.getElementById('gr-toc-doc-nav')) return;
        var artPath = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
        var pageTitle = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
        var teekaPage = document.querySelector('.gr-teeka-page');
        var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || '') : '';
        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);
    }


     span = document.createElement('span');
     var _headingObserver = null;
     span.className = 'gr-toc-title';
     var _tocActiveId = null;
    span.setAttribute('data-deva', LABEL);


     titleEl.appendChild(span);
     // NEW: moved out of attachHeadingObserver's own local scope so they
     translatableSpans.push(span);  // uses your internal array
    // can be reused both by the scroll observer AND by a direct trigger
  }
    // for search-result clicks — was previously only reachable from
    // inside attachHeadingObserver's own IntersectionObserver callback.
    // _tocActiveId (was _activeId, local to attachHeadingObserver) is
    // module-level now for the same reason — both callers need to share
    // the same "what's currently active" state, not track it separately.
    function getTocLink(id) {
        var toc = document.querySelector('.vector-toc');
        if (!toc) return null;
        return toc.querySelector('a[href="#' + CSS.escape(id) + '"]');
     }
    function getTocLi(id) {
        var a = getTocLink(id);
        return a ? a.closest('.vector-toc-list-item') : null;
    }
    // Highlighting is class-only (styled in Common.css §6):
    //   .gr-toc-current  — the one entry being read, on the <a> itself
    //  .gr-toc-in-path  — its parent sections, marked on the <li>
    // Previously this set inline colours on the active item AND on every
    // element inside it, so a section with sub-sections lit up whole.
    function clearActiveToc() {
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        toc.querySelectorAll('.gr-toc-current').forEach(function (el) { el.classList.remove('gr-toc-current'); });
        toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
    }
    function ownLink(li) {
        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);


  span.textContent =
        var lnk = ownLink(li);
    currentScript === 'deva'
        var hostLi = li;
      ? LABEL
        if (lnk && !isShown(lnk)) {          // hidden in a collapsed section
      : transliterateText(LABEL, currentScript);
            var p = li.parentNode;
}
            while (p && p !== toc) {
  // ── TOC: Remove the "Beginning" / top-of-page link ─────────────
                if (p.classList && p.classList.contains('vector-toc-list-item')) {
  function removeTocBeginning() {
                    var pl = ownLink(p);
    if ( _isNoTocPage() ) return;
                    if (pl && isShown(pl)) { lnk = pl; hostLi = p; break; }
    var toc = document.querySelector( '.vector-toc' );
                }
    if ( !toc ) return;
                p = p.parentNode;
    // Try the dedicated id first (Vector 2022)
            }
    var el = toc.querySelector( '#vector-toc-beginning' );
    if ( !el ) {
      // Fallback: first list-item whose link has no # anchor = the "Beginning" entry
      var items = toc.querySelectorAll( '.vector-toc-list-item' );
      for ( var i = 0; i < items.length; i++ ) {
        var a = items[ i ].querySelector( 'a' );
        if ( a ) {
          var href = a.getAttribute( 'href' ) || '';
          if ( href.indexOf( '#' ) === -1 ) { el = items[ i ]; break; }
         }
         }
      }
        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);
     }
     }
    if ( el && el.parentNode ) el.parentNode.removeChild( el );
  }


  // ── TOC: Expand all collapsed subsections on load ───────────────
    // Scroll the TOC panel so the active entry is visible. This used to
  function expandTocSections() {
    // measure the whole <li>, which for a section with sub-sections is as
    if ( _isNoTocPage() ) return;
    // tall as the entire branch — so "centre it" landed somewhere inside
    var toc = document.querySelector( '.vector-toc' );
    // the NEXT section. It now measures the link itself, and finds the
    if ( !toc ) return;
    // element that actually scrolls rather than assuming one.
    toc.querySelectorAll( '.vector-toc-list-item-collapsed' ).forEach( function ( li ) {
    function keepTocEntryVisible(el) {
      li.classList.remove( 'vector-toc-list-item-collapsed' );
        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);
     }


  // ── TOC: Inject मूल / उल्लेख nav links ─────────────────────────
    // NEW: given any element in the article content, finds the nearest
  function injectTocDocNav() {
    // heading (with an id) that precedes it in document order, using
     if ( _isNoTocPage() ) return;
    // compareDocumentPosition — robust regardless of nesting depth.
    var toc = document.querySelector( '.vector-toc' );
    // Verified against a realistic multi-section fragment, including
    if ( !toc ) return;
    // the "before any heading" and "right after a chapter heading, no
    if ( document.getElementById( 'gr-toc-doc-nav' ) ) return;
    // 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;
    }


     var artPath  = ( window.mw && mw.config && mw.config.get( 'wgArticlePath' ) ) || '/wiki/$1';
     // NEW: exposed so the search-highlight IIFE (below) can directly
    var pageTitle = ( window.mw && mw.config && mw.config.get( 'wgPageName'  ) ) || '';
    // 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);
    };


     var teekaPage   = document.querySelector( '.gr-teeka-page' );
     // ── Which section am I reading? ────────────────────────────────
     var primarySlug = teekaPage ? ( teekaPage.getAttribute( 'data-primary' ) || '' ) : '';
    // Was an IntersectionObserver with a shrunken root band. Two faults
     var docSlug     = teekaPage ? ( teekaPage.getAttribute( 'data-slug'    ) || '' ) : '';
    // came out of that, both reported as "the highlight is wrong":
    //  • right after jumping to a heading, that heading sits ABOVE the
    //    band, so nothing was "visible" and the fallback picked the
    //    PREVIOUS heading — clicking a sub-section highlighted its parent;
    //   • once every heading was outside the band, no intersection ever
    //    changed again, so the observer stopped firing and the highlight
    //    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}


     if ( !primarySlug ) {
     function measureHeadings() {
      primarySlug = pageTitle.split( '/' )[ 0 ];
        var y = window.pageYOffset || document.documentElement.scrollTop || 0;
      docSlug    = primarySlug;
        _tocTops = _tocHeadings.map(function (h) { return h.getBoundingClientRect().top + y; });
        _tocMeasuredHeight = document.documentElement.scrollHeight;
     }
     }
     if ( !primarySlug ) return;
     function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
    // The line down the page that decides "which section am I reading":
    // 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
    // there. A plain scrollIntoView lands short or long whenever the page
    // reflows just after the jump — a web font finishing, a commentary
    // being interleaved, the match itself being wrapped in a highlight —
    // 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);
    };


     function wikiUrl( slug ) {
    // Called when the reader picks a heading (TOC click, search result,
      if ( window.mw && mw.util && mw.util.getUrl ) return mw.util.getUrl( slug );
    // or a #hash in the URL) — that heading wins until they scroll on.
      return artPath.replace( '$1', encodeURIComponent( slug ).replace( /%2F/g, '/' ) );
     function pinActive(id) {
        if (!id) return;
        _pin = { id: id, y: scrollY(), until: Date.now() + 900 };
        setActiveToc(id, true);          // open its section if collapsed
        scheduleRecheck(950);
    }
    var _recheck = null;
    function scheduleRecheck(ms) {
        if (_recheck) return;
        _recheck = setTimeout(function () { _recheck = null; updateActiveFromScroll(); }, ms);
    }
    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);
     }
     }


     var moolaUrl  = wikiUrl( primarySlug );
     function attachHeadingObserver() {
    var ullekhaUrl = wikiUrl( primarySlug + '/Ullekha' );
        if (_isNoTocPage()) return;
        if (_headingObserver) return;
        var content = document.querySelector('.mw-parser-output');
        if (!content) return;
        if (!document.querySelector('.vector-toc')) return;
        // 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();
    }


     var docTitleEl  = document.querySelector( '.gr-doc-title' );
     // Clicking an entry highlights it straight away, rather than waiting
    var hasMoolaPage  = docTitleEl && docTitleEl.getAttribute( 'data-has-moola' )  === '1';
    // for the scroll observer to catch up after the jump.
    var hasUllekhaPage = docTitleEl && docTitleEl.getAttribute( 'data-has-ullekha' ) === '1';
    function attachTocClickHandler(toc) {
        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);
        });
    }


     var moolaPageUrl = wikiUrl( primarySlug + '/Moola' );
     function setupToc() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        removeTocBeginning();
        removeTocTeekaEntry();
        stripTocNumbers();
        watchTocNumbers();
        renameTocTitle();
        expandTocSections();
        injectTocDocNav();
        attachTocClickHandler(toc);
        attachHeadingObserver();
    }


     var showMoolaPage  = !teekaPage && hasMoolaPage;
     function init() {
    var showMoolaBack  = !!teekaPage;
        var HIDE_IDS = ['vector-appearance', 'vector-appearance-pinned-container', 'vector-appearance-unpinned-container'];
    var showUllekha    = hasUllekhaPage || !!teekaPage;
        function removeHiddenEls() {
            HIDE_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); });
            var pt = document.getElementById('vector-page-tools') || document.querySelector('.vector-page-tools-pinned-container');
            if (pt) pt.querySelectorAll('[aria-controls="vector-appearance"]').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
        }
        removeHiddenEls();
        (function detectTeekaMode() {
            var tp = document.querySelector('.gr-teeka-page');
            if (!tp) return;
            var primary = tp.getAttribute('data-primary') || '';
            var artPath = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1';
            var mainUrl = artPath.replace('$1', primary);
            var refParam = window.location.search.match(/[?&]ref=([01])/);
            if (refParam) { document.body.classList.add(refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone'); return; }
            var ref = document.referrer || '';
            document.body.classList.add((ref && primary && ref.indexOf(mainUrl) !== -1) ? 'gr-ref-mode' : 'gr-standalone');
        }());
        if (window.MutationObserver) {
            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);
    }


     if ( !showMoolaPage && !showMoolaBack && !showUllekha ) return;
     window.addEventListener('gr-script-change', function (e) {
        var script = e && e.detail && e.detail.script;
        if (script) applyScript(script);
    });
 
    var _translitBusy = false;
    function tagNewContent(container) {
        if (!container || !container.nodeType) return;
        _translitBusy = true;
        try {
            var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
            var nodes = [];
            while (walker.nextNode()) nodes.push(walker.currentNode);
            nodes.forEach(function (node) {
                var p = node.parentNode;
                if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
                if (p.closest && (p.closest('.gr-controls') || p.closest('.mw-editsection')
                                  || 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 = 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 });
    }


     var nav = document.createElement( 'div' );
     try {
    nav.id = 'gr-toc-doc-nav';
        var _grBC = new BroadcastChannel('gr-script');
     nav.setAttribute( 'class', 'toc-main-links');
        _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) { }


     // ── CHANGE: makeBtn now wraps label in a data-deva span so
     if (window.mw) {
    // transliteration (script switching) applies to button text.
        mw.hook('wikipage.content').add(function () {
    function makeBtn( href, label ) {
            setTimeout(function () {
      var a = document.createElement( 'a' );
                var content = document.querySelector('.mw-parser-output');
      a.href = href;
                var alreadyTagged = content && content.querySelector('[data-deva]');
      a.setAttribute( 'class', 'toc-main-link-item');
                if (!alreadyTagged) { translatableSpans = []; tagTextNodes(); }
      var lspan = document.createElement( 'span' );
                else {
      lspan.setAttribute( 'data-deva', label );
                    // tagTocEntry, not a re-tag from textContent: an entry
      lspan.textContent = ( currentScript && currentScript !== 'deva' )
                    // keeps its Devanagari in a span *inside* .vector-toc-text,
        ? transliterateText( label, currentScript )
                    // so .vector-toc-text itself never carries data-deva and a
        : label;
                    // :not([data-deva]) sweep matched every entry on every
      translatableSpans.push( lspan );
                    // fire. It then stored the text as it stood — Tamil, once
      a.appendChild( lspan );
                    // the reader had switched — as that entry's "original", so
      a.addEventListener( 'mouseover', function () { this.style.opacity = '0.72'; } );
                    // the next switch had no Devanagari left to work from and
      a.addEventListener( 'mouseout',  function () { this.style.opacity = '1';   } );
                    // the contents panel stayed in the previous script while
      return a;
                    // 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 ( showMoolaPage ) nav.appendChild( makeBtn( moolaPageUrl, 'मूलम्' ) );
     if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); }
    if ( showMoolaBack ) nav.appendChild( makeBtn( moolaUrl,    'मूल' ) );
     else { init(); }
     if ( showUllekha  ) nav.appendChild( makeBtn( ullekhaUrl,  'उल्लेख') );


    var tocContents = toc.querySelector( '.vector-toc-contents' );
}());  /* ← end of main IIFE */
    if ( tocContents ) toc.insertBefore( nav, tocContents );
    else              toc.appendChild( nav );
   }


  // ── TOC active-item highlight ────────────────────────────────────
  function watchTocActive() {
    if ( _isNoTocPage() ) return;
    var toc = document.querySelector( '.vector-toc' );
    if ( !toc ) return;
    if ( !window.MutationObserver ) return;


     if ( toc._grObserved ) {
// ── Inject "Help" and "About" links into the header ─────────────────
      toc.querySelectorAll( '.vector-toc-list-item' ).forEach( attachHighlight );
(function () {
      return;
     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);
     }
     }
     toc._grObserved = true;
     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 ACTIVE_COLOR = '#f57c00';
        var userName = window.mw ? mw.config.get('wgUserName') : null;
        var isAnon = !userName || (window.mw && mw.config.get('wgUserId') === null);


    function setActive( li, on ) {
        var authEl = null;
      var link = li.querySelector( '.vector-toc-link' ) || li.querySelector( 'a' );
        if (isAnon) {
      if ( !link ) return;
            if (!document.getElementById('gr-header-login')) {
      var hasActiveChild = !!li.querySelector(
                authEl = makeHeaderLink('gr-header-login', wikiHref('Special:UserLogin'), 'Login');
        '.vector-toc-list-item .vector-toc-list-item-active'
            }
      );
        } else if (!document.getElementById('gr-header-user')) {
      var shouldHighlight = on && !hasActiveChild;
            authEl = buildHeaderUserDropdown(userName);
      if ( shouldHighlight ) {
        }
         link.style.setProperty( 'color',      ACTIVE_COLOR, 'important' );
 
        link.style.setProperty( 'font-weight', '700',        'important' );
         var ul = document.querySelector('.vector-user-links') || document.querySelector('#pt-userpage');
         link.querySelectorAll( '*' ).forEach( function ( el ) {
         if (ul && ul.parentNode === headerEnd) {
          el.style.setProperty( 'color',       ACTIVE_COLOR, 'important' );
            if (authEl) headerEnd.insertBefore(authEl, ul);
          el.style.setProperty( 'font-weight', '700',        'important' );
            headerEnd.insertBefore(aboutLink, authEl || ul);
         } );
            headerEnd.insertBefore(helpLink, aboutLink);
      } else {
         } else {
        link.style.removeProperty( 'color' );
            headerEnd.appendChild(helpLink);
        link.style.setProperty( 'font-weight', '400', 'important' );
            headerEnd.appendChild(aboutLink);
        link.querySelectorAll( '*' ).forEach( function ( el ) {
            if (authEl) headerEnd.appendChild(authEl);
          el.style.removeProperty( 'color' );
         }
          el.style.setProperty( 'font-weight', '400', 'important' );
         } );
      }
     }
     }


     function normaliseAll() {
     function buildHeaderUserDropdown(userName) {
      toc.querySelectorAll( '.vector-toc-list-item' ).forEach( function ( li ) {
        var wrap = document.createElement('div');
         setActive( li, li.classList.contains( 'vector-toc-list-item-active' ) );
        wrap.id = 'gr-header-user';
      } );
        wrap.style.cssText = 'position:relative;display:inline-block;margin-right:4px;';
 
        var trigger = document.createElement('button');
        trigger.id = 'gr-header-user-trigger';
        trigger.type = 'button';
        trigger.setAttribute('aria-haspopup', 'true');
        trigger.setAttribute('aria-expanded', 'false');
        trigger.style.cssText = [
            'display:inline-flex', 'align-items:center', 'gap:5px',
            'color:rgba(255,255,255,0.88)', 'font-size:0.88em',
            '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');
        menu.id = 'gr-header-user-menu';
        menu.setAttribute('role', 'menu');
        menu.style.cssText = [
            'display:none', 'position:absolute', 'top:100%', 'right:0', 'margin-top:4px',
            'min-width:150px', 'background:#fff', 'border-radius:6px',
            'box-shadow:0 4px 16px rgba(0,0,0,0.18)', 'overflow:hidden', 'z-index:1000'
        ].join(';');
 
        var itemStyle = 'display:block;padding:10px 16px;font-size:0.9em;color:#2c1810;text-decoration:none;font-family:system-ui,sans-serif;background:#fff;';
        function makeMenuItem(href, label, id) {
            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) {
            e.stopPropagation();
            setOpen(menu.style.display === 'none');
        });
        document.addEventListener('click', function (e) {
            if (!wrap.contains(e.target)) setOpen(false);
        });
 
        menu.querySelector('#gr-header-logout').addEventListener('click', function (e) {
            e.preventDefault();
            if (window.mw && mw.user && mw.user.tokens) {
                var t = mw.user.tokens.get('csrfToken');
                if (t) {
                    var script = (mw.util && mw.util.wikiScript) ? mw.util.wikiScript() : '/index.php';
                    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 {
                location.href = '/index.php?title=Special:UserLogout';
            }
        });
 
        wrap.appendChild(trigger);
        wrap.appendChild(menu);
        return wrap;
     }
     }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectHeaderLinks);
    else injectHeaderLinks();
}());


     function attachHighlight( li ) {
 
      if ( li._grHighlightAttached ) return;
// ── Main page: by-Grantha / by-Author toggle ──────────────────────
      li._grHighlightAttached = true;
(function () {
      liObs.observe( li, { attributes: true, attributeFilter: [ 'class' ] } );
     function grHomeView(v) {
      setActive( li, li.classList.contains( 'vector-toc-list-item-active' ) );
        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) { }
     }
     }
}());


    var liObs = new MutationObserver( function ( mutations ) {
// ── Ullekha reference link handler ─────────────────────────────────
      mutations.forEach( function ( m ) {
(function () {
         if ( m.attributeName !== 'class' ) return;
    // Uses the shared, reflow-proof scroller when it is available.
         var li    = m.target;
    function grScrollTo(el) {
         var active = li.classList.contains( 'vector-toc-list-item-active' );
         if (!el) return;
        setActive( li, active );
         if (window.grScrollToElement) window.grScrollToElement(el);
         else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }


        if ( active ) {
    function highlightOnArrival() {
          var anc = li.parentNode;
        var search = window.location.search;
          while ( anc && anc !== document.body ) {
        if (!search) return;
             if ( anc.classList &&
        var m = search.match(/[?&]hlUllekha=([^&]+)/);
                anc.classList.contains( 'vector-toc-list-item' ) &&
        if (!m) return;
                anc.classList.contains( 'vector-toc-list-item-collapsed' ) ) {
        var needle;
              anc.classList.remove( 'vector-toc-list-item-collapsed' );
        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;
             }
             }
             anc = anc.parentNode;
             if (!found) {
          }
                var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
 
                while (walker.nextNode() && !found) {
          var container = document.querySelector( '.vector-sticky-pinned-container' );
                    var node = walker.currentNode;
          if ( container ) {
                    var txt = node.textContent || '';
            var vis = container.offsetHeight > 0 &&
                    if (txt.indexOf(snippet) === -1) continue;
                      container.offsetParent !== null &&
                    var idx2 = txt.indexOf(snippet);
                      window.getComputedStyle( container ).display !== 'none' &&
                    var mark2 = document.createElement('mark');
                      window.getComputedStyle( container ).visibility !== 'hidden';
                    mark2.className = 'gr-ullekha-highlight';
            if ( vis ) {
                    mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
              var lr = li.getBoundingClientRect();
                    mark2.textContent = txt.slice(idx2, Math.min(idx2 + needle.length, txt.length));
              var cr = container.getBoundingClientRect();
                    var p = node.parentNode;
              if ( lr.top < cr.top + 4 || lr.bottom > cr.bottom - 4 ) {
                    p.insertBefore(document.createTextNode(txt.slice(0, idx2)), node);
                var sh = null, node = li.parentNode;
                    p.insertBefore(mark2, node);
                while ( node && node !== document.body ) {
                    p.insertBefore(document.createTextNode(txt.slice(idx2 + mark2.textContent.length)), node);
                  var ov = window.getComputedStyle( node ).overflowY;
                    p.removeChild(node);
                  if ( ( ov === 'auto' || ov === 'scroll' ) &&
                    setTimeout(function () { grScrollTo(mark2); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark2); }, 100);
                      node.scrollHeight > node.clientHeight ) { sh = node; break; }
                    found = true;
                  node = node.parentNode;
                 }
                 }
                if ( !sh && container.scrollHeight > container.clientHeight ) sh = container;
                if ( sh ) {
                  var hr  = sh.getBoundingClientRect();
                  var top = lr.top - hr.top + sh.scrollTop;
                  sh.scrollTop = Math.max( 0, top - sh.clientHeight / 2 + li.offsetHeight / 2 );
                }
              }
             }
             }
          }
         }
         }
      } );
        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(); }
}());


    var structObs = new MutationObserver( function ( mutations ) {
/* ── Search result highlight ──────────────────────────────────── */
      mutations.forEach( function ( m ) {
(function () {
        if ( m.type !== 'childList' ) return;
        m.addedNodes.forEach( function ( n ) {
          if ( n.nodeType !== 1 ) return;
          if ( n.classList && n.classList.contains( 'vector-toc-list-item' ) ) attachHighlight( n );
          if ( n.querySelectorAll ) n.querySelectorAll( '.vector-toc-list-item' ).forEach( attachHighlight );
          var newSpans = [];
          if ( n.classList && n.classList.contains( 'vector-toc-text' ) ) newSpans.push( n );
          if ( n.querySelectorAll ) n.querySelectorAll( '.vector-toc-text' ).forEach( function ( s ) { newSpans.push( s ); } );
          newSpans.forEach( function ( span ) {
            if ( span.hasAttribute( 'data-deva' ) ) return;
            var orig = span.textContent;
            if ( !orig.trim() ) return;
            span.setAttribute( 'data-deva', orig );
            if ( currentScript !== 'deva' ) span.textContent = transliterateText( orig, currentScript );
            translatableSpans.push( span );
          } );
        } );
      } );
    } );


     toc.querySelectorAll( '.vector-toc-list-item' ).forEach( attachHighlight );
     function storeQueryForLink(url, query) {
    structObs.observe( toc, { childList: true, subtree: true } );
        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() {
        var stored;
        try {
            stored = JSON.parse(sessionStorage.getItem('gr_search_hl') || 'null');
        } catch (e) { return; }
        if (!stored || !stored.query) return;


    setTimeout( function () {
        var currentPath = window.location.pathname;
      normaliseAll();
        var storedPath = stored.pathname || '';
      var active = toc.querySelector( '.vector-toc-list-item-active' );
      if ( active ) setActive( active, true );
    }, 300 );
  }


  // ── TOC: Use Vector's native TOC, add highlight + expand + rename ──────
        function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); }
  // Drops the custom TOC builder entirely. Vector builds the correct TOC
        if (storedPath && normPath(storedPath) !== normPath(currentPath)) {
  // from page headings (== Chapter ==, === Section ===). We just:
            try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
  //  1. Rename "Contents" → "विषयसूची"
            return;
  //  2. Expand all collapsed sections
        }
  //  3. Remove the "Beginning" top link
  //  4. Inject मूल/उल्लेख nav
  //  5. Add reliable IntersectionObserver active-highlight on headings


  function setupToc() {
        var query = stored.query.trim();
    if ( _isNoTocPage() ) return;
        if (!query) return;
    var toc = document.querySelector( '.vector-toc' );
    if ( !toc ) return;


    removeTocBeginning();
        try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
    renameTocTitle();
    expandTocSections();
    injectTocDocNav();
    attachHeadingObserver();
  }


  // ── IntersectionObserver on actual heading elements ──────────────
        var delays = [0, 200, 600];
  // Watches the real == Heading == elements in the page body.
        delays.forEach(function (ms) {
  // Much more reliable than watching gr-toc-anchor spans.
            setTimeout(function () {
  var _headingObserver = null;
                if (document.querySelector('.gr-search-hl')) return;
                highlightText(query);
            }, ms);
        });
    }


  function attachHeadingObserver() {
    // Uses the shared, reflow-proof scroller when it is available.
    if ( _isNoTocPage() ) return;
    function grScrollTo(el) {
    if ( _headingObserver ) return;  // only attach once
        if (!el) return;
    if ( !window.IntersectionObserver ) {
        if (window.grScrollToElement) window.grScrollToElement(el);
      watchTocActive();              // fallback for old browsers
        else el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      return;
     }
     }


     var ACTIVE_COLOR = '#f57c00';
     function highlightText(query) {
    var _activeId    = null;
        var content = document.querySelector('#mw-content-text .mw-parser-output');
        if (!content) return;
 
        var raw = query.replace(/^"|"$/g, '').trim();
        if (!raw) return;


    // Collect all headings that have an id (Vector gives them ids)
        var patterns = [];
    var content  = document.querySelector( '.mw-parser-output' );
        patterns.push(escapeRegex(raw));
    if ( !content ) return;
        raw.split(/\s+/).forEach(function (w) {
    var headings = Array.from(
            if (w.length >= 2) patterns.push(escapeRegex(w));
      content.querySelectorAll( 'h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]' )
        });
    );
    if ( !headings.length ) return;


    var toc = document.querySelector( '.vector-toc' );
        var matched = false;
    if ( !toc ) return;
        for (var pi = 0; pi < patterns.length; pi++) {
            var re;
            try { re = new RegExp('(' + patterns[pi] + ')', 'gi'); }
            catch (e) { continue; }
            var count = wrapMatches(content, re);
            if (count > 0) { matched = true; break; }
        }


    function getTocLink( id ) {
        if (!matched) return;
      // Vector renders TOC links as href="#id"
 
      return toc.querySelector( 'a[href="#' + CSS.escape( id ) + '"]' );
        var first = document.querySelector('.gr-search-hl');
        if (first) {
            grScrollTo(first);
            first.classList.add('gr-search-hl-pulse');
            setTimeout(function () { first.classList.remove('gr-search-hl-pulse'); }, 2000);
            // NEW: directly trigger the TOC to expand and highlight the
            // right section, rather than relying solely on the scroll
            // observer to catch up on its own. This is the actual fix —
            // confirmed the helper against a realistic multi-section
            // fragment before wiring it in here.
            if (window.grHighlightTocForElement) window.grHighlightTocForElement(first);
        }
 
        showDismissBar(query);
     }
     }


     function getTocLi( id ) {
     function escapeRegex(s) {
      var a = getTocLink( id );
        return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      return a ? a.closest( '.vector-toc-list-item' ) : null;
     }
     }


     function clearActive() {
     function wrapMatches(root, re) {
      toc.querySelectorAll( '.vector-toc-list-item-active' ).forEach( function ( li ) {
        var count = 0;
        li.classList.remove( 'vector-toc-list-item-active' );
        var walker = document.createTreeWalker(
        var lnk = li.querySelector( '.vector-toc-link' );
            root, NodeFilter.SHOW_TEXT, {
        if ( !lnk ) return;
            acceptNode: function (node) {
        lnk.style.removeProperty( 'color' );
                var p = node.parentElement;
        lnk.style.setProperty( 'font-weight', '400', 'important' );
                if (!p) return NodeFilter.FILTER_REJECT;
        lnk.querySelectorAll( '*' ).forEach( function ( el ) {
                var tag = p.tagName.toUpperCase();
          el.style.removeProperty( 'color' );
                if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT;
          el.style.setProperty( 'font-weight', '400', 'important' );
                if (p.classList.contains('gr-search-hl')) return NodeFilter.FILTER_REJECT;
         } );
                return NodeFilter.FILTER_ACCEPT;
      } );
            }
    }
        }, false
        );
 
        var nodes = [];
         var node;
        while ((node = walker.nextNode())) nodes.push(node);


    function setActive( id ) {
        nodes.forEach(function (textNode) {
      if ( _activeId === id ) return;
            var val = textNode.nodeValue;
      _activeId = id;
            if (!re.test(val)) return;
      clearActive();
            re.lastIndex = 0;
      if ( !id ) return;


      var li = getTocLi( id );
            var frag = document.createDocumentFragment();
      if ( !li ) return;
            var last = 0;
            var m;
            while ((m = re.exec(val)) !== null) {
                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);
        });


      li.classList.add( 'vector-toc-list-item-active' );
        return count;
    }


      // Highlight only the innermost active item
    function showDismissBar(query) {
      var hasActiveChild = !!li.querySelector(
         var existing = document.getElementById('gr-hl-bar');
        '.vector-toc-list-item .vector-toc-list-item-active'
         if (existing) existing.remove();
      );
      if ( !hasActiveChild ) {
         var lnk = li.querySelector( '.vector-toc-link' );
         if ( lnk ) {
          lnk.style.setProperty( 'color',      ACTIVE_COLOR, 'important' );
          lnk.style.setProperty( 'font-weight', '700',        'important' );
          lnk.querySelectorAll( '*' ).forEach( function ( el ) {
            el.style.setProperty( 'color',      ACTIVE_COLOR, 'important' );
            el.style.setProperty( 'font-weight', '700',        'important' );
          } );
        }
      }


      // Expand collapsed ancestors
        var count = document.querySelectorAll('.gr-search-hl').length;
      var anc = li.parentNode;
         if (!count) return;
      while ( anc && anc !== toc ) {
        if ( anc.classList && anc.classList.contains( 'vector-toc-list-item-collapsed' ) ) {
          anc.classList.remove( 'vector-toc-list-item-collapsed' );
         }
        anc = anc.parentNode;
      }


      // Scroll TOC item into view within sidebar
        var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
      var sticky = document.querySelector( '.vector-sticky-pinned-container' );
         var bar = document.createElement('div');
      var scrollEl = sticky || toc;
         bar.id = 'gr-hl-bar';
      if ( scrollEl.scrollHeight > scrollEl.clientHeight ) {
         var lr = li.getBoundingClientRect();
         var cr = scrollEl.getBoundingClientRect();
        if ( lr.top < cr.top + 8 || lr.bottom > cr.bottom - 8 ) {
          scrollEl.scrollTop += lr.top - cr.top - scrollEl.clientHeight / 2 + li.offsetHeight / 2;
        }
      }
    }


    // Track which headings are visible
        if (isMob) {
    var _visible = new Set();
            bar.className = 'gr-search-hl-mobile';


    _headingObserver = new IntersectionObserver( function ( entries ) {
            bar.innerHTML =
      entries.forEach( function ( entry ) {
                '<button id="gr-hl-results" class="gr-search-bar-btn">' +
        if ( entry.isIntersecting ) {
                '<span class="gra-icon gra-icon-search" aria-hidden="true"></span>' +
          _visible.add( entry.target.id );
                '<span>' + count + ' match' + (count === 1 ? '' : 'es') + '</span></button>' +
                '<button id="gr-hl-prev" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-prev" aria-hidden="true"></span>' +
                '<span>Prev</span></button>' +
                '<button id="gr-hl-next" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-next" aria-hidden="true"></span>' +
                '<span>Next</span></button>' +
                '<button id="gr-hl-dismiss" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>' +
                '<span>Close</span></button>';
         } else {
         } else {
          _visible.delete( entry.target.id );
            bar.style.cssText = [
                'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200',
                'background:#b5451b', 'color:#fff', 'padding:10px 16px',
                'display:flex', 'align-items:center', 'justify-content:space-between',
                'font-family:system-ui,sans-serif', 'font-size:14px',
                'box-shadow:0 -2px 8px rgba(0,0,0,0.2)'
            ].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');
            db.id = 'gr-hl-dismiss'; db.textContent = '✕ Close';
            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;';
            bar.appendChild(db);
         }
         }
      } );


      // Pick the topmost visible heading
         document.body.appendChild(bar);
      var topId = null;
      var topY  = Infinity;
      _visible.forEach( function ( id ) {
         var el = document.getElementById( id );
        if ( el ) {
          var y = el.getBoundingClientRect().top;
          if ( y >= 0 && y < topY ) { topY = y; topId = id; }
        }
      } );


      // Nothing visible: find the last heading scrolled past (above viewport)
         var hlEls = Array.from(document.querySelectorAll('.gr-search-hl'));
      if ( !topId ) {
         var current = 0;
         var bestY = -Infinity;
        headings.forEach( function ( h ) {
          var y = h.getBoundingClientRect().top;
          if ( y < 0 && y > bestY ) { bestY = y; topId = h.id; }
         } );
      }


      setActive( topId || null );
        function goTo(idx) {
    }, {
            hlEls.forEach(function (el) { el.classList.remove('gr-search-hl-current'); });
      // Fire when heading enters/leaves the top 30% of the viewport
            current = ((idx % hlEls.length) + hlEls.length) % hlEls.length;
      rootMargin: '-60px 0px -65% 0px',
            hlEls[current].classList.add('gr-search-hl-current');
      threshold: 0
            grScrollTo(hlEls[current]);
    } );
            // NEW: same TOC sync on Prev/Next navigation within the
            // results, not just the initial jump.
            if (window.grHighlightTocForElement) window.grHighlightTocForElement(hlEls[current]);
        }


    headings.forEach( function ( h ) { _headingObserver.observe( h ); } );
        function dismiss() { clearHighlights(); bar.remove(); }
  }


  // ── Init ────────────────────────────────────────────────────────
        var nb = document.getElementById('gr-hl-next');
  function init() {
        var pb = document.getElementById('gr-hl-prev');
    var HIDE_IDS = [
        var rb = document.getElementById('gr-hl-results');
      'vector-appearance',
         var db2 = document.getElementById('gr-hl-dismiss');
      'vector-appearance-pinned-container',
         if (nb) nb.onclick = function () { goTo(current + 1); };
      'vector-appearance-unpinned-container'
        if (pb) pb.onclick = function () { goTo(current - 1); };
    ];
        if (db2) db2.onclick = dismiss;
    function removeHiddenEls() {
         if (rb) rb.onclick = function () {
      HIDE_IDS.forEach( function ( id ) {
            bar.remove(); clearHighlights();
         var el = document.getElementById( id );
            if (window.showSearchDialog) window.showSearchDialog(query);
         if ( el && el.parentNode ) el.parentNode.removeChild( el );
        };
      } );
      var pt = document.getElementById( 'vector-page-tools' ) ||
              document.querySelector( '.vector-page-tools-pinned-container' );
      if ( pt ) {
         pt.querySelectorAll( '[aria-controls="vector-appearance"]' )
          .forEach( function ( el ) { if ( el.parentNode ) el.parentNode.removeChild( el ); } );
      }
     }
     }
    removeHiddenEls();


     ( function detectTeekaMode() {
     function clearHighlights() {
      var tp = document.querySelector( '.gr-teeka-page' );
        document.querySelectorAll('.gr-search-hl').forEach(function (span) {
      if ( !tp ) return;
            var p = span.parentNode; if (!p) return;
      var primary = tp.getAttribute( 'data-primary' ) || '';
            while (span.firstChild) p.insertBefore(span.firstChild, span);
      var artPath = ( window.mw && mw.config.get( 'wgArticlePath' ) ) || '/wiki/$1';
            p.removeChild(span);
      var mainUrl = artPath.replace( '$1', primary );
         });
      var refParam = window.location.search.match( /[?&]ref=([01])/ );
     }
      if ( refParam ) {
         document.body.classList.add( refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone' );
        return;
      }
      var ref = document.referrer || '';
      document.body.classList.add(
        ( ref && primary && ref.indexOf( mainUrl ) !== -1 ) ? 'gr-ref-mode' : 'gr-standalone'
      );
     }() );


     if ( window.MutationObserver ) {
     function escHtml(s) {
      var hideObs = new MutationObserver( function ( mutations ) {
         return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
         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' );
     function injectHighlightCSS() {
    var alreadyTagged = content && content.querySelector( '[data-deva]' );
        if (document.getElementById('gr-hl-css')) return;
    if ( !alreadyTagged ) {
        var s = document.createElement('style');
      translatableSpans = [];
        s.id = 'gr-hl-css';
      tagTextNodes();
        s.textContent = [
    } else {
            '.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);}',
      document.querySelectorAll( '.vector-toc .vector-toc-text:not([data-deva])' ).forEach( function ( span ) {
            '.gr-search-hl-current{background:#ffb300!important;box-shadow:0 0 0 2px #b5451b!important;}',
        var orig = span.textContent;
            '@keyframes gr-hl-pulse{0%{background:#ffb300;}50%{background:#fff176;}100%{background:#fff176;}}',
        if ( !orig.trim() ) return;
            '.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}',
         span.setAttribute( 'data-deva', orig );
         ].join('');
         translatableSpans.push( span );
         document.head.appendChild(s);
      } );
     }
     }


     var saved = ( function () {
     injectHighlightCSS();
      try { return localStorage.getItem( LS_SCRIPT_KEY ); } catch ( e ) { return null; }
    }() );
    if ( saved && saved !== 'deva' ) { applyScript( saved ); }
    else { currentScript = 'deva'; }


     // Vector 2022 defers TOC render — retry at 300ms and 800ms
     if (document.readyState === 'loading') {
     // Use a single short delay so Vector has rendered the TOC
        document.addEventListener('DOMContentLoaded', applyHighlight);
    setTimeout( setupToc, 200 );
     } else {
  }
        applyHighlight();
    }


  // ── React to toolbar script-change events ──────────────────────
    if (window.mw) {
  window.addEventListener( 'gr-script-change', function ( e ) {
        mw.hook('wikipage.content').add(function () {
    var script = e && e.detail && e.detail.script;
            setTimeout(applyHighlight, 100);
    if ( script ) applyScript( script );
        });
  } );
    }


  // ── React to gr-new-content (siteNav panel rendered new items) ──
    window.grStoreSearchHL = storeQueryForLink;
  // Tag any new text nodes added by the documents panel
  window.addEventListener( 'gr-new-content', function ( e ) {
    var container = e && e.detail && e.detail.container;
    if ( !container ) return;
    var walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT );
    var nodes = [];
    while ( walker.nextNode() ) nodes.push( walker.currentNode );
    nodes.forEach( function ( node ) {
      var p = node.parentNode;
      if ( !p || ( p.hasAttribute && p.hasAttribute( 'data-deva' ) ) ) return;
      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 );
    } );
  } );


  // ── React to script changes from OTHER tabs (BroadcastChannel) ──
}());
  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 ) {}


  // ── MediaWiki SPA-style navigation ──────────────────────────────
/* ═══════════════════════════════════════════════════════════════
  if ( window.mw ) {
  Mobile addon — only runs on Minerva (mobile) skin
    mw.hook( 'wikipage.content' ).add( function () {
  ═══════════════════════════════════════════════════════════════ */
      setTimeout( function () {
(function () {
        var content      = document.querySelector( '.mw-parser-output' );
    if (!document.body.classList.contains('skin-minerva')) return;
         var alreadyTagged = content && content.querySelector( '[data-deva]' );
    function injectMenuLinks() {
         if ( !alreadyTagged ) {
        if (document.getElementById('gr-mob-menu-items')) return;
          translatableSpans = [];
        var navDrawer = document.querySelector('.navigation-drawer');
          tagTextNodes();
        if (!navDrawer) return;
         } else {
        var wrap = document.createElement('div'); wrap.id = 'gr-mob-menu-items'; wrap.className = 'gr-mob-menu';
          document.querySelectorAll( '.vector-toc .vector-toc-text:not([data-deva])' ).forEach( function ( span ) {
        function makeItem(href, label) { var a = document.createElement('a'); a.href = href; a.className = 'gr-mob-item'; a.textContent = label; return a; }
             var orig = span.textContent;
        wrap.appendChild(makeItem('/Main_Page', 'Home'));
             if ( !orig.trim() ) return;
        wrap.appendChild(makeItem('/My_wiki:Help', 'Help'));
             span.setAttribute( 'data-deva', orig );
        wrap.appendChild(makeItem('/My_wiki:About', 'About'));
             translatableSpans.push( span );
         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);
         }
         }
         if ( currentScript !== 'deva' ) applyScript( currentScript );
        else navDrawer.appendChild(wrap);
         setupToc();
    }
      }, 150 );
    function injectMoolaUllekhaLinks() {
     } );
        if (document.getElementById('gr-mob-doc-nav')) return;
  }
        var pageName = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
         if (pageName === 'Main_Page' || !pageName) return;
        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, '/')); }
        var teekaPage = document.querySelector('.gr-teeka-page');
        var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || pageName.split('/')[0]) : pageName.split('/')[0];
        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 showMoolam = !!teekaPage || hasMoolaPage, showUllekha = hasUllekhaPage || !!teekaPage;
        if (!showMoolam && !showUllekha) return;
        var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
        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');
}());


  if ( document.readyState === 'loading' ) {
(function () {
     document.addEventListener( 'DOMContentLoaded', init );
    'use strict';
  } else {
    if (mw.config.get('wgPageName') !== 'Main_Page') return;
     init();
    mw.loader.using('mediawiki.util').done(function () {
  }
        $(function () { applyHomeToggleOffset(); window.addEventListener('resize', applyHomeToggleOffset, { passive: true }); setTimeout(applyHomeToggleOffset, 300); setTimeout(applyHomeToggleOffset, 800); });
    });
    function applyHomeToggleOffset() {
        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 () {


}() );   /* ← end of main IIFE */
    var LS_TEEKA_KEY = 'grantha_reader_teeka_in_moola';


    // Distinct light background per commentary, for when multiple
    // different teekas are interleaved together and need to stay
    // visually distinguishable. CHANGED: was a 30-color palette
    // transcribed from a screenshot - confirmed directly that at least
    // one entry (#F6C500, a saturated gold) wasn't actually light at
    // all, which is very likely what read as "really dark" in practice.
    // Replaced with 10 colors in the site's own warm palette (matching
    // 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];
    }


// ── Inject "Help" and "About" links into the header ─────────────────
    function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
( function () {
     function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
  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 );
  }


  var linkStyle = [
    function fetchAllSubpages(rootTitle) {
    'color:rgba(255,255,255,0.88)', 'font-size:0.88em',
        var deferred = $.Deferred(), subpages = [], nsNum = mw.config.get('wgNamespaceNumber'), localName = mw.config.get('wgTitle');
    'font-family:system-ui,sans-serif', 'font-weight:500',
        function ask(apfrom) {
    'text-decoration:none', 'padding:4px 10px', 'border-radius:4px',
            var params = { action: 'query', list: 'allpages', apprefix: localName + '/', apnamespace: nsNum, aplimit: 'max', format: 'json' };
    'margin-right:4px', 'transition:color 0.15s,background 0.15s',
            if (apfrom) params.apcontinue = apfrom;
    'white-space:nowrap',
            $.ajax({ url: mw.util.wikiScript('api'), data: params, dataType: 'json' }).then(function (data) {
  ].join( ';' );
                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();
    }


  function makeHeaderLink( id, href, label ) {
    // One request for ALL of them, not one each. The API takes up to 50
    var a = document.createElement( 'a' );
     // titles at a time, and a moola page now shows every commentary by
     a.id = id; a.href = href; a.textContent = label;
     // default - so what used to be one round trip became five or ten,
     a.style.cssText = linkStyle;
     // fired together but each with its own latency, and the commentaries
     a.addEventListener( 'mouseover', function () {
    // appeared well after the text. Batching makes it a single request
      this.style.color = '#fff'; this.style.background = 'rgba(255,255,255,0.12)';
     // whatever the page carries. maxage/smaxage let the browser and the
     } );
     // CDN reuse the response, so moving between chapters of one grantha
     a.addEventListener( 'mouseout', function () {
    // does not re-fetch the same commentaries.
      this.style.color = 'rgba(255,255,255,0.88)'; this.style.background = 'transparent';
     var teekaJsonCache = {};
     } );
    return a;
  }


  function injectHeaderLinks() {
    function fetchTeekaJsons(titles) {
    if ( document.getElementById( 'gr-about-link' ) ) return;
        var want = titles.filter(function (t) { return !(t in teekaJsonCache); });
        if (!want.length) return $.Deferred().resolve(teekaJsonCache).promise();


    var headerEnd = document.querySelector( '.vector-header-end' ) ||
        var batches = [];
                    document.querySelector( '#vector-user-links' ) ||
        for (var i = 0; i < want.length; i += 40) batches.push(want.slice(i, i + 40));
                    document.querySelector( '.mw-header' );
    if ( !headerEnd ) return;


    var helpLink  = makeHeaderLink( 'gr-help-link', wikiHref( 'My_wiki:Help' ), 'Help' );
        return $.when.apply($, batches.map(function (batch) {
    var aboutLink = makeHeaderLink( 'gr-about-link', wikiHref( 'My_wiki:About' ), 'About' );
            return $.ajax({
                url: mw.util.wikiScript('api'),
                data: {
                    action: 'query',
                    titles: batch.map(function (t) { return 'JSON:' + t; }).join('|'),
                    prop: 'revisions', rvslots: 'main', rvprop: 'content',
                    format: 'json', formatversion: 2,
                    maxage: 300, smaxage: 300
                },
                dataType: 'json'
            }).then(function (data) {
                var pages = (data.query && data.query.pages) || [];
                pages.forEach(function (page) {
                    // Match the response back to the title we asked for:
                    // the API may normalise or reorder, so never pair by
                    // index. Strip the namespace the same way it was added.
                    var title = (page.title || '').replace(/^JSON:/, '');
                    if (page.missing) { teekaJsonCache[title] = null; return; }
                    var content = page.revisions && page.revisions[0] &&
                        page.revisions[0].slots && page.revisions[0].slots.main &&
                        page.revisions[0].slots.main.content;
                    if (!content) { teekaJsonCache[title] = null; return; }
                    try {
                        var parsed = JSON.parse(content);
                        parsed._title = title;
                        teekaJsonCache[title] = parsed;
                    } catch (e) { teekaJsonCache[title] = null; }
                });
            }).catch(function () {
                batch.forEach(function (t) { if (!(t in teekaJsonCache)) teekaJsonCache[t] = null; });
            });
        })).then(function () {
            // Anything the response never mentioned is recorded as absent,
            // so a later call does not ask for it again.
            want.forEach(function (t) { if (!(t in teekaJsonCache)) teekaJsonCache[t] = null; });
            return teekaJsonCache;
        });
    }


     var ul = document.querySelector( '.vector-user-links' ) ||
     // Finds this document's commentary subpages - filters to the site's
            document.querySelector( '#pt-userpage' );
    // own established commentary-path convention (Vyakhya/Bhashya are
    if ( ul && ul.parentNode === headerEnd ) {
    // shown; Moola/Ullekha are never commentary content).
      headerEnd.insertBefore( aboutLink, ul );
    function findCommentarySubpages(rootTitle) {
      headerEnd.insertBefore( helpLink,  aboutLink );
        return $.Deferred(function (d) {
    } else {
            fetchAllSubpages(rootTitle).then(function (all) {
      headerEnd.appendChild( helpLink );
                d.resolve(all.filter(function (t) {
      headerEnd.appendChild( aboutLink );
                    return t !== rootTitle && (/\/vyakhya(\/|$)/i.test(t) || /\/bhashya(\/|$)/i.test(t));
                }));
            });
        }).promise();
     }
     }
  }


  if ( document.readyState === 'loading' ) document.addEventListener( 'DOMContentLoaded', injectHeaderLinks );
    // Builds moola-verse-id -> [{teekaTitle, teekaSlug, text, blockId}].
  else injectHeaderLinks();
    // 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,
                    teekaAuthor: td.doc.author || '',
                    teekaPage: td.doc.pageTitle || td._title,
                    teekaGranthaId: td.doc.granthaId || '',
                    teekaSlug: slug,
                    // join('\n') - each entry of `lines` is a printed line,
                    // and joining with a space ran a two-pada shloka into
                    // one. The .shloka-line markup inside already carries
                    // the break where the converter put one; this only
                    // matters for older documents that have none.
                    text: (b.lines || []).join('\n'),
                    footnotes: b.footnotes || [],
                    blockId: b.id
                });
            });
        });
        return index;
    }


    function injectTeekaInMoola(index) {
        var injectedAny = false;
        Object.keys(index).forEach(function (verseId) {
            var $verseEl = $(document.getElementById(verseId));
            if (!$verseEl.length) return;
            var entries = index[verseId];
            var $wrap = $('<div>').addClass('gr-injected-teeka-group');
            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)
                    // Citation metadata: a bookmark taken inside an
                    // interleaved commentary must cite THAT work and its
                    // author, and link to the commentary's own page - not
                    // to the moola whose page it happens to be shown on.
                    .attr('data-cite-author', entry.teekaAuthor || '')
                    .attr('data-cite-title', entry.teekaTitle || '')
                    .attr('data-cite-page', entry.teekaPage || '')
                    .attr('data-cite-grantha-id', entry.teekaGranthaId || '')
                    .attr('id', entry.blockId)
                    .css('background', color);
                if (window.grLibrary) {
                    (function (el, gid, page) {
                        // The id may come from the teeka's own JSON (if that
                        // document has been rebuilt) or from the map page.
                        // Either way the commentary cites ITS work, not the
                        // moola it is displayed inside.
                        var got = gid ? Promise.resolve(gid)
                                      : window.grLibrary.idForPage(page, null);
                        got.then(function (id) {
                            if (!id) return null;
                            el.setAttribute('data-cite-grantha-id', id);
                            return window.grLibrary.lookup(id);
                        }).then(function (g) {
                            if (!g) return;
                            if (g.name)      el.setAttribute('data-cite-title', g.name);
                            if (g.nameIAST)  el.setAttribute('data-cite-title-iast', g.nameIAST);
                            if (g.author)    el.setAttribute('data-cite-author', g.author);
                            if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST);
                            var h = el.querySelector('.teeka-title');
                            var label = teekaLabel(g);
                            if (h && label) h.textContent = label;
                            var slug = el.getAttribute('data-teeka-slug');
                            if (slug && label) {
                                labelBySlug[slug] = label;
                                // A footnote section for this commentary may
                                // already be on screen; rename it too.
                                document.querySelectorAll(
                                    '.gr-teeka-notes-group[data-teeka-slug="' + slug +
                                    '"] .gr-teeka-notes-title').forEach(function (t) {
                                        t.textContent = label;
                                    });
                            }
                        });
                    }($entry[0], entry.teekaGranthaId, entry.teekaPage));
                }
                $('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry);
                $('<div>').addClass('teeka-body').html(entry.text).appendTo($entry);
                // A teeka's footnotes live in ITS document, not in this
                // page's own footnote list - so the highlighted words came
                // through the injection but nothing on this page knew what
                // they pointed at, and clicking them did nothing. Register
                // them here, under the commentary's title, for the note
                // card and the right-hand panel to read.
                if (entry.footnotes && entry.footnotes.length && window.grFootnoteRegistry) {
                    window.grFootnoteRegistry.add(entry.teekaSlug, entry.teekaTitle, entry.footnotes);
                }
                $wrap.append($entry);
            });
            // After the bhashya, not between the verse and it. The moola
            // page prints verse then bhashya as one unit, and inserting
            // straight after the verse element split them apart, putting
            // the commentaries ahead of the bhashya they comment on.
            // The bhashya-collection for a verse carries id
            // "bhashya-<verse oldKey>"; where a page has none, the verse
            // itself is still the anchor.
            var anchor = $verseEl;
            var coll = document.getElementById('bhashya-' + verseId);
            if (coll && coll.compareDocumentPosition($verseEl[0]) & Node.DOCUMENT_POSITION_PRECEDING) {
                anchor = $(coll);
            } else {
                // Some pages emit the bhashya as a sibling block without
                // the collection wrapper - walk forward over any blocks
                // that belong to this same verse.
                var n = $verseEl[0].nextElementSibling;
                while (n && n.getAttribute &&
                      (n.getAttribute('data-parent-verse') === verseId ||
                        n.id === 'bhashya-' + verseId)) {
                    anchor = $(n);
                    n = n.nextElementSibling;
                }
            }
            anchor.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();
        if (window.grFootnoteRegistry) window.grFootnoteRegistry.clearInjected();
    }


// ── Main page: by-Grantha / by-Author toggle ──────────────────────
    // CRITICAL FIX: confirmed directly this feature was corrupting saved
( function () {
    // page content. QuickEdit is a contenteditable-based editor operating
  function grHomeView( v ) {
    // on the page's LIVE DOM - since this feature injects real elements
    var gView = document.getElementById( 'gr-view-grantha' );
    // (interleaved teeka blocks, inline checkboxes wrapping links) into
    var aView = document.getElementById( 'gr-view-author' );
    // that same DOM, QuickEdit's save/serialization pipeline had no way
    var gBtn  = document.getElementById( 'gr-toggle-grantha' );
    // to distinguish them from genuine content, and was saving them back
    var aBtn  = document.getElementById( 'gr-toggle-author' );
    // into the page's actual wikitext as literal escaped HTML text. This
    if ( !gView || !aView || !gBtn || !aBtn ) return;
    // teardown strips every trace of this feature's DOM changes -
    gView.style.display = ( v === 'grantha' ) ? '' : 'none';
    // removing injected teeka blocks entirely, and for inline checkboxes,
    aView.style.display = ( v === 'author'  ) ? '' : 'none';
    // specifically UNWRAPPING each link back to its own original
    gBtn.className = 'gr-toggle-btn' + ( v === 'grantha' ? ' gr-toggle-active' : '' );
    // position (not just deleting the wrapper, which would also delete
    aBtn.className = 'gr-toggle-btn' + ( v === 'author'  ? ' gr-toggle-active' : '' );
    // the link itself) - restoring the DOM to exactly what the page's
    try { localStorage.setItem( 'gr_home_view', v ); } catch ( e ) {}
    // 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');
        });
    }


  function initHomeToggle() {
    // Watches for the exact DOM signal that editing has actually begun -
     var gBtn = document.getElementById( 'gr-toggle-grantha' );
    // Common.css targets .mw-body-content[contenteditable="true"]
    var aBtn = document.getElementById( 'gr-toggle-author' );
    // directly, confirming this is the real, load-order-independent
    if ( !gBtn || !aBtn ) return;
    // trigger QuickEdit itself relies on, rather than trying to
    gBtn.addEventListener( 'click', function () { grHomeView( 'grantha' ); } );
    // intercept every possible UI path that could invoke it (edit
    aBtn.addEventListener( 'click', function () { grHomeView( 'author' ); } );
    // button, keyboard shortcut, or anything else) individually and
    [ gBtn, aBtn ].forEach( function ( btn ) {
     // risk missing one.
      btn.addEventListener( 'keydown', function ( e ) {
    function watchForEditMode() {
        if ( e.key === 'Enter' || e.key === ' ' ) btn.click();
        var target = document.querySelector('.mw-body-content') || document.body;
      } );
        if (!target || !window.MutationObserver) return;
    } );
        var wasEditing = false;
    var saved;
        var obs = new MutationObserver(function (mutations) {
    try { saved = localStorage.getItem( 'gr_home_view' ); } catch ( e ) {}
            mutations.forEach(function (m) {
    if ( saved === 'author' ) grHomeView( 'author' );
                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 });
    }


  if ( document.readyState === 'loading' ) document.addEventListener( 'DOMContentLoaded', initHomeToggle );
    function normalizeWikiPath(p) {
  else initHomeToggle();
        try { p = decodeURIComponent(p); } catch (e) { }
}() );
        return p.replace(/_/g, ' ').replace(/\/+$/, '');
    }


    // ── State ──────────────────────────────────────────────────────
    // Not seeded from storage: every page load starts with all
    // commentaries on (see where availableList is filled). Unchecking one
    // still writes to storage, which keeps the control's own state
    // consistent within the page and leaves the value there for anything
    // else that reads it, but it is not what decides the initial view.
    var selectedTeekaSlugs = {};
    var teekaIndex = null;
    var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
    var lastAugmentArgs = null; // {titles, docs} - cached to restore inline checkboxes after a cancelled edit without re-fetching
    var checkboxesBySlug = {};
    var readyCallbacks = [];
    var changeCallbacks = [];
    var visibilityCallbacks = [];
    var isReady = false;
    var inlineListVisible = null; // null = no inline checkboxes exist at all on this page


// ── Ullekha reference link handler ─────────────────────────────────
    function registerCheckbox(slug, $cb) { (checkboxesBySlug[slug] = checkboxesBySlug[slug] || []).push($cb); }
( function () {


  function highlightOnArrival() {
    function renderInterleave() {
    var search = window.location.search;
        removeTeekaInMoola();
    if ( !search ) return;
        if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return;
    var m = search.match( /[?&]hlUllekha=([^&]+)/ );
        var filtered = {};
    if ( !m ) return;
        Object.keys(teekaIndex).forEach(function (verseId) {
    var needle;
            var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; });
    try { needle = decodeURIComponent( m[ 1 ] ); } catch ( e ) { return; }
            if (entries.length) filtered[verseId] = entries;
    if ( !needle || needle.length < 4 ) return;
        });
        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.');
    }


     // Wait for tagTextNodes() to finish wrapping text in data-deva spans,
     function toggleSlug(slug, checked) {
    // then search those spans rather than raw text nodes.
        if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug];
    function doHighlight() {
        writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs)));
      var content = document.querySelector( '.mw-parser-output' );
        (checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); });
      if ( !content ) return;
        renderInterleave();
        changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); });
    }


      var snippet = needle.slice( 0, 40 );
    // Augments existing <a> links pointing to a known commentary subpage
      var found   = false;
    // 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;
    }


      // Search data-deva spans first (post-tagTextNodes state)
    // Tracks whether ANY inline checkbox is currently on-screen, and
      var spans = content.querySelectorAll( '[data-deva]' );
    // notifies subscribers only when that overall state actually flips -
      for ( var i = 0; i < spans.length && !found; i++ ) {
    // verified in isolation before wiring in here. Drives whether
        var spanEl = spans[ i ];
    // readerToolbar.js's "Commentaries" control needs to be shown as a
        var orig  = spanEl.getAttribute( 'data-deva' ) || '';
    // fallback (inline list scrolled out of view) or can stay hidden
         if ( orig.indexOf( snippet ) === -1 ) continue;
    // (reader can already see and use the inline checkboxes).
    function observeInlineVisibility(checkboxEls) {
        if (!checkboxEls.length || !window.IntersectionObserver) return;
        var visibleSet = new Set();
        var obs = new IntersectionObserver(function (entries) {
            entries.forEach(function (entry) {
                var id = entry.target.__grTeekaId;
                if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id);
            });
            var anyVisible = visibleSet.size > 0;
            if (anyVisible !== inlineListVisible) {
                inlineListVisible = anyVisible;
                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); });
    }


         // Replace the span with: text-before + <mark> + text-after
    function init() {
         var idx    = orig.indexOf( snippet );
         // Runs unconditionally, before any of the early-return checks
         var hlText = orig.slice( idx, Math.min( idx + needle.length, orig.length ) );
        // below - harmless as a no-op teardown on pages where nothing
         var mark  = document.createElement( 'mark' );
         // ever gets injected, but critical to have active on every page
         mark.className  = 'gr-ullekha-highlight';
        // this feature COULD have injected into, regardless of which
         mark.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
        // exact code path led here.
         mark.textContent  = hlText;
        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 parent = spanEl.parentNode;
         var rootTitle = mw.config.get('wgTitle');
         if ( !parent ) continue;
         findCommentarySubpages(rootTitle).then(function (subpages) {
            if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
            fetchTeekaJsons(subpages).then(function (cache) {
                var fetched = subpages.map(function (t) { return cache[t] || null; });
                // Pair title+doc together FIRST, then filter as one unit -
                // filtering `docs` separately from `subpages` and passing
                // both (now misaligned) arrays elsewhere was a confirmed
                // real bug in an earlier version of this feature.
                var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; })
                    .filter(function (p) {
                        if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title);
                        return !!p.doc;
                    });


        var before = document.createTextNode( orig.slice( 0, idx ) );
                // Reading order = the order the book prints the
        var after  = document.createTextNode( orig.slice( idx + hlText.length ) );
                // commentaries in, which a document states as doc.order.
        parent.insertBefore( before, spanEl );
                // findCommentarySubpages gets its list from
        parent.insertBefore( mark,  spanEl );
                // action=query&list=allpages, and that is ALPHABETICAL BY
        parent.insertBefore( after,  spanEl );
                // TITLE - so Vadiraja came before Vyasatirtha, and the
        parent.removeChild( spanEl );
                // checkbox list, the colour slots and the interleaved
                // blocks all inherited that same wrong order, since every
                // one of them is derived from this array. Sorting here
                // fixes all three at once. A document with no doc.order
                // (anything published before this field existed) keeps its
                // existing relative position rather than jumping to the
                // front, so mixed pages stay stable.
                pairs = pairs.map(function (p, i) { return { p: p, i: i }; })
                    .sort(function (a, b) {
                        var ao = a.p.doc && a.p.doc.doc && a.p.doc.doc.order;
                        var bo = b.p.doc && b.p.doc.doc && b.p.doc.doc.order;
                        if (typeof ao === 'number' && typeof bo === 'number' && ao !== bo) return ao - bo;
                        if (typeof ao === 'number' && typeof bo !== 'number') return -1;
                        if (typeof bo === 'number' && typeof ao !== 'number') return 1;
                        return a.i - b.i;
                    })
                    .map(function (x) { return x.p; });


        setTimeout( function () {
                var topDocs = pairs.map(function (p) { return p.doc; });
          mark.scrollIntoView( { behavior: 'smooth', block: 'center' } );
        }, 100 );
        found = true;
      }


      // Fallback: raw text nodes (before tagTextNodes runs)
                // NEW: sub-commentaries (tippanis) - a teeka doc declares
      if ( !found ) {
                // its own children via doc.related (an array of slugs,
        var walker = document.createTreeWalker( content, NodeFilter.SHOW_TEXT );
                // matching the same field this session's own generator
        while ( walker.nextNode() && !found ) {
                // already writes into new JSON docs). Only the ASK here
          var node = walker.currentNode;
                // is to show these as sub-categories in the options list
          var txt  = node.textContent || '';
                // and gate their visibility on the parent being checked
          if ( txt.indexOf( snippet ) === -1 ) continue;
                // - so this only needs one more level of fetching, not
          var idx2  = txt.indexOf( snippet );
                // unbounded recursion.
          var mark2  = document.createElement( 'mark' );
                var relatedSlugs = [];
          mark2.className    = 'gr-ullekha-highlight';
                topDocs.forEach(function (d) { (d.doc.related || []).forEach(function (s) { relatedSlugs.push(s); }); });
          mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
                var uniqueRelated = relatedSlugs.filter(function (s, i) { return relatedSlugs.indexOf(s) === i; });
          mark2.textContent  = txt.slice( idx2, Math.min( idx2 + needle.length, txt.length ) );
          var p = node.parentNode;
          p.insertBefore( document.createTextNode( txt.slice( 0, idx2 ) ), node );
          p.insertBefore( mark2, node );
          p.insertBefore( document.createTextNode( txt.slice( idx2 + mark2.textContent.length ) ), node );
          p.removeChild( node );
          setTimeout( function () {
            mark2.scrollIntoView( { behavior: 'smooth', block: 'center' } );
          }, 100 );
          found = true;
        }
      }
    }


    // Delay to let tagTextNodes() and MW rendering finish
                function finishInit(subDocsBySlug) {
    setTimeout( doHighlight, 600 );
                    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.
                    // Every commentary is on at the start of EVERY page
                    // load, not only a reader's first visit. The moola is
                    // published together with its commentaries and the
                    // complete text is what a reader should land on; the
                    // checkboxes are for narrowing it down while reading,
                    // and that narrowing lasts for as long as the page is
                    // open rather than following the reader to the next one.
                    //
                    // This MUST come before augmentExistingTeekaLinks:
                    // that builds each inline checkbox with
                    // .prop('checked', !!selectedTeekaSlugs[slug]), reading
                    // the selection as it stands at that moment. Selecting
                    // afterwards left every box drawn unchecked while the
                    // commentaries themselves were shown - the state and
                    // the control disagreeing on screen.
                    availableList.forEach(function (it) {
                        selectedTeekaSlugs[it.slug] = true;
                        (it.subItems || []).forEach(function (sub) { selectedTeekaSlugs[sub.slug] = true; });
                    });


  function wireUllekhaLinks() {
                    lastAugmentArgs = { titles: pairs.map(function (p) { return p.title; }), docs: topDocs };
    document.querySelectorAll( '.gr-ullekha-ref-link' ).forEach( function ( wrap ) {
                    var augmented = augmentExistingTeekaLinks(lastAugmentArgs.titles, lastAugmentArgs.docs);
      var anchor = wrap.getAttribute( 'data-anchor' ) || '';
                    if (augmented.length) observeInlineVisibility(augmented.map(function (a) { return a.el; }));
      var hl    = wrap.getAttribute( 'data-hl' )     || '';
                    else inlineListVisible = false; // no inline checkboxes exist - toolbar control should always show as the only access point
      var a = wrap.querySelector( 'a' );
                    if (Object.keys(selectedTeekaSlugs).length) renderInterleave();
      if ( !a ) return;
                    isReady = true;
                    readyCallbacks.forEach(function (cb) { cb(availableList); });
                    if (inlineListVisible !== null) visibilityCallbacks.forEach(function (cb) { cb(inlineListVisible); });
                }


      var base = a.href.split( '#' )[ 0 ];
                if (!uniqueRelated.length) { finishInit({}); return; }
      var encoded = encodeURIComponent( hl );
                fetchTeekaJsons(uniqueRelated).then(function (cache) {
      a.href = base + ( hl ? '?hlUllekha=' + encoded : '' )
                    var subDocsBySlug = {};
                     + ( anchor ? '#' + anchor : '' );
                    uniqueRelated.forEach(function (slug) {
    } );
                        if (!cache[slug]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
  }
                        subDocsBySlug[slug] = cache[slug] || null;
                    });
                     finishInit(subDocsBySlug);
                });
            });
        });
    }


  if ( document.readyState === 'loading' ) {
    window.grTeekaInterleave = {
    document.addEventListener( 'DOMContentLoaded', function () {
        teardown: teardownTeekaInterleave,
      highlightOnArrival();
        onReady: function (cb) { if (isReady) cb(availableList); else readyCallbacks.push(cb); },
      wireUllekhaLinks();
        onChange: function (cb) { changeCallbacks.push(cb); },
    } );
        onVisibilityChange: function (cb) { visibilityCallbacks.push(cb); if (inlineListVisible !== null) cb(inlineListVisible); },
  } else {
        getSelected: function () { return Object.keys(selectedTeekaSlugs); },
    highlightOnArrival();
        toggle: function (slug, checked) { toggleSlug(slug, checked); },
    wireUllekhaLinks();
        registerCheckbox: registerCheckbox
  }
    };
}() );/* ── Search result highlight — highlight query term on arrival ──
* When user clicks a search result, we pass the query in the URL
* hash as #gr-search:QUERY or read it from sessionStorage.
* On page load we find all matching text nodes and wrap them.
* ─────────────────────────────────────────────────────────────── */
( function () {


  /* ── Step 1: When leaving via a search result link,
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    store the query in sessionStorage ── */
     else init();
  function storeQueryForLink( url, query ) {
    try {
      /* Store just the pathname so protocol/host differences don't break matching */
      var a = document.createElement( 'a' );
      a.href = url;
      sessionStorage.setItem( 'gr_search_hl', JSON.stringify({
        query:    query,
        pathname: a.pathname  // e.g. "/Brahmasutra"
      }) );
     } catch(e) {}
  }


  /* ── Step 2: On page load, check if we arrived from a search result ── */
}());
  function applyHighlight() {
    var stored;
    try {
      stored = JSON.parse( sessionStorage.getItem( 'gr_search_hl' ) || 'null' );
    } catch(e) { return; }
    if ( !stored || !stored.query ) return;


    /* Check pathname matches — lenient comparison */
    var currentPath = window.location.pathname;
    var storedPath  = stored.pathname || '';
    /* Normalize: remove trailing slash, decode */
    function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); }
    if ( storedPath && normPath(storedPath) !== normPath(currentPath) ) {
      /* Different page — clear and bail */
      try { sessionStorage.removeItem( 'gr_search_hl' ); } catch(e) {}
      return;
    }


    var query = stored.query.trim();
/* ═══════════════════════════════════════════════════════════════
    if ( !query ) return;
  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.


    /* Clear so refreshing doesn't re-highlight */
  Used by:
    try { sessionStorage.removeItem( 'gr_search_hl' ); } catch(e) {}
    • 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.


    /* Wait for content + Common.js tagTextNodes to finish rendering */
  The catalogue is cached in localStorage for CACHE_HOURS and refreshed
    var delays = [400, 900, 1500];
  in the background, so lookups are synchronous on almost every view.
    delays.forEach( function(ms) {
  ═══════════════════════════════════════════════════════════════ */
      setTimeout( function () {
(function () {
        /* Only run if not already highlighted */
     'use strict';
        if ( !document.querySelector( '.gr-search-hl' ) ) {
          highlightText( query );
        }
      }, ms );
     } );
  }


  /* ── Core: walk text nodes and wrap matches ── */
    var CATALOGUE_PAGE = 'Main_Page';
  function highlightText( query ) {
     var CACHE_KEY = 'gr_titles_v1';
     var content = document.querySelector( '#mw-content-text .mw-parser-output' );
     var CACHE_HOURS = 1;
     if ( !content ) return;
    var DEVA = /[ऀ-ॿ]/;


     /* Normalise query — strip quotes, split on spaces */
    var full = {};     // "Katha/Vyakhya/VyasatirthaVivarana" → "श्रीव्यासतीर्थविरचितविवरणम्"
     var raw = query.replace( /^"|"$/g, '' ).trim();
     var leaf = {};    // "VyasatirthaVivarana" → same; null when two pages share a leaf
     if ( !raw ) return;
    var waiting = [];
     var loaded = false;


     /* Build regex — escape special chars, match whole query first,
     function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
      fall back to individual words */
     function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }
    var patterns = [];
     function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
     /* Full phrase */
     function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
     patterns.push( escapeRegex( raw ) );
    function script(name) {
     /* Individual words (min 2 chars) */
        if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
    raw.split( /\s+/ ).forEach( function(w) {
        return (cfg('wgScriptPath') || '') + '/' + name + '.php';
      if ( w.length >= 2 ) patterns.push( escapeRegex( w ) );
     }
     } );


     var matched = false;
     function add(title, label, override) {
    for ( var pi = 0; pi < patterns.length; pi++ ) {
        title = norm(title); label = String(label || '').replace(/<[^>]*>/g, '').trim();
      var re;
        if (!title || !label || !DEVA.test(label)) return;
      try { re = new RegExp( '(' + patterns[pi] + ')', 'gi' ); }
        if (override || !full[title]) full[title] = label;
      catch(e) { continue; }
        var l = title.split('/').pop();
 
        if (!(l in leaf) || override) leaf[l] = label;
      var count = wrapMatches( content, re );
        else if (leaf[l] !== label) leaf[l] = null;   // ambiguous leaf: don't guess
      if ( count > 0 ) { matched = true; break; }
    }
    function parseCatalogue(txt) {
        var out = {}, re = /\[\[([^\]|#]+)\|([^\]]+)\]\]/g, m;
        while ((m = re.exec(txt || ''))) {
            var k = norm(m[1]);
            if (!out[k]) out[k] = m[2].replace(/<[^>]*>/g, '').trim();
        }
        return out;
    }
    function useCatalogue(map) {
        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);
     }
     }


     if ( !matched ) return;
     // ── public API ────────────────────────────────────────────────
    function get(title) { return full[norm(title)] || null; }
    function getLeaf(seg) { var v = leaf[norm(seg)]; return v || null; }
    function whenReady(cb) { if (loaded) cb(); else waiting.push(cb); }
    function all() { var o = {}; for (var k in full) o[k] = full[k]; return o; }
    window.grTitles = { get: get, leaf: getLeaf, all: all, whenReady: whenReady, parse: parseCatalogue };


     /* Scroll to first highlight */
     function finish() {
    var first = document.querySelector( '.gr-search-hl' );
        loaded = true;
    if ( first ) {
        var cbs = waiting; waiting = [];
      first.scrollIntoView({ behavior: 'smooth', block: 'center' });
        cbs.forEach(function (cb) { try { cb(); } catch (e) { } });
      /* Pulse animation */
        relabel();
      first.classList.add( 'gr-search-hl-pulse' );
        if (window.mw && mw.hook) mw.hook('gr.titles').fire(window.grTitles);
      setTimeout( function() {
        first.classList.remove( 'gr-search-hl-pulse' );
      }, 2000 );
     }
     }


     /* Show dismiss button */
     // ── relabel what's on screen ──────────────────────────────────
     showDismissBar( query );
     function articleTitleFromHref(href) {
  }
        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) { }
        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);
    }


  function escapeRegex( s ) {
    // ── load ─────────────────────────────────────────────────────
    return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
    function fetchFresh() {
  }
        return fetch(script('index') + '?title=' + CATALOGUE_PAGE + '&action=raw', { credentials: 'same-origin' })
            .then(function (r) { return r.ok ? r.text() : Promise.reject(); })
            .then(function (txt) {
                var map = parseCatalogue(txt);
                lsSet(CACHE_KEY, JSON.stringify({ at: Date.now(), map: map }));
                return map;
            });
    }
    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); }
    });
}());


  function wrapMatches( root, re ) {
    var count = 0;
    var walker = document.createTreeWalker(
      root, NodeFilter.SHOW_TEXT, {
        acceptNode: function( node ) {
          /* Skip inside script, style, our own highlights */
          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 = [];
/* ═══════════════════════════════════════════════════════════════
    var node;
  Header links "Library" and "Updates", and the Updates page
    while ( ( node = walker.nextNode() ) ) nodes.push( node );
  ───────────────────────────────────────────────────────────────
  Desktop (Vector): "Library" and "Updates" added before "Help".
  Mobile (Minerva): the same two items in the menu panel, after "Home".
  "Updates" opens the page My_wiki:Updates, which has two parts:


    nodes.forEach( function( textNode ) {
  1. What's new — filled in automatically from the wiki's edit history
       var val = textNode.nodeValue;
       into <div id="gr-updates-feed">. Nothing to maintain by hand:
       if ( !re.test( val ) ) return;
        • New      a page created in the last WINDOW_DAYS days
       re.lastIndex = 0;
        • 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.


      var frag = document.createDocumentFragment();
  Styling: MediaWiki:Common.css §37 (no inline styles here).
      var last = 0;
  ═══════════════════════════════════════════════════════════════ */
      var m;
(function () {
      while ( ( m = re.exec( val ) ) !== null ) {
    'use strict';
        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;
     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/पदार्थदीपिका']


  /* ── Dismiss bar ── */
     var SEEN_KEY = 'gr_updates_seen';
  function showDismissBar( query ) {
     var CACHE_KEY = 'gr_updates_cache_v3';
     var isMob = window.innerWidth < 768;
     var isMobile = document.body.classList.contains('skin-minerva');
     var bar = document.createElement( 'div' );
     bar.id = 'gr-hl-bar';


     /* On mobile: float as a pill near top to avoid bottom bar conflicts.
     // ── helpers ────────────────────────────────────────────────
      On desktop: sit at bottom. */
     function cfg(k) { return (window.mw && mw.config && mw.config.get(k)) || null; }
     if ( isMob ) {
    function wikiHref(title) {
      bar.style.cssText = [
         if (window.mw && mw.util && mw.util.getUrl) return mw.util.getUrl(title);
        'position:fixed',
        var ap = cfg('wgArticlePath') || '/wiki/$1';
         'top:calc(var(--gr-header-height,56px) + var(--gr-toc-top,52px) + 8px)',
         return ap.replace('$1', encodeURIComponent(title.replace(/ /g, '_')).replace(/%2F/g, '/').replace(/%3A/g, ':'));
         'left:50%', 'transform:translateX(-50%)',
    }
        'z-index:10200',
    function script(name) {
        'background:#b5451b', 'color:#fff',
         if (window.mw && mw.util && mw.util.wikiScript) return mw.util.wikiScript(name);
         'padding:8px 14px',
         return (cfg('wgScriptPath') || '') + '/' + name + '.php';
         'border-radius:24px',
    }
        'display:flex', 'align-items:center', 'gap:8px',
    function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
        'font-family:system-ui,sans-serif', 'font-size:13px',
    function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
        'box-shadow:0 3px 12px rgba(0,0,0,0.25)',
    function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
        'white-space:nowrap',
     function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
        'max-width:calc(100vw - 32px)'
    function el(tag, cls, text) {
      ].join(';');
         var n = document.createElement(tag);
     } else {
         if (cls) n.className = cls;
      bar.style.cssText = [
         if (text != null) n.textContent = text;
        'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200',
        return n;
        'background:#b5451b', 'color:#fff', 'padding:10px 16px',
         'display:flex', 'align-items:center', 'justify-content:space-between',
         'font-family:system-ui,sans-serif', 'font-size:14px',
         'box-shadow:0 -2px 8px rgba(0,0,0,0.2)'
      ].join(';');
     }
     }
    function norm(t) { return String(t || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); }


     var count = document.querySelectorAll( '.gr-search-hl' ).length;
     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);
    }


     if ( isMob ) {
     // ── data ───────────────────────────────────────────────────
      /* Mobile: compact pill — just count + prev/next + dismiss */
    function getJSON(url) {
      bar.innerHTML =
        return fetch(url, { credentials: 'same-origin' }).then(function (r) {
        '<span style="flex-shrink:0">🔍 ' + count + '</span>' +
            if (!r.ok) throw new Error('HTTP ' + r.status);
        '<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;min-width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;">↑</button>' +
            return r.json();
        '<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;min-width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;">↓</button>' +
         });
         '<button id="gr-hl-dismiss" style="background:rgba(255,255,255,0.15);border:none;color:#fff;min-width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;">✕</button>';
     }
     } else {
    function api(params) {
      /* Desktop: full bar */
        var q = ['format=json', 'formatversion=2'];
      var nav = document.createElement( 'div' );
         for (var k in params) q.push(k + '=' + encodeURIComponent(params[k]));
      nav.style.cssText = 'display:flex;align-items:center;gap:12px;';
        return getJSON(script('api') + '?' + q.join('&'));
      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>';
      bar.appendChild( nav );
     }
     }


     var dismiss = isMob
     // Devanagari names come from window.grTitles (the Main Page catalogue,
      ? bar.querySelector( '#gr-hl-dismiss' )
    // cached) — the same names search and links use.
      : ( function() {
    function fetchCatalogue() {
          var b = document.createElement( 'button' );
        return new Promise(function (resolve) {
          b.textContent = '✕ Clear';
            if (window.grTitles) window.grTitles.whenReady(function () { resolve(window.grTitles.all()); });
          b.id = 'gr-hl-dismiss';
            else resolve({});
          b.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;';
         });
          bar.appendChild( b );
    }
          return b;
         }() );


     dismiss.onclick = function () {
     // Every main-namespace change in the window, oldest→newest pages grouped.
      clearHighlights();
    function fetchChanges() {
      bar.remove();
        var since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString().replace(/\.\d+Z$/, 'Z');
     };
        var all = [];
        function page(cont, limit) {
            var p = {
                action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0,
                rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: limit, rcend: since
            };
            if (cont) p.rccontinue = cont;
            return api(p).then(function (j) {
                // The API answers 200 with an {error:...} body, so a failed
                // query is not a rejected promise. Treat it as one.
                if (j && j.error) throw new Error(j.error.code || 'api error');
                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, limit);
                }
                return all;
            }).catch(function (err) {
                // recentchanges over a date range is the most expensive query
                // this site makes, and on a busy or constrained database it
                // can come back as a DBQueryError. Halve the window and retry
                // once; failing that, return nothing. A "what's new" list is
                // not worth breaking the page for.
                if (limit > 100) return page(cont, 100);
                try {
                    console.warn('[gr] recent changes unavailable: ' +
                                (err && err.message || err));
                } catch (e) { }
                return all;
            });
        }
        return page(null, 500);
     }


     document.body.appendChild( bar );
     function summarise(changes, catalogue) {
        var byTitle = {};
        changes.forEach(function (c) {
            var t = norm(c.title);
            if (SKIP_TITLE.test(t) || HIDE.indexOf(t) !== -1) return;
            var e = byTitle[t] || (byTitle[t] = { title: t, created: null, last: null, bytes: 0 });
            var ts = new Date(c.timestamp);
            if (c.type === 'new') e.created = ts;
            else e.bytes += Math.abs((c.newlen || 0) - (c.oldlen || 0));
            if (!e.last || ts > e.last) e.last = ts;
        });
        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]] + ' — टीका' : 'टीका';
    }


     /* Prev / Next navigation */
     // Drop pages created in the window and deleted since.
     var hlEls = Array.from( document.querySelectorAll( '.gr-search-hl' ) );
     function dropMissing(items) {
    var currentIdx = 0;
        if (!items.length) return Promise.resolve(items);
    function goTo( idx ) {
        var batches = [];
      hlEls.forEach( function(el) { el.classList.remove( 'gr-search-hl-current' ); } );
        for (var i = 0; i < items.length; i += 50) batches.push(items.slice(i, i + 50));
      currentIdx = ( idx + hlEls.length ) % hlEls.length;
        return Promise.all(batches.map(function (b) {
      var el = hlEls[ currentIdx ];
            return api({ action: 'query', prop: 'info', inprop: 'displaytitle', titles: b.map(function (x) { return x.title; }).join('|') })
      el.classList.add( 'gr-search-hl-current' );
                .then(function (j) {
      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
                    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); });
     }
     }


     var nextBtn = document.getElementById( 'gr-hl-next' );
     function loadUpdates() {
    var prevBtn = document.getElementById( 'gr-hl-prev' );
        var cached = ssGet(CACHE_KEY);
    if ( nextBtn ) nextBtn.onclick = function() { goTo( currentIdx + 1 ); };
        if (cached) {
    if ( prevBtn ) prevBtn.onclick = function() { goTo( currentIdx - 1 ); };
            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().catch(function () { return []; }),
            fetchCatalogue().catch(function () { return null; })
        ]).then(function (r) {
            return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10));
        }).then(function (items) {
            items = items.slice(0, MAX_ITEMS);
            ssSet(CACHE_KEY, JSON.stringify({ at: Date.now(), items: items }));
            return items;
        });
    }
    function lastSeen() { return +lsGet(SEEN_KEY) || 0; }
    function markSeen() { lsSet(SEEN_KEY, String(Date.now())); }


  function clearHighlights() {
    // ── Updates page: the automatic list ─────────────────────────
     document.querySelectorAll( '.gr-search-hl' ).forEach( function( span ) {
     //    Filled into <div id="gr-updates-feed"> on UPDATES_PAGE.
      var parent = span.parentNode;
    //    All styling lives in Common.css §37.
      while ( span.firstChild ) parent.insertBefore( span.firstChild, span );
     var KIND = { 'new': ['New', 'gr-upd-new'], 'update': ['Updated', 'gr-upd-update'] };
      parent.removeChild( span );
     } );
  }


  function escHtml( s ) {
    // Today · This week · This month · then one group per month ("August 2026")
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    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();
    }


  /* ── Inject CSS for highlights ── */
    function renderFeed(box, items, seenBefore) {
  function injectHighlightCSS() {
        while (box.firstChild) box.removeChild(box.firstChild);
    if ( document.getElementById( 'gr-hl-css' ) ) return;
        if (items === null) { box.appendChild(el('p', 'gr-upd-empty', 'Loading…')); return; }
    var s = document.createElement( 'style' );
        if (!items.length) { box.appendChild(el('p', 'gr-upd-empty', 'No new or updated pages in the last ' + WINDOW_DAYS + ' days.')); return; }
    s.id = 'gr-hl-css';
        var now = new Date(), group = null, list = null;
    s.textContent = [
        items.forEach(function (x) {
      '.gr-search-hl{',
            var g = groupName(x.when, now);
      ' background:#fff176;color:#1a1a1a;',
            if (g !== group) {
      ' border-radius:2px;padding:0 1px;',
                group = g;
      '  box-shadow:0 0 0 1px rgba(181,69,27,0.25);',
                // A div, not an h3: the site's heading rules would restyle an h3.
      '}',
                var h = el('div', 'gr-upd-group', g);
      '.gr-search-hl-current{',
                h.setAttribute('role', 'heading'); h.setAttribute('aria-level', '3');
      ' background:#ffb300!important;',
                box.appendChild(h);
      ' box-shadow:0 0 0 2px #b5451b!important;',
                list = el('div', 'gr-upd-list');
      '}',
                list.setAttribute('role', 'list');
      '@keyframes gr-hl-pulse{',
                box.appendChild(list);
      ' 0%{background:#ffb300;}',
            }
      ' 50%{background:#fff176;}',
            var li = el('div', 'gr-upd-item' + (seenBefore && x.when.getTime() > seenBefore ? ' gr-upd-unseen' : ''));
      ' 100%{background:#fff176;}',
            li.setAttribute('role', 'listitem');
      '}',
            var main = el('div', 'gr-upd-main');
      '.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}',
            var a = el('a', 'gr-upd-title', x.label);
    ].join('');
            a.href = wikiHref(x.title);
    document.head.appendChild( s );
            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);
        });
    }


  /* ── Boot ── */
    // ── Updates page: the bulletin card ─────────────────────────
  injectHighlightCSS();
    //   Items are written on the page with {{Bulletin|…}}; this only
  if ( document.readyState === 'loading' ) {
    //    formats their dates, marks past events, and shows an empty note.
    document.addEventListener( 'DOMContentLoaded', applyHighlight );
    function decorateBulletin() {
  } else {
        var card = document.querySelector('.gr-bulletin');
    applyHighlight();
        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);
    }


  /* ── Expose storeQueryForLink for readerToolbar to call ── */
    function initUpdatesPage() {
  window.grStoreSearchHL = storeQueryForLink;
        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); }
  Mobile fixes — paste at bottom of MediaWiki:Common.js
  Injects a <style> tag AFTER readerToolbar's inline style
  so it wins the cascade without touching the extension.
  ═══════════════════════════════════════════════════════════════ */
( function () {
  if ( window.innerWidth > 768 ) return;


  /* ── 1. Inject mobile CSS after readerToolbar's <style> ─────── */
    // Which wiki page a link points at ("My wiki:Help"), or null.
  function injectMobileCSS() {
    function linkTarget(a) {
    if ( document.getElementById( 'gr-mobile-css' ) ) return;
        var href = a.getAttribute('href') || '';
    var s = document.createElement( 'style' );
        if (!href || href.charAt(0) === '#') return null;  // e.g. the Log out button
     s.id = 'gr-mobile-css';
        var u;
    s.textContent = [
        try { u = new URL(href, location.href); } catch (e) { return null; }
      /* Font scale */
        if (u.origin !== location.origin) return null;
      'html { font-size: 18px !important; }',
        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) { }
        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');
        });
    }


      /* Full-width layout — kill x-scroll everywhere */
    function injectDesktop() {
      'html body, html body .mw-page-container, html body .vector-page-container,',
        if (document.getElementById('gr-library-link')) return true;
      'html body .mw-content-container, html body .vector-body-container,',
        var help = document.getElementById('gr-help-link');
      'html body .vector-page-content, html body .mw-body,',
        var host = help ? help.parentNode :
      'html body .mw-body-content, html body #mw-content-text {',
            (document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header'));
      ' max-width: 100% !important; width: 100% !important;',
        if (!host) return false;
      '  overflow-x: hidden !important; box-sizing: border-box !important;',
      '  margin: 0 !important; padding: 0 !important; }',


      /* Content padding */
        var lib = el('a', 'gr-header-link', LIBRARY_LABEL);
      'html body #mw-content-text { padding: 8px 14px 48px !important; }',
        lib.id = 'gr-library-link';
        lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
        lib.title = 'Grantha catalogue (opens in a new tab)';


      /* Content font */
        var upd = el('a', 'gr-header-link', UPDATES_LABEL);
      'html body .mw-parser-output {',
        upd.id = 'gr-updates-link';
      ' font-size: 18px !important; line-height: 1.8 !important;',
        upd.href = updatesHref();
      ' max-width: 100% !important; overflow-x: hidden !important; }',


      /* Headings full width */
        if (help) { host.insertBefore(lib, help); host.insertBefore(upd, help); }
      'html body .mw-parser-output h2, html body .mw-parser-output .mw-heading2 h2 {',
        else { host.appendChild(lib); host.appendChild(upd); }
      '  font-size: 1.1em !important; width: 100% !important; margin-top: 1.2em !important; }',
        markCurrent();
      'html body .mw-parser-output h3, html body .mw-parser-output .mw-heading3 h3 {',
        return true;
      '  font-size: 1em !important; width: 100% !important; }',
    }


      /* Hide Vector sidebar TOC — replaced by overlay */
    function injectMobile() {
      'html body .vector-column-start, html body #vector-toc,',
        if (document.getElementById('gr-mob-library')) return true;
      'html body .vector-toc-landmark, html body .mw-table-of-contents-container,',
        var menu = document.getElementById('gr-mob-menu-items');
      'html body .vector-sticky-pinned-container,',
        if (!menu) return false;
      'html body .vector-pinnable-element.vector-toc-pinnable-element {',
      '  display: none !important; }',


      /* Home grid — single column, no x-scroll */
        var lib = el('a', 'gr-mob-item', LIBRARY_LABEL);
      'html body .gr-home { overflow-x: hidden !important; max-width: 100% !important; }',
        lib.id = 'gr-mob-library';
      'html body .gr-home-grid {',
        lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
      ' flex-direction: column !important; flex-wrap: nowrap !important;',
      ' gap: 14px !important; overflow-x: hidden !important; width: 100% !important; }',
      'html body .gr-home-card {',
      '  flex: 0 0 auto !important; width: 100% !important;',
      ' max-width: 100% !important; min-width: unset !important;',
      ' box-sizing: border-box !important; }',


      /* Toolbar */
        var upd = el('a', 'gr-mob-item', UPDATES_LABEL);
      'html body #gr-static-bar {',
        upd.id = 'gr-mob-updates';
      '  height: auto !important; min-height: 52px !important;',
        upd.href = updatesHref();
      '  padding: 6px 8px !important; flex-wrap: nowrap !important;',
      '  overflow-x: auto !important; overflow-y: hidden !important;',
      '  scrollbar-width: none !important; gap: 4px !important; }',
      'html body #gr-static-bar::-webkit-scrollbar { display: none !important; }',
      'html body .gr-controls .gr-btn {',
      '  height: 44px !important; min-width: 44px !important;',
      '  font-size: 15px !important; padding: 0 10px !important; flex-shrink: 0 !important; }',
      'html body .gr-controls .gr-icon-btn { width: 44px !important; padding: 0 !important; }',
      'html body .gr-script-sel {',
      '  height: 40px !important; font-size: 14px !important;',
      '  min-width: 90px !important; max-width: 120px !important; }',
      'html body .gr-controls .gr-sep { display: none !important; }',
      'html body .gr-btn-staging { display: none !important; }',


      /* TOC overlay styles */
        var home = null;
      '.gr-mob-toc-btn {',
        for (var i = 0; i < menu.children.length; i++) {
      '  display: flex !important; position: fixed !important;',
            if (/^\s*Home\s*$/.test(menu.children[i].textContent)) { home = menu.children[i]; break; }
      '  bottom: 148px !important; left: 16px !important; z-index: 9100 !important;',
        }
      '  background: #fff !important; border: 1.5px solid #b5451b !important;',
        var ref = home ? home.nextSibling : menu.firstChild;
      '  border-radius: 24px !important; padding: 10px 16px !important;',
        menu.insertBefore(lib, ref);
      '  font-size: 15px !important; font-family: system-ui, sans-serif !important;',
        menu.insertBefore(upd, ref);
      '  color: #b5451b !important; font-weight: 600 !important;',
        markCurrent();
      '  box-shadow: 0 3px 14px rgba(0,0,0,0.15) !important;',
        return true;
      '  cursor: pointer !important; align-items: center !important;',
    }
      '  gap: 6px !important; white-space: nowrap !important; }',


      '.gr-mob-toc-panel {',
    // ── boot: the Help/About and mobile-menu code creates the anchors on
      '  position: fixed !important; top: 0 !important; left: 0 !important;',
    //    DOMContentLoaded, so wait briefly for them rather than assume order.
      '  bottom: 0 !important; width: 82vw !important; max-width: 340px !important;',
    function boot() {
      '  background: #fff !important; z-index: 10400 !important;',
        initUpdatesPage();
      '  box-shadow: 4px 0 28px rgba(0,0,0,0.22) !important;',
        var inject = isMobile ? injectMobile : injectDesktop;
      '  overflow-y: auto !important; padding: 0 0 40px !important;',
        if (inject()) return;
      '  transform: translateX(-110%) !important;',
        var tries = 0;
      ' transition: transform 0.26s cubic-bezier(0.4,0,0.2,1) !important;',
        var obs = window.MutationObserver ? new MutationObserver(function () { if (inject()) obs.disconnect(); }) : null;
      '  display: block !important; }',
        if (obs) obs.observe(document.body, { childList: true, subtree: true });
      '.gr-mob-toc-panel.open { transform: translateX(0) !important; }',
        (function poll() {
            if (inject()) { if (obs) obs.disconnect(); return; }
            if (++tries < 40) setTimeout(poll, 250); else if (obs) obs.disconnect();
        }());
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
    // Page previews and live-preview re-render content; re-run the page part then.
    if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { initUpdatesPage(); });


      '.gr-mob-toc-backdrop {',
    window.grUpdates = { relDate: relDate, summarise: summarise, groupName: groupName };
      '  display: none !important; position: fixed !important; inset: 0 !important;',
}());
      '  background: rgba(0,0,0,0.4) !important; z-index: 10399 !important; }',
      '.gr-mob-toc-backdrop.open { display: block !important; }',


      '.gr-mob-toc-header {',
      '  position: sticky !important; top: 0 !important; background: #fff !important;',
      '  display: flex !important; align-items: center !important;',
      '  justify-content: space-between !important;',
      '  padding: 16px 16px 12px !important;',
      '  border-bottom: 1px solid #f0ebe6 !important; z-index: 1 !important; }',
      '.gr-mob-toc-title {',
      '  font-size: 13px !important; font-weight: 700 !important;',
      '  text-transform: uppercase !important; letter-spacing: 0.08em !important;',
      '  color: #b5451b !important; font-family: system-ui, sans-serif !important; }',
      '.gr-mob-toc-close {',
      '  background: none !important; border: none !important;',
      '  font-size: 22px !important; color: #999 !important;',
      '  cursor: pointer !important; padding: 4px 8px !important; line-height: 1 !important; }',
      '.gr-mob-toc-body { padding: 12px 16px !important; }',
      '.gr-mob-toc-body a {',
      '  display: block !important; font-size: 16px !important;',
      '  line-height: 1.6 !important; color: #2c1810 !important;',
      '  text-decoration: none !important; padding: 8px 0 !important;',
      '  border-bottom: 1px solid #f5f0ed !important;',
      "  font-family: 'Adishila','Noto Serif Devanagari',system-ui,sans-serif !important; }",
      '.gr-mob-toc-body .vector-toc-level-2 a {',
      '  padding-left: 16px !important; font-size: 15px !important; color: #555 !important; }',


      /* Annotation toggle button position */
/* ═══════════════════════════════════════════════════════════════
      'html body #gra-toggle {',
  Main Page cards: item count beside each title, and a
      ' bottom: 80px !important; right: 14px !important;',
  .gr-home-card-wide class on long lists. Styling: Common.css §25.
      ' width: 54px !important; height: 54px !important; }',
  ═══════════════════════════════════════════════════════════════ */
    ].join( '\n' );
(function () {
     document.head.appendChild( s );
    '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();
}());


  /* ── 2. Mobile TOC overlay ────────────────────────────────────── */
  function initMobileToc() {
    if ( document.getElementById( 'gr-mob-toc-btn' ) ) return;


     var tocList = document.querySelector(
// ── Footnote markers and their cards ────────────────────────────────
      '.vector-toc-contents, .vector-toc .vector-toc-list'
/* ── FOOTNOTE REGISTRY ────────────────────────────────────────────────
     );
  Notes that belong to content injected into this page rather than baked
     if ( !tocList ) { setTimeout( initMobileToc, 500 ); return; }
  into it - an interleaved commentary's own footnotes live in ITS
  document, so nothing on the moola page knew what its highlighted words
  pointed at. Anything registered here is visible to the note card and to
  the right-hand panel, grouped under the commentary it came from. */
window.grFootnoteRegistry = (function () {
    'use strict';
    var byId = {};            // gra-id -> {text, quote, type, group, groupTitle}
    var groups = [];          // [{slug, title}] in the order they were added
     var listeners = [];
    var pending = null;
    // Deferred by a tick on purpose. Notes are registered while a
    // commentary's block is still being built, BEFORE it is inserted into
    // the page - so a listener that re-scans the document synchronously
    // would run against a DOM that does not contain the highlighted words
    // yet, and find nothing to wire up. Coalescing to one call after the
    // current task also means a page with ten commentaries re-scans once
    // rather than ten times.
     function notify() {
        if (pending) return;
        pending = setTimeout(function () {
            pending = null;
            listeners.forEach(function (f) { try { f(); } catch (e) {} });
        }, 0);
     }
    return {
        add: function (slug, title, notes) {
            if (!groups.some(function (g) { return g.slug === slug; })) {
                groups.push({ slug: slug, title: title });
            }
            var added = false;
            (notes || []).forEach(function (n) {
                if (!n || !n.id || byId[n.id]) return;
                byId[n.id] = {
                    text: n.text || '', quote: n.quote || '',
                    type: n.kind || n.type || 'note',
                    group: slug, groupTitle: title
                };
                added = true;
            });
            if (added) notify();
        },
        get: function (id) { return byId[id] || null; },
        all: function () { return byId; },
        groups: function () { return groups.slice(); },
        isInjected: function (id) { return !!byId[id]; },
        clearInjected: function () { byId = {}; groups = []; notify(); },
        onChange: function (f) { listeners.push(f); }
    };
}());


    /* Check if page actually has TOC items */
/* QuickEdit already stores footnotes: it wraps the annotated words in
    var hasItems = tocList.querySelector( '.vector-toc-list-item' );
  <span class="gra-qe-footnote-highlight" data-gra-id="…"> and appends a
  <div class="gra-qe-footnotes"><ol> at the foot of the document, one
  <li data-gra-id="…"> per note carrying the quoted words and the note.


    /* Backdrop */
  That is the whole apparatus, but reading it meant scrolling to the end of
    var bd = document.createElement( 'div' );
  a long document and scrolling back. This turns the same data into what
    bd.className = 'gr-mob-toc-backdrop';
  Setutila shows: a small superscript after the annotated words, and a card
    document.body.appendChild( bd );
  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.


    /* Panel */
  The note text is transliterated with the rest of the page: the card's body
    var panel = document.createElement( 'div' );
  is tagged data-deva and announced through gr-new-content, which the script
     panel.className = 'gr-mob-toc-panel';
  switcher already listens for. */
(function () {
     'use strict';


     var hdr = document.createElement( 'div' );
     var HL = '.gra-qe-footnote-highlight';
    hdr.className = 'gr-mob-toc-header';
     var LIST = '.gra-qe-footnotes';
     var ttl = document.createElement( 'div' );
     // data-gra-type on the <li>. The names the converter writes, and the
     ttl.className = 'gr-mob-toc-title';
     // ones a QuickEdit button could write later, land in the same map.
     ttl.textContent = 'विषयसूची';
     var TYPES = {
     var cls = document.createElement( 'button' );
        pathantara: 'पाठान्तरम्',  // a variant reading
    cls.className = 'gr-mob-toc-close';
        locus: 'प्रमाणम्',          // where a quotation is from — ऋ.सं.७.९९
    cls.textContent = '✕';
        source: 'प्रमाणम्',
    hdr.appendChild( ttl ); hdr.appendChild( cls );
        note: 'टिप्पणी'           // an editorial remark
     panel.appendChild( hdr );
     };


     var body = document.createElement( 'div' );
     var card = null, openFor = null;
    body.className = 'gr-mob-toc-body';
    body.appendChild( tocList.cloneNode( true ) );
    panel.appendChild( body );
    document.body.appendChild( panel );


     /* Toggle button — only show if page has TOC */
     function notesFromList() {
    var btn = document.createElement( 'button' );
        var out = {};
    btn.className = 'gr-mob-toc-btn';
        var reg = window.grFootnoteRegistry.all();
    btn.id = 'gr-mob-toc-btn';
        Object.keys(reg).forEach(function (id) {
    btn.innerHTML = '☰ &nbsp;Contents';
            out[id] = { text: reg[id].text, quote: reg[id].quote,
    if ( !hasItems ) btn.style.display = 'none';
                        type: reg[id].type, injected: true,
    document.body.appendChild( btn );
                        groupTitle: reg[id].groupTitle };
        });
        document.querySelectorAll(LIST + ' .gra-qe-footnote-item').forEach(function (li) {
            var id = li.getAttribute('data-gra-id');
            if (!id) return;
            // An injected note is published into this same list so the
            // right-hand panel can see it, which means the list entry gets
            // read back here. Keep the registry's own record for those, or
            // the entry overwrites it, the `injected` flag is lost, and the
            // note is numbered as though it belonged to this document.
            if (out[id] && out[id].injected) 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 open() {
     function buildCard() {
      panel.classList.add( 'open' );
        if (card) return card;
      bd.classList.add( 'open' );
        card = document.createElement('div');
      document.body.style.overflow = 'hidden';
        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);
        card.addEventListener('mouseenter', function () { clearTimeout(hoverTimer); });
        card.addEventListener('mouseleave', function () { if (!COARSE) hoverClose(); });
        document.body.appendChild(card);
        return card;
     }
     }
     function close() {
     function close() {
      panel.classList.remove( 'open' );
        if (!card || card.hidden) return;
      bd.classList.remove( 'open' );
        card.hidden = true;
      document.body.style.overflow = '';
        if (openFor) {
            openFor.setAttribute('aria-expanded', 'false');
            openFor.classList.remove('gr-note-marker-open');
            openFor.classList.remove('gr-note-hl-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';
     }
     }


     btn.addEventListener( 'click', open );
     function open(marker, note, num) {
    cls.addEventListener( 'click', close );
        buildCard();
    bd.addEventListener( 'click', close );
        if (openFor === marker && !card.hidden) { close(); return; }
    body.querySelectorAll( 'a' ).forEach( function ( a ) {
        close();
      a.addEventListener( 'click', close );
        card.querySelector('.gr-note-card-kind').textContent =
    } );
            (TYPES[note.type] || TYPES.note) +
  }
            (typeof num === 'number' ? '  [' + num + ']' : (num ? ' \u00b7  ' + 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');
        // A commentary's note has no superscript, so the element the card is
        // anchored to IS the highlighted word. gr-note-marker-open paints a
        // solid maroon chip with white text - right for a small superscript,
        // and it made the word itself unreadable. The word gets its own,
        // quieter open state instead.
        marker.classList.add(marker.classList.contains('gr-note-marker')
            ? 'gr-note-marker-open' : 'gr-note-hl-open');
        openFor = marker;
    }


  /* ── 3. Mobile hamburger menu ─────────────────────────────────── */
    // A coarse pointer has no hover, so there the card opens on tap. With a
  function initMobileMenu() {
    // mouse, hover shows the note and a click is left free for the reader's
     if ( document.getElementById( 'gr-mob-menu-btn' ) ) return;
    // own panel - which is what the marker is for on desktop.
     var COARSE = (window.matchMedia && window.matchMedia('(hover: none), (pointer: coarse)').matches)
        || ('ontouchstart' in window) || navigator.maxTouchPoints > 0;


     var headerStart = document.querySelector( '.vector-header-start' );
     var hoverTimer = null;
     if ( !headerStart ) return;
    function hoverOpen(m, note, num) {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(function () { open(m, note, num); }, 90);
     }
    function hoverClose() {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(function () {
            if (card && !card.matches(':hover')) close();
        }, 220);
    }


     /* Hamburger button — prepend to header start */
     function decorate() {
    var btn = document.createElement( 'button' );
        var notes = notesFromList();
    btn.id = 'gr-mob-menu-btn';
        var n = 0;
    btn.className = 'gr-mob-menu-btn';
        document.querySelectorAll('#mw-content-text ' + HL).forEach(function (hl) {
    btn.setAttribute( 'aria-label', 'Open menu' );
            var id = hl.getAttribute('data-gra-id');
    btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
            var note = id && notes[id];
    headerStart.insertBefore( btn, headerStart.firstChild );
            if (!note || !note.text) return;         // orphan highlight: leave it alone


    /* Backdrop */
            // An interleaved commentary's notes get NO superscript. The
    var bd = document.createElement( 'div' );
            // numbers would restart per commentary and interleave with the
    bd.className = 'gr-mob-menu-backdrop';
            // moola's own, so "[3]" on the page would mean three different
    document.body.appendChild( bd );
            // notes depending on which block it sat in. The highlighted
            // words stay clickable, and the panel lists them by commentary.
            var injected = !!note.injected;
            var num = injected ? null : ++n;


    /* Panel */
            var m = null;
    var panel = document.createElement( 'div' );
            if (!injected) {
    panel.className = 'gr-mob-menu-panel';
                if (hl.nextElementSibling &&
                    hl.nextElementSibling.classList.contains('gr-note-marker')) {
                    m = hl.nextElementSibling;
                    m.textContent = '[' + num + ']';
                } else {
                    m = document.createElement('sup');
                    m.className = 'gr-note-marker';
                    m.textContent = '[' + num + ']';
                    m.setAttribute('role', 'button');
                    m.setAttribute('tabindex', '0');
                    m.setAttribute('aria-expanded', 'false');
                    hl.parentNode.insertBefore(m, hl.nextSibling);
                }
            }
            if (hl.dataset.grNoteWired) return;
            hl.dataset.grNoteWired = '1';


    /* Header */
            var anchorEl = m || hl;
    var hdr = document.createElement( 'div' );
            var label = injected ? (note.groupTitle || '') : num;
    hdr.className = 'gr-mob-menu-header';
    var ttl = document.createElement( 'div' );
    ttl.className = 'gr-mob-menu-header-title';
    ttl.textContent = 'Anandamakaranda';
    var cls = document.createElement( 'button' );
    cls.className = 'gr-mob-menu-close';
    cls.textContent = '✕';
    hdr.appendChild( ttl ); hdr.appendChild( cls );
    panel.appendChild( hdr );


    /* Body — collect links from vector-header-end */
            function show(e) { e.preventDefault(); e.stopPropagation(); open(anchorEl, note, label); }
    var body = document.createElement( 'div' );
    body.className = 'gr-mob-menu-body';


    /* Username / user page */
            if (COARSE) {
    var userName = window.mw ? mw.config.get( 'wgUserName' ) : null;
                if (m) m.addEventListener('click', show);
    if ( userName ) {
                hl.addEventListener('click', show);
      var userLink = document.createElement( 'a' );
            } else {
      userLink.className = 'gr-mob-menu-item gr-mob-menu-item-user';
                // Hover opens the card; the click is NOT swallowed, so the
      userLink.href = '/User:' + encodeURIComponent( userName );
                // annotation gadget still gets it and opens its Footnotes
      userLink.textContent = '👤 ' + userName;
                // panel - the behaviour that was lost when this handler
      body.appendChild( userLink );
                // called stopPropagation on every click.
      var div0 = document.createElement( 'div' );
                [m, hl].forEach(function (el) {
      div0.className = 'gr-mob-menu-divider';
                    if (!el) return;
      body.appendChild( div0 );
                    el.addEventListener('mouseenter', function () { hoverOpen(anchorEl, note, label); });
                    el.addEventListener('mouseleave', hoverClose);
                });
                if (m) m.addEventListener('click', show);
            }
            if (m) {
                m.addEventListener('keydown', function (e) {
                    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(m, note, label); }
                });
            }
        });
        var list = document.querySelector(LIST);
        if (list) list.classList.toggle('gr-notes-listed', n > 0);
     }
     }


     /* Collect links from header-end */
    document.addEventListener('click', function (e) {
     var headerEnd = document.querySelector( '.vector-header-end' );
        if (!card || card.hidden) return;
    if ( headerEnd ) {
        if (!card.contains(e.target)) close();
      var links = headerEnd.querySelectorAll( 'a' );
    });
      links.forEach( function ( a ) {
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
        var item = document.createElement( 'a' );
    window.addEventListener('resize', function () { if (openFor) place(openFor); });
        item.className = 'gr-mob-menu-item';
    window.addEventListener('scroll', function () { if (openFor) place(openFor); }, true);
        item.href = a.href;
 
        item.textContent = a.textContent.trim();
     // Commentaries are fetched and injected after the page has rendered, so
        if ( item.textContent ) body.appendChild( item );
    // their notes arrive later than this module's first pass. Re-run when
      } );
    // the registry changes, and publish them into the page's own footnote
    // list so the right-hand panel - which reads that list, not this
    // module - shows them too, under the commentary each came from.
     function publishInjectedList() {
        var host = document.querySelector(LIST);
        if (!host) {
            var content = document.getElementById('mw-content-text');
            if (!content) return;
            host = document.createElement('div');
            host.className = 'gra-qe-footnotes';
            content.appendChild(host);
        }
        host.querySelectorAll('.gr-teeka-notes-group').forEach(function (g) { g.remove(); });
        var reg = window.grFootnoteRegistry.all();
        window.grFootnoteRegistry.groups().forEach(function (g) {
            var ids = Object.keys(reg).filter(function (id) { return reg[id].group === g.slug; });
            if (!ids.length) return;
            var wrap = document.createElement('div');
            wrap.className = 'gr-teeka-notes-group';
            wrap.setAttribute('data-teeka-slug', g.slug);
            var h = document.createElement('div');
            h.className = 'gr-teeka-notes-title';
            h.textContent = (window.grLibrary && window.grLibrary.labelFor(g.slug))
                            || g.title || g.slug;
            wrap.appendChild(h);
            var ol = document.createElement('ol');
            ids.forEach(function (id) {
                var n = reg[id];
                var li = document.createElement('li');
                li.className = 'gra-qe-footnote-item';
                li.setAttribute('data-gra-id', id);
                li.setAttribute('data-gra-type', n.type);
                if (n.quote) {
                    var q = document.createElement('span');
                    q.className = 'gra-qe-footnote-quote';
                    q.textContent = n.quote;
                    li.appendChild(q);
                }
                var t = document.createElement('span');
                t.className = 'gra-qe-footnote-text';
                t.textContent = n.text;
                li.appendChild(t);
                ol.appendChild(li);
            });
            wrap.appendChild(ol);
            host.appendChild(wrap);
        });
        try {
            window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: host } }));
        } catch (e) { }
     }
     }


     /* Static links */
     window.grFootnoteRegistry.onChange(function () {
    var statics = [
        publishInjectedList();
      { label: 'Help', href: '/Help:Contents' },
        decorate();
      { label: 'About', href: '/Anandamakaranda:About' },
     });
    ];
    var div1 = document.createElement( 'div' );
     div1.className = 'gr-mob-menu-divider';
    body.appendChild( div1 );


     /* Login / logout */
     if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', decorate);
    var isLoggedIn = userName !== null;
     else decorate();
    var authItem = document.createElement( 'a' );
     if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { setTimeout(decorate, 150); });
     authItem.className = 'gr-mob-menu-item';
}());
     if ( isLoggedIn ) {
      var logoutLink = document.querySelector( '#pt-logout a, a[href*="action=logout"]' );
      authItem.href = logoutLink ? logoutLink.href : '/index.php?title=Special:UserLogout';
      authItem.textContent = 'Log out';
    } else {
      authItem.href = '/index.php?title=Special:UserLogin';
      authItem.textContent = 'Log in';
    }
    body.appendChild( authItem );


    panel.appendChild( body );
/* ── HASH REVEAL ───────────────────────────────────────────────────────
    document.body.appendChild( panel );
  A copied id or a bookmark citation is only useful if its #link actually
  lands on the block. The browser's own fragment scroll fires once, at
  parse time, and then our fonts swap in and the teeka blocks inject -
  the page grows under the reader and the target drifts off screen. It
  looks exactly like the link didn't work.


    function open()  { panel.classList.add('open'); bd.classList.add('open'); document.body.style.overflow = 'hidden'; }
  This retries until the element exists and re-settles after late content
    function close() { panel.classList.remove('open'); bd.classList.remove('open'); document.body.style.overflow = ''; }
  arrives. It used to live in copyId.js (Editor extension), which is no
  longer loaded on this wiki, so it moves here where it always runs. If
  copyId IS loaded it does the same job; both are idempotent, and the
  guard below keeps them from fighting over the same jump. */
(function () {
    'use strict';
    if (window.grHashReveal) return;


     btn.addEventListener( 'click', open );
     var FLASH_MS = 2600;
     cls.addEventListener( 'click', close );
     var _last = null;
    bd.addEventListener( 'click', close );
    body.querySelectorAll( 'a' ).forEach( function(a){ a.addEventListener( 'click', close ); } );
  }


  /* ── Boot ─────────────────────────────────────────────────────── */
    function findTarget(id) {
  injectMobileCSS();   /* CSS first — synchronous */
        if (!id) return null;
        var esc = (window.CSS && CSS.escape) ? CSS.escape(id) : id.replace(/["\\]/g, '\\$&');
        return document.getElementById(id) ||
              document.querySelector('[data-block-id="' + esc + '"]') ||
              document.querySelector('[data-copyid="' + esc + '"]') ||
              null;
    }


  /* ── Fix Minerva header: remove top gap, ensure orange, show logo ── */
    function currentHash() {
  function fixMinervaHeader() {
        var raw = (location.hash || '').slice(1);
    if ( !document.body.classList.contains( 'skin-minerva' ) ) return;
        if (!raw) return '';
        try { return decodeURIComponent(raw); } catch (e) { return raw; }
    }


     /* Remove any top margin/padding on body — readerToolbar may have set it */
     function land(el, flash) {
    document.body.style.paddingTop = '';
        el.scrollIntoView({ block: 'center', behavior: 'smooth' });
    document.body.style.marginTop = '0';
        if (!flash) return;
        el.classList.add('gid-target');
        setTimeout(function () { el.classList.remove('gid-target'); }, FLASH_MS);
    }


     /* Also watch for readerToolbar setting paddingTop after us */
     function reveal(attemptsLeft, flash) {
    var _bodyObserver = new MutationObserver( function() {
        var id = currentHash();
      if ( document.body.style.paddingTop && document.body.style.paddingTop !== '0px' ) {
        if (!id) return;
         document.body.style.paddingTop = '';
        var el = findTarget(id);
      }
        if (!el) {
    } );
            if (attemptsLeft > 0) setTimeout(function () { reveal(attemptsLeft - 1, flash); }, 400);
    _bodyObserver.observe( document.body, { attributes: true, attributeFilter: ['style'] } );
            return;
        }
        _last = id;
        land(el, flash);
        // Re-settle once the late arrivals (fonts, injected teekas) have
         // shifted the layout. Cheap, and it is what makes the link feel
        // like it worked rather than like it missed.
        setTimeout(function () {
            if (currentHash() !== id) return;
            var again = findTarget(id);
            if (again) again.scrollIntoView({ block: 'center', behavior: 'auto' });
        }, 1200);
    }


     /* Force orange header */
     function onLateContent() {
    var headerEl = document.querySelector( 'header.header-container' );
        var id = currentHash();
    if ( headerEl ) headerEl.style.background = '#b5451b';
        if (!id || id !== _last) return;
        var el = findTarget(id);
        if (el) el.scrollIntoView({ block: 'center', behavior: 'auto' });
    }


     /* Add quill logo before site name */
     window.grHashReveal = function (attempts) { reveal(attempts || 12, true); };
    var brandingLink = document.querySelector( '.branding-box a' );
    if ( brandingLink && !brandingLink.querySelector( '.gr-mob-logo' ) ) {
      var quillSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="28" height="28" fill="none" style="margin-right:8px;flex-shrink:0;">' +
        '<path stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M32 4C26 4 14 10 8 28L6 34l6-1c4-1 8-4 11-8"/>' +
        '<path stroke="#fff" stroke-width="1.8" stroke-linecap="round" d="M32 4C28 12 22 20 12 26"/>' +
        '<path stroke="#fff" stroke-width="1.8" stroke-linecap="round" d="M8 28c2-2 4-3 6-2s3 3 2 5"/>' +
        '<path stroke="#fff" stroke-width="1.5" stroke-linecap="round" d="M12 26L6 34" opacity="0.6"/></svg>';
      var logoEl = document.createElement( 'span' );
      logoEl.className = 'gr-mob-logo';
      logoEl.innerHTML = quillSvg;
      brandingLink.style.cssText = 'display:flex;align-items:center;text-decoration:none;';
      brandingLink.insertBefore( logoEl, brandingLink.firstChild );


      /* Style the text span */
    function boot() {
      var textSpan = brandingLink.querySelector( 'span:not(.gr-mob-logo)' );
        reveal(12, true);
      if ( textSpan ) {
        window.addEventListener('hashchange', function () { reveal(6, true); });
        textSpan.style.cssText = 'color:#fff;font-size:17px;font-weight:700;font-family:system-ui,sans-serif;line-height:1.2;';
        window.addEventListener('gr-new-content', onLateContent);
      }
        if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
            document.fonts.ready.then(onLateContent);
        }
     }
     }
  }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
}());


  if ( document.readyState === 'loading' ) {
/* ── GRANTHA LIBRARY ───────────────────────────────────────────────────
    document.addEventListener( 'DOMContentLoaded', fixMinervaHeader );
  The colophon in the source docx says "श्रीमद्वादिराजपूज्यचरणविरचिता
  } else {
  प्रकाशिका" - an author phrase welded to a generic label. That is not a
    fixMinervaHeader();
  citable title, and it is what the client saw as "Vadirajathirtha
  }
  virachitha". The Grantha library holds the real title and author, so
  the page carries only the library's stable id (data-grantha-id) and we
  resolve the rest here, at read time. Correct a name in the library and
  every citation on the site is correct on the next load - nothing to
  rebuild, nothing to re-upload.


  initMobileMenu();
  Reads only, and the library's GET endpoints are public: no API key is
  sent from the browser, and none may ever be put in this file, which
  any reader can fetch. */
(function () {
    'use strict';
    if (window.grLibrary) return;


  if ( document.readyState === 'loading' ) {
    // ---- CONFIGURE ME -------------------------------------------------
     document.addEventListener( 'DOMContentLoaded', function () {
    // Where the Grantha library's REST API lives. Same-origin by default,
      setTimeout( initMobileToc, 600 );
    // which needs no CORS - but the library is its own app, so unless it is
     } );
    // reverse-proxied under the wiki's domain this must be its real host:
  } else {
    //    var API_BASE = 'https://library.example.org/api/granthas';
     setTimeout( initMobileToc, 600 );
    // On another host the library must send
  }
    //    Access-Control-Allow-Origin: https://anandamakaranda.in
    // or the browser will refuse the reply and every citation will fall
    // back to the author baked into the page (usually none).
     var API_BASE = window.GR_LIBRARY_API || '/api/granthas';
    // Set to false while the library is not yet reachable: no request is
    // made at all, and citations use whatever the page itself carries.
    // Flip it back to true once API_BASE resolves.
    var LIBRARY_ENABLED = true;
    // -------------------------------------------------------------------
    var API = API_BASE.replace(/\/+$/, '');
     var mem = {};
    var warned = false;
     // Once the library has answered "not here", every further call will get
    // the same answer. Stop asking: a wrong or missing API base otherwise
    // fills the network log with one 404 per work on the page.
    var libraryDown = !LIBRARY_ENABLED;


  if ( window.mw ) {
    // In memory only, for the life of the page. Names used to be kept in
     mw.hook( 'wikipage.content' ).add( function () {
    // sessionStorage for six hours, which quietly outlived any correction
      setTimeout( initMobileToc, 600 );
     // made in the library or on the map page. They now come from the map
      setTimeout( function() { expandAllSections(); watchSections(); }, 100 );
    // page, which is read once per load, so there is nothing to gain from
      setTimeout( expandAllSections, 600 );
    // a second cache that can disagree with it.
    } );
    function cacheGet(id) { return mem[id] || null; }
  }
    function cacheSet(id, v) { mem[id] = v; }


  /* ── Force expand all Minerva collapsed sections ──────────────
    // The library returns camelCase fields; normalise to what the citation
  * MobileFrontend JS runs AFTER our code and re-collapses sections.
    // builder wants and keep BOTH scripts, since a Sanskrit reference is
  * We use a MutationObserver to immediately re-expand anything
    // cited in Devanagari and an English-language one in IAST.
  * that gets hidden, plus run on load and on MW hooks.
    function shape(g) {
  * ─────────────────────────────────────────────────────────── */
        if (!g) return null;
  function expandAllSections() {
        return {
    if ( !document.body.classList.contains( 'skin-minerva' ) ) return;
            id:          g.id || '',
            name:        g.name || '',
            nameIAST:    g.transliteration || '',
            author:      g.author || '',
            authorIAST:  g.authorTransliteration || g.author_transliteration || '',
            category:    g.category || '',
            prasthana:  g.prasthana || ''
        };
    }


     /* THE KEY FIX: MobileFrontend uses hidden="until-found" attribute
     var inflight = {};
    * which CSS display:block cannot override. Must remove the attribute. */
    function lookup(id) {
    document.querySelectorAll( '[hidden]' ).forEach( function(el) {
        if (!id) return Promise.resolve(null);
      var h = el.getAttribute( 'hidden' );
        var hit = cacheGet(id);
      if ( h === 'until-found' || h === '' || h === 'hidden' ) {
        if (hit) return Promise.resolve(hit);
         if ( el.closest( '[class*="mf-section-"], .collapsible-block' )
        // The map page carries the names for every work this wiki uses, so
          || el.classList.contains( 'collapsible-block' )
        // normally nothing is fetched from the library at all. That matters
          || /mf-section-/.test( el.className ) ) {
        // because the library is someone else's server: we cannot add CORS
          el.removeAttribute( 'hidden' );
        // headers to it, and a cross-origin call we cannot configure is a
        }
        // call that will not work. The live API stays as an enhancement for
      }
        // whenever it IS reachable.
    } );
        return granthaMap().then(function (m) {
            var w = (m.works || {})[id];
            if (w && (w.name || w.nameIAST)) {
                var v = { id: id, name: w.name || '', nameIAST: w.nameIAST || '',
                          author: w.author || '', authorIAST: w.authorIAST || '',
                          category: w.category || '', prasthana: w.prasthana || '' };
                cacheSet(id, v);
                return v;
            }
            return fetchFromLibrary(id);
        });
    }
 
    function fetchFromLibrary(id) {
         if (libraryDown) return Promise.resolve(null);
        if (inflight[id]) return inflight[id];
        inflight[id] = fetch(API + '/' + encodeURIComponent(id), {
            credentials: 'omit', headers: { 'Accept': 'application/json' }
        }).then(function (r) {
            // A 404 or 5xx means the base URL is wrong or the service is
            // down - not that this one work is missing - so trip the breaker.
            if (!r.ok) {
                if (r.status === 404 || r.status >= 500) libraryDown = true;
                throw new Error('library ' + r.status);
            }
            return r.json();
        }).then(function (j) {
            var v = shape(j && (j.grantha || j.data || j));
            if (v && v.name) cacheSet(id, v);
            delete inflight[id];
            return v;
        }).catch(function (err) {
            // Offline, blocked by CORS, wrong host, or the id is not in the
            // library yet. The page's own data-author/data-doc-title stand
            // in, so a citation is never blocked on this request - but an
            // unreachable library looks exactly like a work with no author,
            // so say which it is, once.
            delete inflight[id];
            libraryDown = true;
            if (!warned) {
                warned = true;
                try {
                    console.warn('[grantha] could not read the library at ' + API +
                        ' (asked for "' + id + '"): ' + (err && err.message || err) +
                        '. Citations will use whatever the page itself carries. ' +
                        'Set window.GR_LIBRARY_API to the library host, and allow ' +
                        'this origin via Access-Control-Allow-Origin.');
                } catch (e) { }
            }
            return null;
        });
        return inflight[id];
    }


     /* Also remove hidden from all mf-section and collapsible-block directly */
     // ── WHICH GRANTHA IS THIS PAGE? ────────────────────────────────────
     document.querySelectorAll(
    // A page can say so itself (data-grantha-id, written by the pipeline),
      '[class*="mf-section-"], .collapsible-block'
     // but that would mean re-importing every document just to add one
     ).forEach( function(el) {
    // attribute - and a re-import risks losing edits made on the site
      el.removeAttribute( 'hidden' );
     // since. So the mapping also lives in ONE wiki page, keyed by page
      el.style.setProperty( 'display', 'block', 'important' );
    // name, and the page looks itself up. Adding a document to the
      el.style.setProperty( 'visibility', 'visible', 'important' );
    // library is then a single edit to that one page, with no content
      el.style.setProperty( 'content-visibility', 'visible', 'important' );
    // touched anywhere.
      el.removeAttribute( 'aria-hidden' );
    var MAP_PAGE = window.GR_GRANTHA_MAP_PAGE || 'MediaWiki:GranthaMap.json';
     } );
     var mapPromise = null;


     /* Mark headings as open, disable pointer events to prevent re-collapse */
     function granthaMap() {
    document.querySelectorAll( '.section-heading, .collapsible-heading' ).forEach( function(el) {
        if (mapPromise) return mapPromise;
      el.setAttribute( 'aria-expanded', 'true' );
        // Deliberately NOT cached in sessionStorage. It was, for six hours,
      el.style.setProperty( 'pointer-events', 'none', 'important' );
        // which meant that editing the map page changed nothing for anyone
    } );
        // whose tab had already read the old one - the exact failure you hit
        // while setting this up. mapPromise already collapses it to one
        // request per page load, and the browser's own HTTP cache handles
        // the rest; a stale map is far more expensive than a small fetch.
        try { sessionStorage.removeItem('gr-lib:map'); } catch (e) { }


    /* Hide collapse indicators */
        var url = (window.mw && mw.util && mw.util.getUrl)
    document.querySelectorAll(
            ? mw.util.getUrl(MAP_PAGE, { action: 'raw', ctype: 'application/json' })
      '.section-heading .indicator, .collapsible-heading .indicator'
            : '/index.php?title=' + encodeURIComponent(MAP_PAGE) + '&action=raw';
    ).forEach( function(el) {
        mapPromise = fetch(url, { credentials: 'same-origin' })
      el.style.setProperty( 'display', 'none', 'important' );
            .then(function (r) { return r.ok ? r.json() : {}; })
    } );
            .then(function (m) {
  }
                // Either a flat {page: id} object, or {pages, works,
                // locators} when names and division names are recorded too.
                m = m || {};
                if (!m.pages) m = { pages: m };
                m.works = m.works || {};
                m.locators = m.locators || {};
                try {
                    if (!Object.keys(m.works).length) {
                        console.info('[grantha] the map page has no "works" section, ' +
                            'so names must come from the library API. Re-run ' +
                            'sync_grantha_map.py to embed them.');
                    }
                } catch (e) { }
                return m;
            })
            .catch(function () { return {}; });      // no map page yet - fine
        return mapPromise;
    }


  /* Persistent observer — fight back when MF re-collapses */
    // page name -> library id, however it is known. The attribute wins,
  var _sectionObserver = null;
    // so a rebuilt page needs no map entry and a map entry needs no rebuild.
  function watchSections() {
    function idForPage(pageName, el) {
    if ( !document.body.classList.contains( 'skin-minerva' ) ) return;
        var own = el && el.getAttribute && el.getAttribute('data-grantha-id');
     if ( _sectionObserver ) return;
        if (own) return Promise.resolve(own);
        if (!pageName) return Promise.resolve('');
        return granthaMap().then(function (m) {
            var p = m.pages || {};
            return p[pageName] || p[String(pageName).replace(/_/g, ' ')] || '';
        });
     }


     var _timer = null;
     // Resolve this page and hand the result to anything listening (the
     _sectionObserver = new MutationObserver( function( mutations ) {
    // citation builder). Also fills in the visible title when the library
      var needExpand = mutations.some( function(m) {
    // and the page disagree, so the reader sees the real name.
         /* Watch for hidden attribute being added or style=display:none */
     function resolvePage() {
         if ( m.type === 'attributes' ) {
        var t = document.querySelector('.gr-doc-title');
          if ( m.attributeName === 'hidden' ) return true;
        if (!t) return;
          if ( m.attributeName === 'style' && m.target.style.display === 'none' ) return true;
        var page = (window.mw && mw.config.get('wgPageName')) || '';
         // Division names (adhyaya / valli / mantra) come from the same
        // map page, so a locator can be spelled out without a rebuild too.
         if (!t.getAttribute('data-locator-scheme')) {
            granthaMap().then(function (m) {
                var sc = (m.locators || {})[page] ||
                        (m.locators || {})[String(page).replace(/_/g, ' ')];
                if (sc) t.setAttribute('data-locator-scheme', sc);
            });
         }
         }
         return false;
         idForPage(page, t).then(function (gid) {
      } );
            if (!gid) return;
       if ( needExpand ) {
            t.setAttribute('data-grantha-id', gid);
        clearTimeout( _timer );
            return lookup(gid).then(function (g) {
        _timer = setTimeout( expandAllSections, 30 );
                if (!g) return;
      }
                // On a commentary's own page the heading still reads as the
    } );
                // docx colophon ("...विरचिता प्रकाशिका"). Name it the way the
                // blocks are named instead.
                if (t.getAttribute('data-parent-doc')) {
                    var lab = teekaLabel(g);
                    if (lab) t.textContent = lab;
                } else if (g.name) {
                    t.textContent = g.name;
                }
                if (g.name)       t.setAttribute('data-doc-title', g.name);
                if (g.nameIAST)  t.setAttribute('data-doc-title-iast', g.nameIAST);
                if (g.author)     t.setAttribute('data-author', g.author);
                if (g.authorIAST) t.setAttribute('data-author-iast', g.authorIAST);
                try {
                    window.dispatchEvent(new CustomEvent('gr-library-resolved', { detail: g }));
                } catch (e) { }
            });
        });
    }


     /* Observe the content area for attribute changes including hidden */
     // Fetching the whole catalogue is one request; fetching fifty records
     var contentEl = document.querySelector( '#mw-content-text, #mw-mf-page-center' );
    // one at a time is fifty. Past a handful, take the catalogue.
     if ( contentEl ) {
     var cataloguePromise = null;
      _sectionObserver.observe( contentEl, {
     function catalogue() {
         subtree: true, attributes: true,
        if (libraryDown) return Promise.resolve({});
         attributeFilter: ['style', 'class', 'hidden', 'aria-hidden']
         if (cataloguePromise) return cataloguePromise;
      } );
         cataloguePromise = fetch(API + '?limit=1000', {
            credentials: 'omit', headers: { 'Accept': 'application/json' }
        }).then(function (r) {
              if (!r.ok) { libraryDown = true; return null; }
              return r.json();
          })
          .then(function (j) {
              var list = (j && (j.granthas || j.data || j.results)) || [];
              var by = {};
              list.forEach(function (g) { var v = shape(g); if (v && v.id) { by[v.id] = v; cacheSet(v.id, v); } });
              return by;
          }).catch(function () { return {}; });
        return cataloguePromise;
     }
     }
  }


  /* Run on load */
    function lookupMany(ids) {
  if ( document.readyState === 'loading' ) {
        var want = [], seen = {};
    document.addEventListener( 'DOMContentLoaded', function() {
        (ids || []).forEach(function (i) { if (i && !seen[i]) { seen[i] = 1; want.push(i); } });
      setTimeout( function() { expandAllSections(); watchSections(); }, 100 );
        // lookup() answers from the map page without a network call, so
      setTimeout( expandAllSections, 500 );
        // resolving fifty works costs one shared map fetch, not fifty
      setTimeout( expandAllSections, 1200 );
        // requests. Only ids the map does not name reach the library.
    } );
        return Promise.all(want.map(lookup)).then(function (vals) {
  } else {
            var out = {};
    setTimeout( function() { expandAllSections(); watchSections(); }, 100 );
            want.forEach(function (i, n) { out[i] = vals[n]; });
    setTimeout( expandAllSections, 500 );
            return out;
    setTimeout( expandAllSections, 1200 );
        });
  }
    }
 
    // How a commentary is named on screen: its author, then the work.
    // "श्रीवादिराजतीर्थः: काठकोपनिषद्भाष्यप्रकाशिका" - because several
    // commentaries on one moola share a work-type name (प्रकाशिका,
    // विवरणम्, खण्डार्थः), and the author is what actually tells them
    // apart for a reader scanning down the page.
    var labelBySlug = {};
    function teekaLabel(g) {
        if (!g) return '';
        var name = g.name || g.nameIAST || '';
        var author = g.author || g.authorIAST || '';
        if (!author || !name) return name || author;
        // Nothing to prefix if the work already opens with the author.
        if (name.indexOf(author) === 0) return name;
        return author + ': ' + name;
    }
 
    // ── DOCUMENT LINKS ────────────────────────────────────────────────
    // The home page's cards were written by hand and carry the same
    // colophon labels the citation had to stop using - "प्रकाशिका",
    // "भाष्यम्" - which say what KIND of work it is, not which one. Any
    // link to a mapped page gets the library's name instead, so the
    // catalogue and the site agree without the page being edited.
    function relabelDocLinks(root) {
        var scope = root || document;
        var links = scope.querySelectorAll('.gr-home-grid a, .gr-doc-nav a, .gr-vadavali a');
        if (!links.length) return;
        granthaMap().then(function (m) {
            var pages = m.pages || {};
            var jobs = [];
            Array.prototype.forEach.call(links, function (a) {
                if (a.getAttribute('data-gr-relabelled')) return;
                var t = a.getAttribute('title') ||
                        decodeURIComponent((a.getAttribute('href') || '').split('#')[0]
                            .replace(/^.*\/(?:wiki\/)?/, ''));
                var gid = pages[t] || pages[String(t).replace(/_/g, ' ')];
                if (gid) jobs.push([a, gid]);
            });
            if (!jobs.length) return;
            lookupMany(jobs.map(function (j) { return j[1]; })).then(function (by) {
                jobs.forEach(function (j) {
                    var g = by[j[1]];
                    if (!g || !g.name) return;
                    j[0].setAttribute('data-gr-relabelled', '1');
                    j[0].setAttribute('data-grantha-id', j[1]);
                    if (g.authorIAST || g.author) {
                        j[0].setAttribute('title', (g.author || '') + ' — ' + g.name);
                    }
                    j[0].textContent = g.name;
                });
            });
        });
    }


}() );
    window.grLibraryVersion = '2026-09-30a';
    window.grLibrary = { lookup: lookup, lookupMany: lookupMany, catalogue: catalogue,
                        teekaLabel: teekaLabel,
                        labelFor: function (slug) { return labelBySlug[slug] || ''; },
                        idForPage: idForPage, map: granthaMap, api: API,
                        relabelDocLinks: relabelDocLinks };
    function boot() { resolvePage(); relabelDocLinks(); }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
    window.addEventListener('gr-new-content', function (e) {
        relabelDocLinks(e && e.detail && e.detail.container);
    });
}());

Latest revision as of 13:15, 30 September 2026

/* MediaWiki:Common.js */

(function () {

    var LS_SCRIPT_KEY = 'grantha_reader_script';
    var currentScript = 'deva';

    function devanagariToTamil(text) {
        var CONSONANTS = {
            'क': 'க', 'ख': 'க', 'ग': 'க', 'घ': 'க', 'ङ': 'ங',
            'च': 'ச', 'छ': 'ச', 'ज': 'ஜ', 'झ': 'ஜ', 'ञ': 'ஞ',
            'ट': 'ட', 'ठ': 'ட', 'ड': 'ட', 'ढ': 'ட', 'ण': 'ண',
            'त': 'த', 'थ': 'த', 'द': 'த', 'ध': 'த', 'न': 'ந',
            'प': 'ப', 'फ': 'ப', 'ब': 'ப', 'भ': 'ப', 'म': 'ம',
            'य': 'ய', 'र': 'ர', 'ल': 'ல', 'ळ': 'ள', 'व': 'வ',
            'श': 'ஶ', 'ष': 'ஷ', 'स': 'ஸ', 'ह': 'ஹ'
        };
        var DIACRITICS = {
            'ा': 'ா', 'ि': 'ி', 'ी': 'ீ', 'ु': 'ு', 'ू': 'ூ',
            'े': 'ே', 'ै': 'ை', 'ो': 'ோ', 'ौ': 'ௌ'
        };
        var VOWELS = {
            'अ': 'அ', 'आ': 'ஆ', 'इ': 'இ', 'ई': 'ஈ', 'उ': 'உ', 'ऊ': 'ஊ',
            'ए': 'ஏ', 'ऐ': 'ஐ', 'ओ': 'ஓ', 'औ': 'ஔ', 'ऽ': 'ऽ'
        };
        var MISC = {
            'ं':'ம்', 'ः':':', 'ँ':'ம்', 'ॐ':'ௐ',
            '०': '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 (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) {
        var CONSONANTS = {
            'क': 'k', 'ख': 'kh', 'ग': 'g', 'घ': 'gh', 'ङ': 'ṅ',
            'च': '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 = {
        kn: {
            'अ': 'ಅ', 'आ': 'ಆ', 'इ': 'ಇ', 'ई': 'ಈ', 'उ': 'ಉ', 'ऊ': 'ಊ', 'ऋ': 'ಋ', 'ॠ': 'ೠ',
            'ए': 'ಏ', 'ऐ': 'ಐ', 'ओ': 'ಓ', 'औ': 'ಔ', 'ऽ': 'ಽ',
            'क': 'ಕ', 'ख': 'ಖ', 'ग': 'ಗ', 'घ': 'ಘ', 'ङ': 'ಙ',
            'च': 'ಚ', 'छ': 'ಛ', 'ज': 'ಜ', 'झ': 'ಝ', 'ञ': 'ಞ',
            'ट': 'ಟ', 'ठ': 'ಠ', 'ड': 'ಡ', 'ढ': 'ಢ', 'ण': 'ಣ',
            'त': 'ತ', 'थ': 'ಥ', 'द': 'ದ', 'ध': 'ಧ', 'न': 'ನ',
            'प': 'ಪ', 'फ': 'ಫ', 'ब': 'ಬ', 'भ': 'ಭ', 'म': 'ಮ',
            'य': 'ಯ', 'र': 'ರ', 'ल': 'ಲ', 'ळ': 'ಳ', 'व': 'ವ',
            'श': 'ಶ', 'ष': 'ಷ', 'स': 'ಸ', 'ह': 'ಹ',
            'ा': 'ಾ', 'ि': 'ಿ', 'ी': 'ೀ', 'ु': 'ು', 'ू': 'ೂ',
            'ृ': 'ೃ', 'ॄ': 'ೄ', 'े': 'ೇ', 'ै': 'ೈ', 'ो': 'ೋ', 'ौ': 'ೌ',
            'ं': 'ಂ', 'ः': 'ಃ', '्': '್', 'ँ': 'ಁ',
            '०': '೦', '१': '೧', '२': '೨', '३': '೩', '४': '೪',
            '५': '೫', '६': '೬', '७': '೭', '८': '೮', '९': '೯'
        },
        te: {
            'अ': 'అ', 'आ': 'ఆ', 'इ': 'ఇ', 'ई': 'ఈ', 'उ': 'ఉ', 'ऊ': 'ఊ', 'ऋ': 'ఋ', 'ॠ': 'ౠ',
            'ए': 'ఏ', 'ऐ': 'ఐ', 'ओ': 'ఓ', 'औ': 'ఔ', 'ऽ': 'ఽ',
            'क': 'క', 'ख': 'ఖ', 'ग': 'గ', 'घ': 'ఘ', 'ङ': 'ఙ',
            'च': 'చ', 'छ': 'ఛ', 'ज': 'జ', 'झ': 'ఝ', 'ञ': 'ఞ',
            'ट': 'ట', 'ठ': 'ఠ', 'ड': 'డ', 'ढ': 'ఢ', 'ण': 'ణ',
            'त': 'త', 'थ': 'థ', 'द': 'ద', 'ध': 'ధ', 'न': 'న',
            'प': 'ప', 'फ': 'ఫ', 'ब': 'బ', 'भ': 'భ', 'म': 'మ',
            'य': 'య', 'र': 'ర', 'ल': 'ల', 'ळ': 'ళ', 'व': 'వ',
            'श': 'శ', 'ष': 'ష', 'स': 'స', 'ह': 'హ',
            'ा': 'ా', 'ि': 'ి', 'ी': 'ీ', 'ु': 'ు', 'ू': 'ూ',
            'ृ': 'ృ', 'ॄ': 'ౄ', 'े': 'ే', 'ै': 'ై', 'ो': 'ో', 'ौ': 'ౌ',
            'ं': 'ం', 'ः': 'ః', '्': '్', 'ँ': 'ఁ',
            '०': '౦', '१': '౧', '२': '౨', '३': '౩', '४': '౪',
            '५': '౫', '६': '౬', '७': '౭', '८': '౮', '९': '౯'
        }
    };

    var PRE = [
        [/ङ्क/g, 'ंक'], [/ङ्ख/g, 'ंख'], [/ङ्ग/g, 'ंग'], [/ङ्घ/g, 'ंघ'],
        [/ञ्च/g, 'ंच'], [/ञ्ज/g, 'ंज'], [/ण्ट/g, 'ंट'], [/ण्ड/g, 'ंड'],
        [/न्त/g, 'ंत'], [/न्द/g, 'ंद'], [/म्ब/g, 'ंब'], [/म्भ/g, 'ंभ']
    ];

    function transliterateText(text, script) {
        if (script === 'en') return devanagariToIAST(text);
        if (script === 'ta') return devanagariToTamil(text);
        var map = SCRIPT_MAP[script];
        if (!map) return text;
        var t = text;
        PRE.forEach(function (p) { t = t.replace(p[0], p[1]); });
        return Array.from(t).map(function (ch) {
            return map[ch] !== undefined ? map[ch] : ch;
        }).join('');
    }

    var translatableSpans = [];

    function tagTextNodes() {
        var content = document.querySelector('.mw-parser-output');
        if (content) {
            var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
            var nodes = [];
            while (walker.nextNode()) nodes.push(walker.currentNode);
            nodes.forEach(function (node) {
                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
    // .vector-toc-numb span, which Common.css hides. Tagging the whole
    // .vector-toc-text meant transliteration replaced its contents with
    // one flat string — number included — so switching to Tamil or any
    // other script made the numbering appear. Only the label is tagged.
    function tagTocEntry(textEl) {
        if (!textEl || textEl.hasAttribute('data-deva')) return;
        var label = textEl.querySelector('[data-deva]');
        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
    // swap the reading area onto --font-indic while a transliteration is
    // showing: most rules here name Adishila directly, which has no Tamil,
    // Kannada or Telugu letters, so without this the switched text fell back
    // to whatever serif the visitor's machine happened to have — the reason
    // it looked uneven from one line to the next.
    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 renameTocTitle() {
        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 removeTocBeginning() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        var el = toc.querySelector('#vector-toc-beginning');
        if (!el) {
            var items = toc.querySelectorAll('.vector-toc-list-item');
            for (var i = 0; i < items.length; i++) {
                var a = items[i].querySelector('a');
                if (a) {
                    var href = a.getAttribute('href') || '';
                    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.
    // Hiding them with CSS was not enough: the script switcher replaces an
    // entry's contents with one flat string, which turned the hidden number
    // into ordinary text — so it reappeared in Tamil, Kannada and the rest.
    // They are also wrong here, since this contents list leaves entries out.
    var NUMBERED = /^\s*\d+(?:\.\d+)*[.)]?\s+([\s\S]*\S)\s*$/;
    function stripTocNumbers() {
        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.
    var _numbObserver = null;
    function watchTocNumbers() {
        var toc = document.querySelector('.vector-toc');
        if (!toc || _numbObserver || !window.MutationObserver) return;
        var pending = false;
        _numbObserver = new MutationObserver(function () {
            if (pending) return;
            pending = true;
            setTimeout(function () { pending = false; stripTocNumbers(); }, 50);
        });
        _numbObserver.observe(toc, { childList: true, subtree: true, characterData: true });
    }

    function expandTocSections() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        toc.querySelectorAll('.vector-toc-list-item-collapsed').forEach(function (li) {
            li.classList.remove('vector-toc-list-item-collapsed');
        });
    }

    function injectTocDocNav() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        if (document.getElementById('gr-toc-doc-nav')) return;
        var artPath = (window.mw && mw.config && mw.config.get('wgArticlePath')) || '/wiki/$1';
        var pageTitle = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
        var teekaPage = document.querySelector('.gr-teeka-page');
        var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || '') : '';
        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 _tocActiveId = null;

    // NEW: moved out of attachHeadingObserver's own local scope so they
    // can be reused both by the scroll observer AND by a direct trigger
    // for search-result clicks — was previously only reachable from
    // inside attachHeadingObserver's own IntersectionObserver callback.
    // _tocActiveId (was _activeId, local to attachHeadingObserver) is
    // module-level now for the same reason — both callers need to share
    // the same "what's currently active" state, not track it separately.
    function getTocLink(id) {
        var toc = document.querySelector('.vector-toc');
        if (!toc) return null;
        return toc.querySelector('a[href="#' + CSS.escape(id) + '"]');
    }
    function getTocLi(id) {
        var a = getTocLink(id);
        return a ? a.closest('.vector-toc-list-item') : null;
    }
    // Highlighting is class-only (styled in Common.css §6):
    //   .gr-toc-current  — the one entry being read, on the <a> itself
    //   .gr-toc-in-path  — its parent sections, marked on the <li>
    // Previously this set inline colours on the active item AND on every
    // element inside it, so a section with sub-sections lit up whole.
    function clearActiveToc() {
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        toc.querySelectorAll('.gr-toc-current').forEach(function (el) { el.classList.remove('gr-toc-current'); });
        toc.querySelectorAll('.gr-toc-in-path').forEach(function (el) { el.classList.remove('gr-toc-in-path'); });
    }
    function ownLink(li) {
        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
    // measure the whole <li>, which for a section with sub-sections is as
    // tall as the entire branch — so "centre it" landed somewhere inside
    // the NEXT section. It now measures the link itself, and finds the
    // 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
    // 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? ────────────────────────────────
    // Was an IntersectionObserver with a shrunken root band. Two faults
    // came out of that, both reported as "the highlight is wrong":
    //   • right after jumping to a heading, that heading sits ABOVE the
    //     band, so nothing was "visible" and the fallback picked the
    //     PREVIOUS heading — clicking a sub-section highlighted its parent;
    //   • once every heading was outside the band, no intersection ever
    //     changed again, so the observer stopped firing and the highlight
    //     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() {
        var y = window.pageYOffset || document.documentElement.scrollTop || 0;
        _tocTops = _tocHeadings.map(function (h) { return h.getBoundingClientRect().top + y; });
        _tocMeasuredHeight = document.documentElement.scrollHeight;
    }
    function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
    // The line down the page that decides "which section am I reading":
    // 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
    // there. A plain scrollIntoView lands short or long whenever the page
    // reflows just after the jump — a web font finishing, a commentary
    // being interleaved, the match itself being wrapped in a highlight —
    // 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,
    // or a #hash in the URL) — that heading wins until they scroll on.
    function pinActive(id) {
        if (!id) return;
        _pin = { id: id, y: scrollY(), until: Date.now() + 900 };
        setActiveToc(id, true);          // open its section if collapsed
        scheduleRecheck(950);
    }
    var _recheck = null;
    function scheduleRecheck(ms) {
        if (_recheck) return;
        _recheck = setTimeout(function () { _recheck = null; updateActiveFromScroll(); }, ms);
    }
    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() {
        if (_isNoTocPage()) return;
        if (_headingObserver) return;
        var content = document.querySelector('.mw-parser-output');
        if (!content) return;
        if (!document.querySelector('.vector-toc')) return;
        // 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
    // for the scroll observer to catch up after the jump.
    function attachTocClickHandler(toc) {
        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() {
        if (_isNoTocPage()) return;
        var toc = document.querySelector('.vector-toc');
        if (!toc) return;
        removeTocBeginning();
        removeTocTeekaEntry();
        stripTocNumbers();
        watchTocNumbers();
        renameTocTitle();
        expandTocSections();
        injectTocDocNav();
        attachTocClickHandler(toc);
        attachHeadingObserver();
    }

    function init() {
        var HIDE_IDS = ['vector-appearance', 'vector-appearance-pinned-container', 'vector-appearance-unpinned-container'];
        function removeHiddenEls() {
            HIDE_IDS.forEach(function (id) { var el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); });
            var pt = document.getElementById('vector-page-tools') || document.querySelector('.vector-page-tools-pinned-container');
            if (pt) pt.querySelectorAll('[aria-controls="vector-appearance"]').forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
        }
        removeHiddenEls();
        (function detectTeekaMode() {
            var tp = document.querySelector('.gr-teeka-page');
            if (!tp) return;
            var primary = tp.getAttribute('data-primary') || '';
            var artPath = (window.mw && mw.config.get('wgArticlePath')) || '/wiki/$1';
            var mainUrl = artPath.replace('$1', primary);
            var refParam = window.location.search.match(/[?&]ref=([01])/);
            if (refParam) { document.body.classList.add(refParam[1] === '1' ? 'gr-ref-mode' : 'gr-standalone'); return; }
            var ref = document.referrer || '';
            document.body.classList.add((ref && primary && ref.indexOf(mainUrl) !== -1) ? 'gr-ref-mode' : 'gr-standalone');
        }());
        if (window.MutationObserver) {
            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) {
        var script = e && e.detail && e.detail.script;
        if (script) applyScript(script);
    });

    var _translitBusy = false;
    function tagNewContent(container) {
        if (!container || !container.nodeType) return;
        _translitBusy = true;
        try {
            var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
            var nodes = [];
            while (walker.nextNode()) nodes.push(walker.currentNode);
            nodes.forEach(function (node) {
                var p = node.parentNode;
                if (!p || (p.hasAttribute && p.hasAttribute('data-deva'))) return;
                if (p.closest && (p.closest('.gr-controls') || p.closest('.mw-editsection')
                                  || 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 = 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) {
        var wrap = document.createElement('div');
        wrap.id = 'gr-header-user';
        wrap.style.cssText = 'position:relative;display:inline-block;margin-right:4px;';

        var trigger = document.createElement('button');
        trigger.id = 'gr-header-user-trigger';
        trigger.type = 'button';
        trigger.setAttribute('aria-haspopup', 'true');
        trigger.setAttribute('aria-expanded', 'false');
        trigger.style.cssText = [
            'display:inline-flex', 'align-items:center', 'gap:5px',
            'color:rgba(255,255,255,0.88)', 'font-size:0.88em',
            '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');
        menu.id = 'gr-header-user-menu';
        menu.setAttribute('role', 'menu');
        menu.style.cssText = [
            'display:none', 'position:absolute', 'top:100%', 'right:0', 'margin-top:4px',
            'min-width:150px', 'background:#fff', 'border-radius:6px',
            'box-shadow:0 4px 16px rgba(0,0,0,0.18)', 'overflow:hidden', 'z-index:1000'
        ].join(';');

        var itemStyle = 'display:block;padding:10px 16px;font-size:0.9em;color:#2c1810;text-decoration:none;font-family:system-ui,sans-serif;background:#fff;';
        function makeMenuItem(href, label, id) {
            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) {
            e.stopPropagation();
            setOpen(menu.style.display === 'none');
        });
        document.addEventListener('click', function (e) {
            if (!wrap.contains(e.target)) setOpen(false);
        });

        menu.querySelector('#gr-header-logout').addEventListener('click', function (e) {
            e.preventDefault();
            if (window.mw && mw.user && mw.user.tokens) {
                var t = mw.user.tokens.get('csrfToken');
                if (t) {
                    var script = (mw.util && mw.util.wikiScript) ? mw.util.wikiScript() : '/index.php';
                    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 {
                location.href = '/index.php?title=Special:UserLogout';
            }
        });

        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);
                while (walker.nextNode() && !found) {
                    var node = walker.currentNode;
                    var txt = node.textContent || '';
                    if (txt.indexOf(snippet) === -1) continue;
                    var idx2 = txt.indexOf(snippet);
                    var mark2 = document.createElement('mark');
                    mark2.className = 'gr-ullekha-highlight';
                    mark2.style.cssText = 'background:#fff176;border-radius:2px;padding:0 2px;';
                    mark2.textContent = txt.slice(idx2, Math.min(idx2 + needle.length, txt.length));
                    var p = node.parentNode;
                    p.insertBefore(document.createTextNode(txt.slice(0, idx2)), node);
                    p.insertBefore(mark2, node);
                    p.insertBefore(document.createTextNode(txt.slice(idx2 + mark2.textContent.length)), node);
                    p.removeChild(node);
                    setTimeout(function () { grScrollTo(mark2); if (window.grHighlightTocForElement) window.grHighlightTocForElement(mark2); }, 100);
                    found = true;
                }
            }
        }
        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 ──────────────────────────────────── */
(function () {

    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() {
        var stored;
        try {
            stored = JSON.parse(sessionStorage.getItem('gr_search_hl') || 'null');
        } catch (e) { return; }
        if (!stored || !stored.query) return;

        var currentPath = window.location.pathname;
        var storedPath = stored.pathname || '';

        function normPath(p) { return decodeURIComponent(p).replace(/\/+$/, ''); }
        if (storedPath && normPath(storedPath) !== normPath(currentPath)) {
            try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }
            return;
        }

        var query = stored.query.trim();
        if (!query) return;

        try { sessionStorage.removeItem('gr_search_hl'); } catch (e) { }

        var delays = [0, 200, 600];
        delays.forEach(function (ms) {
            setTimeout(function () {
                if (document.querySelector('.gr-search-hl')) return;
                highlightText(query);
            }, ms);
        });
    }

    // 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 highlightText(query) {
        var content = document.querySelector('#mw-content-text .mw-parser-output');
        if (!content) return;

        var raw = query.replace(/^"|"$/g, '').trim();
        if (!raw) return;

        var patterns = [];
        patterns.push(escapeRegex(raw));
        raw.split(/\s+/).forEach(function (w) {
            if (w.length >= 2) patterns.push(escapeRegex(w));
        });

        var matched = false;
        for (var pi = 0; pi < patterns.length; pi++) {
            var re;
            try { re = new RegExp('(' + patterns[pi] + ')', 'gi'); }
            catch (e) { continue; }
            var count = wrapMatches(content, re);
            if (count > 0) { matched = true; break; }
        }

        if (!matched) return;

        var first = document.querySelector('.gr-search-hl');
        if (first) {
            grScrollTo(first);
            first.classList.add('gr-search-hl-pulse');
            setTimeout(function () { first.classList.remove('gr-search-hl-pulse'); }, 2000);
            // NEW: directly trigger the TOC to expand and highlight the
            // right section, rather than relying solely on the scroll
            // observer to catch up on its own. This is the actual fix —
            // confirmed the helper against a realistic multi-section
            // fragment before wiring it in here.
            if (window.grHighlightTocForElement) window.grHighlightTocForElement(first);
        }

        showDismissBar(query);
    }

    function escapeRegex(s) {
        return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function wrapMatches(root, re) {
        var count = 0;
        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 = [];
        var node;
        while ((node = walker.nextNode())) nodes.push(node);

        nodes.forEach(function (textNode) {
            var val = textNode.nodeValue;
            if (!re.test(val)) return;
            re.lastIndex = 0;

            var frag = document.createDocumentFragment();
            var last = 0;
            var m;
            while ((m = re.exec(val)) !== null) {
                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;
    }

    function showDismissBar(query) {
        var existing = document.getElementById('gr-hl-bar');
        if (existing) existing.remove();

        var count = document.querySelectorAll('.gr-search-hl').length;
        if (!count) return;

        var isMob = window.innerWidth < 768 || !!document.getElementById('mw-mf-viewport');
        var bar = document.createElement('div');
        bar.id = 'gr-hl-bar';

        if (isMob) {
            bar.className = 'gr-search-hl-mobile';

            bar.innerHTML =
                '<button id="gr-hl-results" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-search" aria-hidden="true"></span>' +
                '<span>' + count + ' match' + (count === 1 ? '' : 'es') + '</span></button>' +
                '<button id="gr-hl-prev" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-prev" aria-hidden="true"></span>' +
                '<span>Prev</span></button>' +
                '<button id="gr-hl-next" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-next" aria-hidden="true"></span>' +
                '<span>Next</span></button>' +
                '<button id="gr-hl-dismiss" class="gr-search-bar-btn">' +
                '<span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>' +
                '<span>Close</span></button>';
        } else {
            bar.style.cssText = [
                'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200',
                'background:#b5451b', 'color:#fff', 'padding:10px 16px',
                'display:flex', 'align-items:center', 'justify-content:space-between',
                'font-family:system-ui,sans-serif', 'font-size:14px',
                'box-shadow:0 -2px 8px rgba(0,0,0,0.2)'
            ].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');
            db.id = 'gr-hl-dismiss'; db.textContent = '✕ Close';
            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;';
            bar.appendChild(db);
        }

        document.body.appendChild(bar);

        var hlEls = Array.from(document.querySelectorAll('.gr-search-hl'));
        var current = 0;

        function goTo(idx) {
            hlEls.forEach(function (el) { el.classList.remove('gr-search-hl-current'); });
            current = ((idx % hlEls.length) + hlEls.length) % hlEls.length;
            hlEls[current].classList.add('gr-search-hl-current');
            grScrollTo(hlEls[current]);
            // NEW: same TOC sync on Prev/Next navigation within the
            // 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);
        };
    }

    function clearHighlights() {
        document.querySelectorAll('.gr-search-hl').forEach(function (span) {
            var p = span.parentNode; if (!p) return;
            while (span.firstChild) p.insertBefore(span.firstChild, span);
            p.removeChild(span);
        });
    }

    function escHtml(s) {
        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }

    function injectHighlightCSS() {
        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();

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

    if (window.mw) {
        mw.hook('wikipage.content').add(function () {
            setTimeout(applyHighlight, 100);
        });
    }

    window.grStoreSearchHL = storeQueryForLink;

}());

/* ═══════════════════════════════════════════════════════════════
   Mobile addon — only runs on Minerva (mobile) skin
   ═══════════════════════════════════════════════════════════════ */
(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() {
        if (document.getElementById('gr-mob-doc-nav')) return;
        var pageName = (window.mw && mw.config && mw.config.get('wgPageName')) || '';
        if (pageName === 'Main_Page' || !pageName) return;
        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, '/')); }
        var teekaPage = document.querySelector('.gr-teeka-page');
        var primarySlug = teekaPage ? (teekaPage.getAttribute('data-primary') || pageName.split('/')[0]) : pageName.split('/')[0];
        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 showMoolam = !!teekaPage || hasMoolaPage, showUllekha = hasUllekhaPage || !!teekaPage;
        if (!showMoolam && !showUllekha) return;
        var nav = document.createElement('div'); nav.id = 'gr-mob-doc-nav';
        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() {
        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
    // different teekas are interleaved together and need to stay
    // visually distinguishable. CHANGED: was a 30-color palette
    // transcribed from a screenshot - confirmed directly that at least
    // one entry (#F6C500, a saturated gold) wasn't actually light at
    // all, which is very likely what read as "really dark" in practice.
    // Replaced with 10 colors in the site's own warm palette (matching
    // 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];
    }

    function readLS(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
    function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }

    function fetchAllSubpages(rootTitle) {
        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();
    }

    // One request for ALL of them, not one each. The API takes up to 50
    // titles at a time, and a moola page now shows every commentary by
    // default - so what used to be one round trip became five or ten,
    // fired together but each with its own latency, and the commentaries
    // appeared well after the text. Batching makes it a single request
    // whatever the page carries. maxage/smaxage let the browser and the
    // CDN reuse the response, so moving between chapters of one grantha
    // does not re-fetch the same commentaries.
    var teekaJsonCache = {};

    function fetchTeekaJsons(titles) {
        var want = titles.filter(function (t) { return !(t in teekaJsonCache); });
        if (!want.length) return $.Deferred().resolve(teekaJsonCache).promise();

        var batches = [];
        for (var i = 0; i < want.length; i += 40) batches.push(want.slice(i, i + 40));

        return $.when.apply($, batches.map(function (batch) {
            return $.ajax({
                url: mw.util.wikiScript('api'),
                data: {
                    action: 'query',
                    titles: batch.map(function (t) { return 'JSON:' + t; }).join('|'),
                    prop: 'revisions', rvslots: 'main', rvprop: 'content',
                    format: 'json', formatversion: 2,
                    maxage: 300, smaxage: 300
                },
                dataType: 'json'
            }).then(function (data) {
                var pages = (data.query && data.query.pages) || [];
                pages.forEach(function (page) {
                    // Match the response back to the title we asked for:
                    // the API may normalise or reorder, so never pair by
                    // index. Strip the namespace the same way it was added.
                    var title = (page.title || '').replace(/^JSON:/, '');
                    if (page.missing) { teekaJsonCache[title] = null; return; }
                    var content = page.revisions && page.revisions[0] &&
                        page.revisions[0].slots && page.revisions[0].slots.main &&
                        page.revisions[0].slots.main.content;
                    if (!content) { teekaJsonCache[title] = null; return; }
                    try {
                        var parsed = JSON.parse(content);
                        parsed._title = title;
                        teekaJsonCache[title] = parsed;
                    } catch (e) { teekaJsonCache[title] = null; }
                });
            }).catch(function () {
                batch.forEach(function (t) { if (!(t in teekaJsonCache)) teekaJsonCache[t] = null; });
            });
        })).then(function () {
            // Anything the response never mentioned is recorded as absent,
            // so a later call does not ask for it again.
            want.forEach(function (t) { if (!(t in teekaJsonCache)) teekaJsonCache[t] = null; });
            return teekaJsonCache;
        });
    }

    // Finds this document's commentary subpages - filters to the site's
    // own established commentary-path convention (Vyakhya/Bhashya are
    // shown; Moola/Ullekha are never commentary content).
    function findCommentarySubpages(rootTitle) {
        return $.Deferred(function (d) {
            fetchAllSubpages(rootTitle).then(function (all) {
                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}].
    // 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,
                    teekaAuthor: td.doc.author || '',
                    teekaPage: td.doc.pageTitle || td._title,
                    teekaGranthaId: td.doc.granthaId || '',
                    teekaSlug: slug,
                    // join('\n') - each entry of `lines` is a printed line,
                    // and joining with a space ran a two-pada shloka into
                    // one. The .shloka-line markup inside already carries
                    // the break where the converter put one; this only
                    // matters for older documents that have none.
                    text: (b.lines || []).join('\n'),
                    footnotes: b.footnotes || [],
                    blockId: b.id
                });
            });
        });
        return index;
    }

    function injectTeekaInMoola(index) {
        var injectedAny = false;
        Object.keys(index).forEach(function (verseId) {
            var $verseEl = $(document.getElementById(verseId));
            if (!$verseEl.length) return;
            var entries = index[verseId];
            var $wrap = $('<div>').addClass('gr-injected-teeka-group');
            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)
                    // Citation metadata: a bookmark taken inside an
                    // interleaved commentary must cite THAT work and its
                    // author, and link to the commentary's own page - not
                    // to the moola whose page it happens to be shown on.
                    .attr('data-cite-author', entry.teekaAuthor || '')
                    .attr('data-cite-title', entry.teekaTitle || '')
                    .attr('data-cite-page', entry.teekaPage || '')
                    .attr('data-cite-grantha-id', entry.teekaGranthaId || '')
                    .attr('id', entry.blockId)
                    .css('background', color);
                if (window.grLibrary) {
                    (function (el, gid, page) {
                        // The id may come from the teeka's own JSON (if that
                        // document has been rebuilt) or from the map page.
                        // Either way the commentary cites ITS work, not the
                        // moola it is displayed inside.
                        var got = gid ? Promise.resolve(gid)
                                      : window.grLibrary.idForPage(page, null);
                        got.then(function (id) {
                            if (!id) return null;
                            el.setAttribute('data-cite-grantha-id', id);
                            return window.grLibrary.lookup(id);
                        }).then(function (g) {
                            if (!g) return;
                            if (g.name)       el.setAttribute('data-cite-title', g.name);
                            if (g.nameIAST)   el.setAttribute('data-cite-title-iast', g.nameIAST);
                            if (g.author)     el.setAttribute('data-cite-author', g.author);
                            if (g.authorIAST) el.setAttribute('data-cite-author-iast', g.authorIAST);
                            var h = el.querySelector('.teeka-title');
                            var label = teekaLabel(g);
                            if (h && label) h.textContent = label;
                            var slug = el.getAttribute('data-teeka-slug');
                            if (slug && label) {
                                labelBySlug[slug] = label;
                                // A footnote section for this commentary may
                                // already be on screen; rename it too.
                                document.querySelectorAll(
                                    '.gr-teeka-notes-group[data-teeka-slug="' + slug +
                                    '"] .gr-teeka-notes-title').forEach(function (t) {
                                        t.textContent = label;
                                    });
                            }
                        });
                    }($entry[0], entry.teekaGranthaId, entry.teekaPage));
                }
                $('<div>').addClass('teeka-title').text(entry.teekaTitle).appendTo($entry);
                $('<div>').addClass('teeka-body').html(entry.text).appendTo($entry);
                // A teeka's footnotes live in ITS document, not in this
                // page's own footnote list - so the highlighted words came
                // through the injection but nothing on this page knew what
                // they pointed at, and clicking them did nothing. Register
                // them here, under the commentary's title, for the note
                // card and the right-hand panel to read.
                if (entry.footnotes && entry.footnotes.length && window.grFootnoteRegistry) {
                    window.grFootnoteRegistry.add(entry.teekaSlug, entry.teekaTitle, entry.footnotes);
                }
                $wrap.append($entry);
            });
            // After the bhashya, not between the verse and it. The moola
            // page prints verse then bhashya as one unit, and inserting
            // straight after the verse element split them apart, putting
            // the commentaries ahead of the bhashya they comment on.
            // The bhashya-collection for a verse carries id
            // "bhashya-<verse oldKey>"; where a page has none, the verse
            // itself is still the anchor.
            var anchor = $verseEl;
            var coll = document.getElementById('bhashya-' + verseId);
            if (coll && coll.compareDocumentPosition($verseEl[0]) & Node.DOCUMENT_POSITION_PRECEDING) {
                anchor = $(coll);
            } else {
                // Some pages emit the bhashya as a sibling block without
                // the collection wrapper - walk forward over any blocks
                // that belong to this same verse.
                var n = $verseEl[0].nextElementSibling;
                while (n && n.getAttribute &&
                       (n.getAttribute('data-parent-verse') === verseId ||
                        n.id === 'bhashya-' + verseId)) {
                    anchor = $(n);
                    n = n.nextElementSibling;
                }
            }
            anchor.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();
        if (window.grFootnoteRegistry) window.grFootnoteRegistry.clearInjected();
    }

    // CRITICAL FIX: confirmed directly this feature was corrupting saved
    // page content. QuickEdit is a contenteditable-based editor operating
    // on the page's LIVE DOM - since this feature injects real elements
    // (interleaved teeka blocks, inline checkboxes wrapping links) into
    // that same DOM, QuickEdit's save/serialization pipeline had no way
    // 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 -
    // Common.css targets .mw-body-content[contenteditable="true"]
    // directly, confirming this is the real, load-order-independent
    // 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) {
        try { p = decodeURIComponent(p); } catch (e) { }
        return p.replace(/_/g, ' ').replace(/\/+$/, '');
    }

    // ── State ──────────────────────────────────────────────────────
    // Not seeded from storage: every page load starts with all
    // commentaries on (see where availableList is filled). Unchecking one
    // still writes to storage, which keeps the control's own state
    // consistent within the page and leaves the value there for anything
    // else that reads it, but it is not what decides the initial view.
    var selectedTeekaSlugs = {};
    var teekaIndex = null;
    var availableList = []; // [{slug, title, subItems: [{slug, title}, ...]}]
    var lastAugmentArgs = null; // {titles, docs} - cached to restore inline checkboxes after a cancelled edit without re-fetching
    var checkboxesBySlug = {};
    var readyCallbacks = [];
    var changeCallbacks = [];
    var visibilityCallbacks = [];
    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); }

    function renderInterleave() {
        removeTeekaInMoola();
        if (!teekaIndex || !Object.keys(selectedTeekaSlugs).length) return;
        var filtered = {};
        Object.keys(teekaIndex).forEach(function (verseId) {
            var entries = teekaIndex[verseId].filter(function (e) { return selectedTeekaSlugs[e.teekaSlug]; });
            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) {
        if (checked) selectedTeekaSlugs[slug] = true; else delete selectedTeekaSlugs[slug];
        writeLS(LS_TEEKA_KEY, JSON.stringify(Object.keys(selectedTeekaSlugs)));
        (checkboxesBySlug[slug] || []).forEach(function ($cb) { $cb.prop('checked', checked); });
        renderInterleave();
        changeCallbacks.forEach(function (cb) { cb(Object.keys(selectedTeekaSlugs)); });
    }

    // Augments existing <a> links pointing to a known commentary subpage
    // 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
    // notifies subscribers only when that overall state actually flips -
    // verified in isolation before wiring in here. Drives whether
    // readerToolbar.js's "Commentaries" control needs to be shown as a
    // fallback (inline list scrolled out of view) or can stay hidden
    // (reader can already see and use the inline checkboxes).
    function observeInlineVisibility(checkboxEls) {
        if (!checkboxEls.length || !window.IntersectionObserver) return;
        var visibleSet = new Set();
        var obs = new IntersectionObserver(function (entries) {
            entries.forEach(function (entry) {
                var id = entry.target.__grTeekaId;
                if (entry.isIntersecting) visibleSet.add(id); else visibleSet.delete(id);
            });
            var anyVisible = visibleSet.size > 0;
            if (anyVisible !== inlineListVisible) {
                inlineListVisible = anyVisible;
                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() {
        // Runs unconditionally, before any of the early-return checks
        // below - harmless as a no-op teardown on pages where nothing
        // 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');
        findCommentarySubpages(rootTitle).then(function (subpages) {
            if (!subpages.length) { isReady = true; readyCallbacks.forEach(function (cb) { cb([]); }); return; }
            fetchTeekaJsons(subpages).then(function (cache) {
                var fetched = subpages.map(function (t) { return cache[t] || null; });
                // Pair title+doc together FIRST, then filter as one unit -
                // filtering `docs` separately from `subpages` and passing
                // both (now misaligned) arrays elsewhere was a confirmed
                // real bug in an earlier version of this feature.
                var pairs = subpages.map(function (title, i) { return { title: title, doc: fetched[i] }; })
                    .filter(function (p) {
                        if (!p.doc) console.warn('[grTeekaInterleave] Could not load JSON:' + p.title);
                        return !!p.doc;
                    });

                // Reading order = the order the book prints the
                // commentaries in, which a document states as doc.order.
                // findCommentarySubpages gets its list from
                // action=query&list=allpages, and that is ALPHABETICAL BY
                // TITLE - so Vadiraja came before Vyasatirtha, and the
                // checkbox list, the colour slots and the interleaved
                // blocks all inherited that same wrong order, since every
                // one of them is derived from this array. Sorting here
                // fixes all three at once. A document with no doc.order
                // (anything published before this field existed) keeps its
                // existing relative position rather than jumping to the
                // front, so mixed pages stay stable.
                pairs = pairs.map(function (p, i) { return { p: p, i: i }; })
                    .sort(function (a, b) {
                        var ao = a.p.doc && a.p.doc.doc && a.p.doc.doc.order;
                        var bo = b.p.doc && b.p.doc.doc && b.p.doc.doc.order;
                        if (typeof ao === 'number' && typeof bo === 'number' && ao !== bo) return ao - bo;
                        if (typeof ao === 'number' && typeof bo !== 'number') return -1;
                        if (typeof bo === 'number' && typeof ao !== 'number') return 1;
                        return a.i - b.i;
                    })
                    .map(function (x) { return x.p; });

                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.
                    // Every commentary is on at the start of EVERY page
                    // load, not only a reader's first visit. The moola is
                    // published together with its commentaries and the
                    // complete text is what a reader should land on; the
                    // checkboxes are for narrowing it down while reading,
                    // and that narrowing lasts for as long as the page is
                    // open rather than following the reader to the next one.
                    //
                    // This MUST come before augmentExistingTeekaLinks:
                    // that builds each inline checkbox with
                    // .prop('checked', !!selectedTeekaSlugs[slug]), reading
                    // the selection as it stands at that moment. Selecting
                    // afterwards left every box drawn unchecked while the
                    // commentaries themselves were shown - the state and
                    // the control disagreeing on screen.
                    availableList.forEach(function (it) {
                        selectedTeekaSlugs[it.slug] = true;
                        (it.subItems || []).forEach(function (sub) { selectedTeekaSlugs[sub.slug] = true; });
                    });

                    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; }
                fetchTeekaJsons(uniqueRelated).then(function (cache) {
                    var subDocsBySlug = {};
                    uniqueRelated.forEach(function (slug) {
                        if (!cache[slug]) console.warn('[grTeekaInterleave] Could not load sub-commentary JSON:' + slug);
                        subDocsBySlug[slug] = cache[slug] || null;
                    });
                    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) {
        title = norm(title); label = String(label || '').replace(/<[^>]*>/g, '').trim();
        if (!title || !label || !DEVA.test(label)) return;
        if (override || !full[title]) full[title] = label;
        var l = title.split('/').pop();
        if (!(l in leaf) || override) leaf[l] = label;
        else if (leaf[l] !== label) leaf[l] = null;   // ambiguous leaf: don't guess
    }
    function parseCatalogue(txt) {
        var out = {}, re = /\[\[([^\]|#]+)\|([^\]]+)\]\]/g, m;
        while ((m = re.exec(txt || ''))) {
            var k = norm(m[1]);
            if (!out[k]) out[k] = m[2].replace(/<[^>]*>/g, '').trim();
        }
        return out;
    }
    function useCatalogue(map) {
        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 ────────────────────────────────────────────────
    function get(title) { return full[norm(title)] || null; }
    function getLeaf(seg) { var v = leaf[norm(seg)]; return v || null; }
    function whenReady(cb) { if (loaded) cb(); else waiting.push(cb); }
    function all() { var o = {}; for (var k in full) o[k] = full[k]; return o; }
    window.grTitles = { get: get, leaf: getLeaf, all: all, whenReady: whenReady, parse: parseCatalogue };

    function finish() {
        loaded = true;
        var cbs = waiting; waiting = [];
        cbs.forEach(function (cb) { try { cb(); } catch (e) { } });
        relabel();
        if (window.mw && mw.hook) mw.hook('gr.titles').fire(window.grTitles);
    }

    // ── relabel what's on screen ──────────────────────────────────
    function articleTitleFromHref(href) {
        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) { }
        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 ─────────────────────────────────────────────────────
    function fetchFresh() {
        return fetch(script('index') + '?title=' + CATALOGUE_PAGE + '&action=raw', { credentials: 'same-origin' })
            .then(function (r) { return r.ok ? r.text() : Promise.reject(); })
            .then(function (txt) {
                var map = parseCatalogue(txt);
                lsSet(CACHE_KEY, JSON.stringify({ at: Date.now(), map: map }));
                return map;
            });
    }
    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); }
    });
}());


/* ═══════════════════════════════════════════════════════════════
   Header links "Library" and "Updates", and the Updates page
   ───────────────────────────────────────────────────────────────
   Desktop (Vector): "Library" and "Updates" added before "Help".
   Mobile (Minerva): the same two items in the menu panel, after "Home".
   "Updates" opens the page My_wiki:Updates, which has two parts:

   1. What's new — filled in automatically from the wiki's edit history
      into <div id="gr-updates-feed">. Nothing to maintain by hand:
        • 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, limit) {
            var p = {
                action: 'query', list: 'recentchanges', rctype: 'new|edit', rcnamespace: 0,
                rcshow: '!redirect', rcprop: 'title|timestamp|sizes', rclimit: limit, rcend: since
            };
            if (cont) p.rccontinue = cont;
            return api(p).then(function (j) {
                // The API answers 200 with an {error:...} body, so a failed
                // query is not a rejected promise. Treat it as one.
                if (j && j.error) throw new Error(j.error.code || 'api error');
                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, limit);
                }
                return all;
            }).catch(function (err) {
                // recentchanges over a date range is the most expensive query
                // this site makes, and on a busy or constrained database it
                // can come back as a DBQueryError. Halve the window and retry
                // once; failing that, return nothing. A "what's new" list is
                // not worth breaking the page for.
                if (limit > 100) return page(cont, 100);
                try {
                    console.warn('[gr] recent changes unavailable: ' +
                                 (err && err.message || err));
                } catch (e) { }
                return all;
            });
        }
        return page(null, 500);
    }

    function summarise(changes, catalogue) {
        var byTitle = {};
        changes.forEach(function (c) {
            var t = norm(c.title);
            if (SKIP_TITLE.test(t) || HIDE.indexOf(t) !== -1) return;
            var e = byTitle[t] || (byTitle[t] = { title: t, created: null, last: null, bytes: 0 });
            var ts = new Date(c.timestamp);
            if (c.type === 'new') e.created = ts;
            else e.bytes += Math.abs((c.newlen || 0) - (c.oldlen || 0));
            if (!e.last || ts > e.last) e.last = ts;
        });
        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().catch(function () { return []; }),
            fetchCatalogue().catch(function () { return null; })
        ]).then(function (r) {
            return dropMissing(summarise(r[0], r[1]).slice(0, MAX_ITEMS + 10));
        }).then(function (items) {
            items = items.slice(0, MAX_ITEMS);
            ssSet(CACHE_KEY, JSON.stringify({ at: Date.now(), items: items }));
            return items;
        });
    }
    function lastSeen() { return +lsGet(SEEN_KEY) || 0; }
    function markSeen() { lsSet(SEEN_KEY, String(Date.now())); }

    // ── Updates page: the automatic list ─────────────────────────
    //    Filled into <div id="gr-updates-feed"> on UPDATES_PAGE.
    //    All styling lives in Common.css §37.
    var KIND = { 'new': ['New', 'gr-upd-new'], 'update': ['Updated', 'gr-upd-update'] };

    // 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) { }
        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() {
        if (document.getElementById('gr-library-link')) return true;
        var help = document.getElementById('gr-help-link');
        var host = help ? help.parentNode :
            (document.querySelector('.vector-header-end') || document.querySelector('#vector-user-links') || document.querySelector('.mw-header'));
        if (!host) return false;

        var lib = el('a', 'gr-header-link', LIBRARY_LABEL);
        lib.id = 'gr-library-link';
        lib.href = LIBRARY_URL; lib.target = '_blank'; lib.rel = 'noopener noreferrer';
        lib.title = 'Grantha catalogue (opens in a new tab)';

        var upd = el('a', 'gr-header-link', UPDATES_LABEL);
        upd.id = 'gr-updates-link';
        upd.href = updatesHref();

        if (help) { host.insertBefore(lib, help); host.insertBefore(upd, help); }
        else { host.appendChild(lib); host.appendChild(upd); }
        markCurrent();
        return true;
    }

    function injectMobile() {
        if (document.getElementById('gr-mob-library')) return true;
        var menu = document.getElementById('gr-mob-menu-items');
        if (!menu) return false;

        var lib = el('a', 'gr-mob-item', LIBRARY_LABEL);
        lib.id = 'gr-mob-library';
        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
    //    DOMContentLoaded, so wait briefly for them rather than assume order.
    function boot() {
        initUpdatesPage();
        var inject = isMobile ? injectMobile : injectDesktop;
        if (inject()) return;
        var tries = 0;
        var obs = window.MutationObserver ? new MutationObserver(function () { if (inject()) obs.disconnect(); }) : null;
        if (obs) obs.observe(document.body, { childList: true, subtree: true });
        (function poll() {
            if (inject()) { if (obs) obs.disconnect(); return; }
            if (++tries < 40) setTimeout(poll, 250); else if (obs) obs.disconnect();
        }());
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
    // Page previews and live-preview re-render content; re-run the page part then.
    if (window.mw && mw.hook) mw.hook('wikipage.content').add(function () { initUpdatesPage(); });

    window.grUpdates = { relDate: relDate, summarise: summarise, groupName: groupName };
}());


/* ═══════════════════════════════════════════════════════════════
   Main Page cards: item count beside each title, and a
   .gr-home-card-wide class on long lists. Styling: Common.css §25.
   ═══════════════════════════════════════════════════════════════ */
(function () {
    '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();
}());


// ── Footnote markers and their cards ────────────────────────────────
/* ── FOOTNOTE REGISTRY ────────────────────────────────────────────────
   Notes that belong to content injected into this page rather than baked
   into it - an interleaved commentary's own footnotes live in ITS
   document, so nothing on the moola page knew what its highlighted words
   pointed at. Anything registered here is visible to the note card and to
   the right-hand panel, grouped under the commentary it came from. */
window.grFootnoteRegistry = (function () {
    'use strict';
    var byId = {};            // gra-id -> {text, quote, type, group, groupTitle}
    var groups = [];          // [{slug, title}] in the order they were added
    var listeners = [];
    var pending = null;
    // Deferred by a tick on purpose. Notes are registered while a
    // commentary's block is still being built, BEFORE it is inserted into
    // the page - so a listener that re-scans the document synchronously
    // would run against a DOM that does not contain the highlighted words
    // yet, and find nothing to wire up. Coalescing to one call after the
    // current task also means a page with ten commentaries re-scans once
    // rather than ten times.
    function notify() {
        if (pending) return;
        pending = setTimeout(function () {
            pending = null;
            listeners.forEach(function (f) { try { f(); } catch (e) {} });
        }, 0);
    }
    return {
        add: function (slug, title, notes) {
            if (!groups.some(function (g) { return g.slug === slug; })) {
                groups.push({ slug: slug, title: title });
            }
            var added = false;
            (notes || []).forEach(function (n) {
                if (!n || !n.id || byId[n.id]) return;
                byId[n.id] = {
                    text: n.text || '', quote: n.quote || '',
                    type: n.kind || n.type || 'note',
                    group: slug, groupTitle: title
                };
                added = true;
            });
            if (added) notify();
        },
        get: function (id) { return byId[id] || null; },
        all: function () { return byId; },
        groups: function () { return groups.slice(); },
        isInjected: function (id) { return !!byId[id]; },
        clearInjected: function () { byId = {}; groups = []; notify(); },
        onChange: function (f) { listeners.push(f); }
    };
}());

/* QuickEdit already stores footnotes: it wraps the annotated words in
   <span class="gra-qe-footnote-highlight" data-gra-id="…"> and appends a
   <div class="gra-qe-footnotes"><ol> at the foot of the document, one
   <li data-gra-id="…"> per note carrying the quoted words and the note.

   That is the whole apparatus, but reading it meant scrolling to the end of
   a long document and scrolling back. This turns the same data into what
   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';
    // data-gra-type on the <li>. The names the converter writes, and the
    // ones a QuickEdit button could write later, land in the same map.
    var TYPES = {
        pathantara: 'पाठान्तरम्',   // a variant reading
        locus: 'प्रमाणम्',          // where a quotation is from — ऋ.सं.७.९९
        source: 'प्रमाणम्',
        note: 'टिप्पणी'            // an editorial remark
    };

    var card = null, openFor = null;

    function notesFromList() {
        var out = {};
        var reg = window.grFootnoteRegistry.all();
        Object.keys(reg).forEach(function (id) {
            out[id] = { text: reg[id].text, quote: reg[id].quote,
                        type: reg[id].type, injected: true,
                        groupTitle: reg[id].groupTitle };
        });
        document.querySelectorAll(LIST + ' .gra-qe-footnote-item').forEach(function (li) {
            var id = li.getAttribute('data-gra-id');
            if (!id) return;
            // An injected note is published into this same list so the
            // right-hand panel can see it, which means the list entry gets
            // read back here. Keep the registry's own record for those, or
            // the entry overwrites it, the `injected` flag is lost, and the
            // note is numbered as though it belonged to this document.
            if (out[id] && out[id].injected) 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);
        card.addEventListener('mouseenter', function () { clearTimeout(hoverTimer); });
        card.addEventListener('mouseleave', function () { if (!COARSE) hoverClose(); });
        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.classList.remove('gr-note-hl-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) +
            (typeof num === 'number' ? '  [' + num + ']' : (num ? '  \u00b7  ' + 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');
        // A commentary's note has no superscript, so the element the card is
        // anchored to IS the highlighted word. gr-note-marker-open paints a
        // solid maroon chip with white text - right for a small superscript,
        // and it made the word itself unreadable. The word gets its own,
        // quieter open state instead.
        marker.classList.add(marker.classList.contains('gr-note-marker')
            ? 'gr-note-marker-open' : 'gr-note-hl-open');
        openFor = marker;
    }

    // A coarse pointer has no hover, so there the card opens on tap. With a
    // mouse, hover shows the note and a click is left free for the reader's
    // own panel - which is what the marker is for on desktop.
    var COARSE = (window.matchMedia && window.matchMedia('(hover: none), (pointer: coarse)').matches)
        || ('ontouchstart' in window) || navigator.maxTouchPoints > 0;

    var hoverTimer = null;
    function hoverOpen(m, note, num) {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(function () { open(m, note, num); }, 90);
    }
    function hoverClose() {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(function () {
            if (card && !card.matches(':hover')) close();
        }, 220);
    }

    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

            // An interleaved commentary's notes get NO superscript. The
            // numbers would restart per commentary and interleave with the
            // moola's own, so "[3]" on the page would mean three different
            // notes depending on which block it sat in. The highlighted
            // words stay clickable, and the panel lists them by commentary.
            var injected = !!note.injected;
            var num = injected ? null : ++n;

            var m = null;
            if (!injected) {
                if (hl.nextElementSibling &&
                    hl.nextElementSibling.classList.contains('gr-note-marker')) {
                    m = hl.nextElementSibling;
                    m.textContent = '[' + num + ']';
                } else {
                    m = document.createElement('sup');
                    m.className = 'gr-note-marker';
                    m.textContent = '[' + num + ']';
                    m.setAttribute('role', 'button');
                    m.setAttribute('tabindex', '0');
                    m.setAttribute('aria-expanded', 'false');
                    hl.parentNode.insertBefore(m, hl.nextSibling);
                }
            }
            if (hl.dataset.grNoteWired) return;
            hl.dataset.grNoteWired = '1';

            var anchorEl = m || hl;
            var label = injected ? (note.groupTitle || '') : num;

            function show(e) { e.preventDefault(); e.stopPropagation(); open(anchorEl, note, label); }

            if (COARSE) {
                if (m) m.addEventListener('click', show);
                hl.addEventListener('click', show);
            } else {
                // Hover opens the card; the click is NOT swallowed, so the
                // annotation gadget still gets it and opens its Footnotes
                // panel - the behaviour that was lost when this handler
                // called stopPropagation on every click.
                [m, hl].forEach(function (el) {
                    if (!el) return;
                    el.addEventListener('mouseenter', function () { hoverOpen(anchorEl, note, label); });
                    el.addEventListener('mouseleave', hoverClose);
                });
                if (m) m.addEventListener('click', show);
            }
            if (m) {
                m.addEventListener('keydown', function (e) {
                    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(m, note, label); }
                });
            }
        });
        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);

    // Commentaries are fetched and injected after the page has rendered, so
    // their notes arrive later than this module's first pass. Re-run when
    // the registry changes, and publish them into the page's own footnote
    // list so the right-hand panel - which reads that list, not this
    // module - shows them too, under the commentary each came from.
    function publishInjectedList() {
        var host = document.querySelector(LIST);
        if (!host) {
            var content = document.getElementById('mw-content-text');
            if (!content) return;
            host = document.createElement('div');
            host.className = 'gra-qe-footnotes';
            content.appendChild(host);
        }
        host.querySelectorAll('.gr-teeka-notes-group').forEach(function (g) { g.remove(); });
        var reg = window.grFootnoteRegistry.all();
        window.grFootnoteRegistry.groups().forEach(function (g) {
            var ids = Object.keys(reg).filter(function (id) { return reg[id].group === g.slug; });
            if (!ids.length) return;
            var wrap = document.createElement('div');
            wrap.className = 'gr-teeka-notes-group';
            wrap.setAttribute('data-teeka-slug', g.slug);
            var h = document.createElement('div');
            h.className = 'gr-teeka-notes-title';
            h.textContent = (window.grLibrary && window.grLibrary.labelFor(g.slug))
                            || g.title || g.slug;
            wrap.appendChild(h);
            var ol = document.createElement('ol');
            ids.forEach(function (id) {
                var n = reg[id];
                var li = document.createElement('li');
                li.className = 'gra-qe-footnote-item';
                li.setAttribute('data-gra-id', id);
                li.setAttribute('data-gra-type', n.type);
                if (n.quote) {
                    var q = document.createElement('span');
                    q.className = 'gra-qe-footnote-quote';
                    q.textContent = n.quote;
                    li.appendChild(q);
                }
                var t = document.createElement('span');
                t.className = 'gra-qe-footnote-text';
                t.textContent = n.text;
                li.appendChild(t);
                ol.appendChild(li);
            });
            wrap.appendChild(ol);
            host.appendChild(wrap);
        });
        try {
            window.dispatchEvent(new CustomEvent('gr-new-content', { detail: { container: host } }));
        } catch (e) { }
    }

    window.grFootnoteRegistry.onChange(function () {
        publishInjectedList();
        decorate();
    });

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

/* ── HASH REVEAL ───────────────────────────────────────────────────────
   A copied id or a bookmark citation is only useful if its #link actually
   lands on the block. The browser's own fragment scroll fires once, at
   parse time, and then our fonts swap in and the teeka blocks inject -
   the page grows under the reader and the target drifts off screen. It
   looks exactly like the link didn't work.

   This retries until the element exists and re-settles after late content
   arrives. It used to live in copyId.js (Editor extension), which is no
   longer loaded on this wiki, so it moves here where it always runs. If
   copyId IS loaded it does the same job; both are idempotent, and the
   guard below keeps them from fighting over the same jump. */
(function () {
    'use strict';
    if (window.grHashReveal) return;

    var FLASH_MS = 2600;
    var _last = null;

    function findTarget(id) {
        if (!id) return null;
        var esc = (window.CSS && CSS.escape) ? CSS.escape(id) : id.replace(/["\\]/g, '\\$&');
        return document.getElementById(id) ||
               document.querySelector('[data-block-id="' + esc + '"]') ||
               document.querySelector('[data-copyid="' + esc + '"]') ||
               null;
    }

    function currentHash() {
        var raw = (location.hash || '').slice(1);
        if (!raw) return '';
        try { return decodeURIComponent(raw); } catch (e) { return raw; }
    }

    function land(el, flash) {
        el.scrollIntoView({ block: 'center', behavior: 'smooth' });
        if (!flash) return;
        el.classList.add('gid-target');
        setTimeout(function () { el.classList.remove('gid-target'); }, FLASH_MS);
    }

    function reveal(attemptsLeft, flash) {
        var id = currentHash();
        if (!id) return;
        var el = findTarget(id);
        if (!el) {
            if (attemptsLeft > 0) setTimeout(function () { reveal(attemptsLeft - 1, flash); }, 400);
            return;
        }
        _last = id;
        land(el, flash);
        // Re-settle once the late arrivals (fonts, injected teekas) have
        // shifted the layout. Cheap, and it is what makes the link feel
        // like it worked rather than like it missed.
        setTimeout(function () {
            if (currentHash() !== id) return;
            var again = findTarget(id);
            if (again) again.scrollIntoView({ block: 'center', behavior: 'auto' });
        }, 1200);
    }

    function onLateContent() {
        var id = currentHash();
        if (!id || id !== _last) return;
        var el = findTarget(id);
        if (el) el.scrollIntoView({ block: 'center', behavior: 'auto' });
    }

    window.grHashReveal = function (attempts) { reveal(attempts || 12, true); };

    function boot() {
        reveal(12, true);
        window.addEventListener('hashchange', function () { reveal(6, true); });
        window.addEventListener('gr-new-content', onLateContent);
        if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
            document.fonts.ready.then(onLateContent);
        }
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
}());

/* ── GRANTHA LIBRARY ───────────────────────────────────────────────────
   The colophon in the source docx says "श्रीमद्वादिराजपूज्यचरणविरचिता
   प्रकाशिका" - an author phrase welded to a generic label. That is not a
   citable title, and it is what the client saw as "Vadirajathirtha
   virachitha". The Grantha library holds the real title and author, so
   the page carries only the library's stable id (data-grantha-id) and we
   resolve the rest here, at read time. Correct a name in the library and
   every citation on the site is correct on the next load - nothing to
   rebuild, nothing to re-upload.

   Reads only, and the library's GET endpoints are public: no API key is
   sent from the browser, and none may ever be put in this file, which
   any reader can fetch. */
(function () {
    'use strict';
    if (window.grLibrary) return;

    // ---- CONFIGURE ME -------------------------------------------------
    // Where the Grantha library's REST API lives. Same-origin by default,
    // which needs no CORS - but the library is its own app, so unless it is
    // reverse-proxied under the wiki's domain this must be its real host:
    //     var API_BASE = 'https://library.example.org/api/granthas';
    // On another host the library must send
    //     Access-Control-Allow-Origin: https://anandamakaranda.in
    // or the browser will refuse the reply and every citation will fall
    // back to the author baked into the page (usually none).
    var API_BASE = window.GR_LIBRARY_API || '/api/granthas';
    // Set to false while the library is not yet reachable: no request is
    // made at all, and citations use whatever the page itself carries.
    // Flip it back to true once API_BASE resolves.
    var LIBRARY_ENABLED = true;
    // -------------------------------------------------------------------
    var API = API_BASE.replace(/\/+$/, '');
    var mem = {};
    var warned = false;
    // Once the library has answered "not here", every further call will get
    // the same answer. Stop asking: a wrong or missing API base otherwise
    // fills the network log with one 404 per work on the page.
    var libraryDown = !LIBRARY_ENABLED;

    // In memory only, for the life of the page. Names used to be kept in
    // sessionStorage for six hours, which quietly outlived any correction
    // made in the library or on the map page. They now come from the map
    // page, which is read once per load, so there is nothing to gain from
    // a second cache that can disagree with it.
    function cacheGet(id) { return mem[id] || null; }
    function cacheSet(id, v) { mem[id] = v; }

    // The library returns camelCase fields; normalise to what the citation
    // builder wants and keep BOTH scripts, since a Sanskrit reference is
    // cited in Devanagari and an English-language one in IAST.
    function shape(g) {
        if (!g) return null;
        return {
            id:          g.id || '',
            name:        g.name || '',
            nameIAST:    g.transliteration || '',
            author:      g.author || '',
            authorIAST:  g.authorTransliteration || g.author_transliteration || '',
            category:    g.category || '',
            prasthana:   g.prasthana || ''
        };
    }

    var inflight = {};
    function lookup(id) {
        if (!id) return Promise.resolve(null);
        var hit = cacheGet(id);
        if (hit) return Promise.resolve(hit);
        // The map page carries the names for every work this wiki uses, so
        // normally nothing is fetched from the library at all. That matters
        // because the library is someone else's server: we cannot add CORS
        // headers to it, and a cross-origin call we cannot configure is a
        // call that will not work. The live API stays as an enhancement for
        // whenever it IS reachable.
        return granthaMap().then(function (m) {
            var w = (m.works || {})[id];
            if (w && (w.name || w.nameIAST)) {
                var v = { id: id, name: w.name || '', nameIAST: w.nameIAST || '',
                          author: w.author || '', authorIAST: w.authorIAST || '',
                          category: w.category || '', prasthana: w.prasthana || '' };
                cacheSet(id, v);
                return v;
            }
            return fetchFromLibrary(id);
        });
    }

    function fetchFromLibrary(id) {
        if (libraryDown) return Promise.resolve(null);
        if (inflight[id]) return inflight[id];
        inflight[id] = fetch(API + '/' + encodeURIComponent(id), {
            credentials: 'omit', headers: { 'Accept': 'application/json' }
        }).then(function (r) {
            // A 404 or 5xx means the base URL is wrong or the service is
            // down - not that this one work is missing - so trip the breaker.
            if (!r.ok) {
                if (r.status === 404 || r.status >= 500) libraryDown = true;
                throw new Error('library ' + r.status);
            }
            return r.json();
        }).then(function (j) {
            var v = shape(j && (j.grantha || j.data || j));
            if (v && v.name) cacheSet(id, v);
            delete inflight[id];
            return v;
        }).catch(function (err) {
            // Offline, blocked by CORS, wrong host, or the id is not in the
            // library yet. The page's own data-author/data-doc-title stand
            // in, so a citation is never blocked on this request - but an
            // unreachable library looks exactly like a work with no author,
            // so say which it is, once.
            delete inflight[id];
            libraryDown = true;
            if (!warned) {
                warned = true;
                try {
                    console.warn('[grantha] could not read the library at ' + API +
                        ' (asked for "' + id + '"): ' + (err && err.message || err) +
                        '. Citations will use whatever the page itself carries. ' +
                        'Set window.GR_LIBRARY_API to the library host, and allow ' +
                        'this origin via Access-Control-Allow-Origin.');
                } catch (e) { }
            }
            return null;
        });
        return inflight[id];
    }

    // ── WHICH GRANTHA IS THIS PAGE? ────────────────────────────────────
    // A page can say so itself (data-grantha-id, written by the pipeline),
    // but that would mean re-importing every document just to add one
    // attribute - and a re-import risks losing edits made on the site
    // since. So the mapping also lives in ONE wiki page, keyed by page
    // name, and the page looks itself up. Adding a document to the
    // library is then a single edit to that one page, with no content
    // touched anywhere.
    var MAP_PAGE = window.GR_GRANTHA_MAP_PAGE || 'MediaWiki:GranthaMap.json';
    var mapPromise = null;

    function granthaMap() {
        if (mapPromise) return mapPromise;
        // Deliberately NOT cached in sessionStorage. It was, for six hours,
        // which meant that editing the map page changed nothing for anyone
        // whose tab had already read the old one - the exact failure you hit
        // while setting this up. mapPromise already collapses it to one
        // request per page load, and the browser's own HTTP cache handles
        // the rest; a stale map is far more expensive than a small fetch.
        try { sessionStorage.removeItem('gr-lib:map'); } catch (e) { }

        var url = (window.mw && mw.util && mw.util.getUrl)
            ? mw.util.getUrl(MAP_PAGE, { action: 'raw', ctype: 'application/json' })
            : '/index.php?title=' + encodeURIComponent(MAP_PAGE) + '&action=raw';
        mapPromise = fetch(url, { credentials: 'same-origin' })
            .then(function (r) { return r.ok ? r.json() : {}; })
            .then(function (m) {
                // Either a flat {page: id} object, or {pages, works,
                // locators} when names and division names are recorded too.
                m = m || {};
                if (!m.pages) m = { pages: m };
                m.works = m.works || {};
                m.locators = m.locators || {};
                try {
                    if (!Object.keys(m.works).length) {
                        console.info('[grantha] the map page has no "works" section, ' +
                            'so names must come from the library API. Re-run ' +
                            'sync_grantha_map.py to embed them.');
                    }
                } catch (e) { }
                return m;
            })
            .catch(function () { return {}; });      // no map page yet - fine
        return mapPromise;
    }

    // page name -> library id, however it is known. The attribute wins,
    // so a rebuilt page needs no map entry and a map entry needs no rebuild.
    function idForPage(pageName, el) {
        var own = el && el.getAttribute && el.getAttribute('data-grantha-id');
        if (own) return Promise.resolve(own);
        if (!pageName) return Promise.resolve('');
        return granthaMap().then(function (m) {
            var p = m.pages || {};
            return p[pageName] || p[String(pageName).replace(/_/g, ' ')] || '';
        });
    }

    // Resolve this page and hand the result to anything listening (the
    // citation builder). Also fills in the visible title when the library
    // and the page disagree, so the reader sees the real name.
    function resolvePage() {
        var t = document.querySelector('.gr-doc-title');
        if (!t) return;
        var page = (window.mw && mw.config.get('wgPageName')) || '';
        // Division names (adhyaya / valli / mantra) come from the same
        // map page, so a locator can be spelled out without a rebuild too.
        if (!t.getAttribute('data-locator-scheme')) {
            granthaMap().then(function (m) {
                var sc = (m.locators || {})[page] ||
                         (m.locators || {})[String(page).replace(/_/g, ' ')];
                if (sc) t.setAttribute('data-locator-scheme', sc);
            });
        }
        idForPage(page, t).then(function (gid) {
            if (!gid) return;
            t.setAttribute('data-grantha-id', gid);
            return lookup(gid).then(function (g) {
                if (!g) return;
                // On a commentary's own page the heading still reads as the
                // docx colophon ("...विरचिता प्रकाशिका"). Name it the way the
                // blocks are named instead.
                if (t.getAttribute('data-parent-doc')) {
                    var lab = teekaLabel(g);
                    if (lab) t.textContent = lab;
                } else if (g.name) {
                    t.textContent = g.name;
                }
                if (g.name)       t.setAttribute('data-doc-title', g.name);
                if (g.nameIAST)   t.setAttribute('data-doc-title-iast', g.nameIAST);
                if (g.author)     t.setAttribute('data-author', g.author);
                if (g.authorIAST) t.setAttribute('data-author-iast', g.authorIAST);
                try {
                    window.dispatchEvent(new CustomEvent('gr-library-resolved', { detail: g }));
                } catch (e) { }
            });
        });
    }

    // Fetching the whole catalogue is one request; fetching fifty records
    // one at a time is fifty. Past a handful, take the catalogue.
    var cataloguePromise = null;
    function catalogue() {
        if (libraryDown) return Promise.resolve({});
        if (cataloguePromise) return cataloguePromise;
        cataloguePromise = fetch(API + '?limit=1000', {
            credentials: 'omit', headers: { 'Accept': 'application/json' }
        }).then(function (r) {
              if (!r.ok) { libraryDown = true; return null; }
              return r.json();
          })
          .then(function (j) {
              var list = (j && (j.granthas || j.data || j.results)) || [];
              var by = {};
              list.forEach(function (g) { var v = shape(g); if (v && v.id) { by[v.id] = v; cacheSet(v.id, v); } });
              return by;
          }).catch(function () { return {}; });
        return cataloguePromise;
    }

    function lookupMany(ids) {
        var want = [], seen = {};
        (ids || []).forEach(function (i) { if (i && !seen[i]) { seen[i] = 1; want.push(i); } });
        // lookup() answers from the map page without a network call, so
        // resolving fifty works costs one shared map fetch, not fifty
        // requests. Only ids the map does not name reach the library.
        return Promise.all(want.map(lookup)).then(function (vals) {
            var out = {};
            want.forEach(function (i, n) { out[i] = vals[n]; });
            return out;
        });
    }

    // How a commentary is named on screen: its author, then the work.
    // "श्रीवादिराजतीर्थः: काठकोपनिषद्भाष्यप्रकाशिका" - because several
    // commentaries on one moola share a work-type name (प्रकाशिका,
    // विवरणम्, खण्डार्थः), and the author is what actually tells them
    // apart for a reader scanning down the page.
    var labelBySlug = {};
    function teekaLabel(g) {
        if (!g) return '';
        var name = g.name || g.nameIAST || '';
        var author = g.author || g.authorIAST || '';
        if (!author || !name) return name || author;
        // Nothing to prefix if the work already opens with the author.
        if (name.indexOf(author) === 0) return name;
        return author + ': ' + name;
    }

    // ── DOCUMENT LINKS ────────────────────────────────────────────────
    // The home page's cards were written by hand and carry the same
    // colophon labels the citation had to stop using - "प्रकाशिका",
    // "भाष्यम्" - which say what KIND of work it is, not which one. Any
    // link to a mapped page gets the library's name instead, so the
    // catalogue and the site agree without the page being edited.
    function relabelDocLinks(root) {
        var scope = root || document;
        var links = scope.querySelectorAll('.gr-home-grid a, .gr-doc-nav a, .gr-vadavali a');
        if (!links.length) return;
        granthaMap().then(function (m) {
            var pages = m.pages || {};
            var jobs = [];
            Array.prototype.forEach.call(links, function (a) {
                if (a.getAttribute('data-gr-relabelled')) return;
                var t = a.getAttribute('title') ||
                        decodeURIComponent((a.getAttribute('href') || '').split('#')[0]
                            .replace(/^.*\/(?:wiki\/)?/, ''));
                var gid = pages[t] || pages[String(t).replace(/_/g, ' ')];
                if (gid) jobs.push([a, gid]);
            });
            if (!jobs.length) return;
            lookupMany(jobs.map(function (j) { return j[1]; })).then(function (by) {
                jobs.forEach(function (j) {
                    var g = by[j[1]];
                    if (!g || !g.name) return;
                    j[0].setAttribute('data-gr-relabelled', '1');
                    j[0].setAttribute('data-grantha-id', j[1]);
                    if (g.authorIAST || g.author) {
                        j[0].setAttribute('title', (g.author || '') + ' — ' + g.name);
                    }
                    j[0].textContent = g.name;
                });
            });
        });
    }

    window.grLibraryVersion = '2026-09-30a';
    window.grLibrary = { lookup: lookup, lookupMany: lookupMany, catalogue: catalogue,
                         teekaLabel: teekaLabel,
                         labelFor: function (slug) { return labelBySlug[slug] || ''; },
                         idForPage: idForPage, map: granthaMap, api: API,
                         relabelDocLinks: relabelDocLinks };
    function boot() { resolvePage(); relabelDocLinks(); }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
    window.addEventListener('gr-new-content', function (e) {
        relabelDocLinks(e && e.detail && e.detail.container);
    });
}());