Jump to content

MediaWiki:Gadget-readerchrome.js: Difference between revisions

From Anandamakaranda
Created page with "/** * readerChrome.js β€” site-wide reader chrome (NOT part of the editor). * * Provides two always-available reader aids that used to live inside the * Editor extension's siteNav.js: * β€’ [πŸ“š] document navigator (bottom-right FAB + panel) * β€’ [↑] scroll-to-top (appears after 300px scroll) * * Why it's separate from the editor: these are reading aids, useful on every * page for every visitor β€” they have nothing to do with editing. Keeping th..."
Β 
No edit summary
Β 
Line 1: Line 1:
/**
/**
Β  * readerChrome.js β€” site-wide reader chrome (NOT part of the editor).
Β  * readerLoader.js β€” brief themed loading overlay for content pages.
Β  *
Β  *
Β  * Provides two always-available reader aids that used to live inside the
Β  * Replaces the "blank visibility:hidden" gap (used to hide the Devanagari
Β  * Editor extension's siteNav.js:
Β  * flash-then-transliterate flip) with an intentional-looking loader. The
Β  * Β  β€’ [πŸ“š] document navigator (bottom-right FAB + panel)
Β  * overlay covers only the content area and fades out the moment the page is
Β  * Β  β€’ [↑]Β  scroll-to-topΒ  Β  Β  (appears after 300px scroll)
Β  * "ready" β€” either when the first transliteration pass has run, or after a
Β  * short safety timeout so it can never get stuck.
Β  *
Β  *
Β  * Why it's separate from the editor: these are reading aids, useful on every
Β  * How it coordinates with transliteration:
Β  * page for every visitor β€” they have nothing to do with editing. Keeping them
Β  * Β  β€’ This script adds class `gr-loading` to <html> immediately (before paint).
Β  * in the editor module meant they only made sense in edit context and leaked
Β  * Β  Β  CSS then hides content and shows the overlay.
Β  * onto all pages awkwardly. This module owns them, loads site-wide, and the
Β  * Β  β€’ When common.js finishes its first applyScript() (or right away if the
Β  * editor no longer touches them.
*Β  Β  saved script is 'deva'), it must callΒ  window.grReaderReady()Β  β€” that
*Β  Β  removes `gr-loading` and adds `gr-ready`, fading the overlay out and
Β  * Β  Β  revealing content.
*Β  β€’ If grReaderReady() is never called (JS error, etc.), a 2.5s failsafe
*Β  Β  reveals the page anyway β€” the reader is never left staring at a loader.
Β  *
Β  *
* Docslist is DERIVED from the JSON: namespace (NS 3000): every document has a
Β  * Install: load early (top of MediaWiki:Common.js, or a Gadget with
* JSON: page, so listing that namespace is the authoritative, self-maintaining
Β  * "type":"general" so it's in <head>). It self-limits to content article
* document list. When the editor creates a new document (writes its JSON page),
Β  * pages and skips Main Page / Special pages.
* it appears here automatically β€” no hardcoded list to maintain.
*
Β  * Install: add as a Gadget (or load via MediaWiki:Common.js /
Β  * wgResourceModules) so it runs on every page. Requires jQuery + mediawiki.util
Β  * (both default). The FAB image /images/docs.svg is optional (emoji fallback).
Β  */
Β  */
( function () {
( function () {
'use strict';
'use strict';


/* The JSON: namespace id β€” the authoritative document catalogue. */
/* Only show the loader on real content article pages. */
var JSON_NS = 3000;
function isContentPage() {
try {
if ( !window.mw || !mw.config ) return false;
var ns = mw.config.get( 'wgNamespaceNumber' );
var pn = mw.config.get( 'wgPageName' ) || '';
if ( ns !== 0 ) return false;Β  Β  Β  Β  Β  Β  Β  Β  // main namespace only
if ( pn === ( mw.config.get( 'wgMainPageTitle' ) || 'Main_Page' ) ) return false;
if ( pn === 'Main_Page' || pn === 'Welcome' ) return false;
return true;
} catch ( e ) { return false; }
}


function boot() {
if ( !isContentPage() ) return;
if ( document.getElementById( 'se-docnav' ) ) return;


var FAB_BGΒ  Β  Β  = '#c0521a';
var root = document.documentElement;
var FAB_BG_HOVER = '#a0410e';
root.classList.add( 'gr-loading' );
var FAB_RIGHTΒ  Β  = '20px';


/* ── Styles (scoped, class-based, no page-CSS dependency) ─────── */
/* ── Styles ─────────────────────────────────────────────────────── */
var css = [
var css = [
'#se-docnav-btn{',
/* Hide the parsed content while loading, but keep layout height so the
'Β  position:fixed;bottom:22px;right:' + FAB_RIGHT + ';z-index:9998;',
Β  page doesn't jump when it reveals. */
'Β  width:48px;height:48px;border-radius:50%;',
'html.gr-loading .mw-parser-output{visibility:hidden;}',
'Β  background:' + FAB_BG + ';border:none;cursor:pointer;padding:0;',
'html.gr-readyΒ  .mw-parser-output{visibility:visible;}',
'Β  box-shadow:0 3px 12px rgba(0,0,0,0.22);',
' display:flex;align-items:center;justify-content:center;',
'Β  transition:background .15s,transform .15s,box-shadow .15s;user-select:none;',
'}',
'#se-docnav-btn:hover{background:' + FAB_BG_HOVER + ';transform:scale(1.07);box-shadow:0 4px 16px rgba(0,0,0,0.3);}',
'#se-docnav-btn .se-fab-img{width:22px;height:22px;filter:brightness(0) invert(1);display:block;}',
'#se-docnav-btn .se-fab-emoji{font-size:20px;line-height:1;}',


'#se-scrolltop{',
/* Overlay sits over the content column only (not the header/toolbar). */
'Β  position:fixed;bottom:78px;right:' + FAB_RIGHT + ';z-index:9997;',
'#gr-reader-loader{',
'Β  width:48px;height:48px;border-radius:50%;',
'Β  position:absolute;left:0;right:0;top:0;',
'Β  background:' + FAB_BG + ';border:none;cursor:pointer;padding:0;',
'Β  min-height:60vh;height:100%;',
'Β  box-shadow:0 3px 12px rgba(0,0,0,0.22);',
'Β  display:flex;flex-direction:column;align-items:center;justify-content:center;',
'Β  display:flex;align-items:center;justify-content:center;',
'Β  gap:16px;z-index:50;pointer-events:none;',
'Β  opacity:0;pointer-events:none;',
'Β  background:transparent;',
'Β  transition:opacity .25s,transform .15s,background .15s;transform:translateY(8px);',
' transition:opacity .3s ease;',
'}',
'}',
'#se-scrolltop.se-scrolltop-visible{opacity:1;pointer-events:auto;transform:translateY(0);}',
'html.gr-ready #gr-reader-loader{opacity:0;}',
'#se-scrolltop:hover{background:' + FAB_BG_HOVER + ';transform:scale(1.07);}',
'#se-scrolltop svg{pointer-events:none;}',


'#se-docnav-btn .se-tip,#se-scrolltop .se-tip{',
/* Spinner β€” a rotating ring in the site terracotta. */
'Β  position:absolute;right:calc(100% + 10px);top:50%;transform:translateY(-50%);',
'#gr-reader-loader .gr-rl-ring{',
'Β  white-space:nowrap;background:rgba(30,30,30,.82);color:#fff;',
'Β  width:38px;height:38px;border-radius:50%;',
'Β  font-size:11px;font-family:system-ui,sans-serif;font-weight:500;',
'Β  border:3px solid rgba(181,69,27,0.18);',
'Β  padding:3px 9px;border-radius:999px;pointer-events:none;opacity:0;transition:opacity .18s;',
'Β  border-top-color:#b5451b;',
'}',
'Β  animation:gr-rl-spin .8s linear infinite;',
'#se-docnav-btn:hover .se-tip,#se-scrolltop:hover .se-tip{opacity:1;}',
'}',
'@keyframes gr-rl-spin{to{transform:rotate(360deg);}}',


'#se-docnav{',
/* Label β€” quiet, manuscript-toned. */
'Β  position:fixed;bottom:134px;right:' + FAB_RIGHT + ';z-index:9999;',
'#gr-reader-loader .gr-rl-label{',
' width:300px;max-height:calc(100vh - 164px);',
'Β  font-family:"Adishila","Noto Serif Devanagari",system-ui,serif;',
'Β  background:#fff;border-radius:14px;',
' font-size:15px;color:#a07040;letter-spacing:.02em;',
'Β  box-shadow:0 8px 32px rgba(0,0,0,0.18),0 1.5px 4px rgba(0,0,0,0.08);',
'}',
'Β  border:0.5px solid #e0e0e0;',
'Β  display:none;flex-direction:column;overflow:hidden;',
'Β  font-family:system-ui,sans-serif;transform-origin:bottom right;animation:se-dn-pop .18s ease;',
'}',
'@keyframes se-dn-pop{from{opacity:0;transform:scale(.93) translateY(6px)}to{opacity:1;transform:none}}',
'#se-docnav.se-dn-open{display:flex;}',
'#se-docnav .se-dn-head{display:flex;align-items:center;justify-content:space-between;padding:13px 16px 10px;border-bottom:.5px solid #eee;flex-shrink:0;}',
'#se-docnav .se-dn-head-title{font-size:13px;font-weight:700;color:#1a2e40;letter-spacing:.01em;}',
'#se-docnav .se-dn-close{background:none;border:none;cursor:pointer;padding:2px 5px;font-size:16px;color:#999;line-height:1;border-radius:4px;transition:background .12s,color .12s;}',
'#se-docnav .se-dn-close:hover{background:#f1f3f4;color:#333;}',
'#se-docnav .se-dn-search{padding:9px 12px;border-bottom:.5px solid #eee;flex-shrink:0;}',
'#se-docnav .se-dn-search input{width:100%;padding:6px 10px;border:.5px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;color:#333;outline:none;box-sizing:border-box;transition:border-color .15s;}',
'#se-docnav .se-dn-search input:focus{border-color:' + FAB_BG + ';}',
'#se-docnav .se-dn-list{overflow-y:auto;flex:1;padding:6px 0;}',
'#se-docnav .se-dn-item{display:flex;align-items:center;gap:10px;padding:9px 16px;cursor:pointer;font-size:13.5px;color:#222;border-left:3px solid transparent;transition:background .1s,border-color .1s;text-decoration:none;}',
'#se-docnav .se-dn-item:hover{background:#fdf3ed;border-left-color:' + FAB_BG + ';}',
'#se-docnav .se-dn-item.se-dn-current{background:#fdf3ed;border-left-color:' + FAB_BG + ';font-weight:600;color:' + FAB_BG + ';}',
'#se-docnav .se-dn-icon{font-size:15px;flex-shrink:0;opacity:.7;}',
'#se-docnav .se-dn-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
'#se-docnav .se-dn-status{padding:18px 16px;text-align:center;color:#999;font-size:13px;}',
'#se-docnav .se-dn-footer{border-top:.5px solid #eee;flex-shrink:0;}',
'#se-docnav .se-dn-footer a{display:flex;align-items:center;gap:8px;padding:10px 16px;font-size:12.5px;color:#666;text-decoration:none;transition:background .12s,color .12s;}',
'#se-docnav .se-dn-footer a:hover{background:#fdf3ed;color:' + FAB_BG + ';}',


'@media(max-width:768px){',
/* Reduced-motion: no spin, just a gentle pulse. */
'Β  #se-docnav-btn{bottom:18px;right:16px;width:44px;height:44px;}',
'@media (prefers-reduced-motion: reduce){',
'Β  #se-scrolltop{bottom:74px;right:16px;width:44px;height:44px;}',
'Β  #gr-reader-loader .gr-rl-ring{animation:gr-rl-pulse 1.2s ease-in-out infinite;border-top-color:rgba(181,69,27,0.18);}',
'Β  #se-docnav{right:16px;width:calc(100vw - 32px);bottom:128px;}',
'Β  @keyframes gr-rl-pulse{0%,100%{opacity:.4}50%{opacity:1}}',
'}',
'}'
'@media print{#se-docnav,#se-docnav-btn,#se-scrolltop{display:none!important}}'
].join( '' );
].join( '' );


var styleEl = document.createElement( 'style' );
var style = document.createElement( 'style' );
styleEl.id = 'se-readerchrome-css';
style.id = 'gr-reader-loader-css';
styleEl.textContent = css;
style.textContent = css;
document.head.appendChild( styleEl );
( document.head || document.documentElement ).appendChild( style );


/* ── Build DOM ───────────────────────────────────────── */
/* ── Overlay element (inserted into the content container) ──────── */
var mainPage = ( mw.config.get( 'wgArticlePath' ) || '/wiki/$1' )
function mountOverlay() {
.replace( '$1', encodeURIComponent( mw.config.get( 'wgMainPageTitle' ) || 'Main_Page' ) );
if ( document.getElementById( 'gr-reader-loader' ) ) return;
var currentTitle = mw.config.get( 'wgPageName' ) || '';
var host = document.getElementById( 'mw-content-text' ) ||
var currentRootΒ  = currentTitle.split( '/' )[ 0 ];
document.querySelector( '.mw-body-content' ) ||
document.body;
if ( !host ) return;
/* host needs positioning context so the absolute overlay covers it */
var pos = window.getComputedStyle( host ).position;
if ( pos === 'static' ) host.style.position = 'relative';


var panel = document.createElement( 'div' );
var ov = document.createElement( 'div' );
panel.id = 'se-docnav';
ov.id = 'gr-reader-loader';
panel.innerHTML = [
ov.setAttribute( 'aria-hidden', 'true' );
'<div class="se-dn-head">',
ov.innerHTML =
' <span class="se-dn-head-title">πŸ“š Documents</span>',
'<div class="gr-rl-ring"></div>' +
'Β  <button class="se-dn-close" title="Close">βœ•</button>',
'<div class="gr-rl-label">ΰ€²ΰ₯‹ΰ€‘ΰ₯ΰ€―ΰ€€ΰ₯‡β€¦</div>';Β  /* "loading…" in Sanskrit */
'</div>',
host.appendChild( ov );
'<div class="se-dn-search"><input type="text" placeholder="Search documents…" id="se-dn-input" autocomplete="off"></div>',
}
'<div class="se-dn-list" id="se-dn-list"><div class="se-dn-status">Loading…</div></div>',
'<div class="se-dn-footer"><a href="' + mainPage + '">βŒ‚&ensp;Go to Main Page</a></div>'
].join( '' );


var triggerBtn = document.createElement( 'button' );
if ( document.readyState === 'loading' ) {
triggerBtn.idΒ  Β  = 'se-docnav-btn';
document.addEventListener( 'DOMContentLoaded', mountOverlay );
triggerBtn.title = 'Browse documents';
} else {
triggerBtn.setAttribute( 'aria-label', 'Browse documents' );
mountOverlay();
triggerBtn.innerHTML =
}
'<img class="se-fab-img" src="/images/docs.svg" alt="" aria-hidden="true" ' +
'Β  Β  onerror="this.outerHTML=\'<span class=&quot;se-fab-emoji&quot; aria-hidden=&quot;true&quot;>πŸ“š</span>\'">' +
'<span class="se-tip">Browse documents</span>';


var scrollBtn = document.createElement( 'button' );
/* ── Reveal logic ───────────────────────────────────────────────── */
scrollBtn.idΒ  Β  = 'se-scrolltop';
var revealed = false;
scrollBtn.title = 'Back to top';
function reveal() {
scrollBtn.setAttribute( 'aria-label', 'Scroll to top' );
if ( revealed ) return;
scrollBtn.innerHTML =
revealed = true;
'<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
root.classList.remove( 'gr-loading' );
'Β  <path d="M10 3.5L3.5 11H7.5V16.5H12.5V11H16.5L10 3.5Z" fill="white"/>' +
root.classList.add( 'gr-ready' );
'</svg>' +
/* Remove the overlay node after the fade so it leaves no trace. */
'<span class="se-tip">Back to top</span>';
setTimeout( function () {
Β 
var ov = document.getElementById( 'gr-reader-loader' );
document.body.appendChild( panel );
if ( ov && ov.parentNode ) ov.parentNode.removeChild( ov );
document.body.appendChild( triggerBtn );
}, 320 );
document.body.appendChild( scrollBtn );
}
Β 
/* ── Docs logic β€” DERIVED from the JSON: namespace ───── */
var allDocs = [], loaded = false, open = false;
Β 
function artPath( title ) {
return ( mw.config.get( 'wgArticlePath' ) || '/wiki/$1' ).replace( '$1', title.replace( / /g, '_' ) );
}
Β 
function renderList( filter ) {
var listΒ  = document.getElementById( 'se-dn-list' );
var qΒ  Β  = ( filter || '' ).toLowerCase().trim();
var shown = q ? allDocs.filter( function ( d ) {
return d.title.toLowerCase().indexOf( q ) !== -1 ||
( d.display && d.display.toLowerCase().indexOf( q ) !== -1 );
} ) : allDocs;
if ( !shown.length ) {
list.innerHTML = '<div class="se-dn-status">' +
( q ? 'No results for "' + q + '"' : 'No documents found.' ) + '</div>';
return;
}
var html = '';
shown.forEach( function ( d ) {
var cur = d.title === currentRoot;
html += '<a class="se-dn-item' + ( cur ? ' se-dn-current' : '' ) + '" href="' + d.href + '" title="' + d.title + '">' +
'<span class="se-dn-icon">πŸ“–</span>' +
'<span class="se-dn-name">' + d.display + '</span>' +
( cur ? '<span style="font-size:11px;color:' + FAB_BG + ';flex-shrink:0">← here</span>' : '' ) +
'</a>';
} );
list.innerHTML = html;
}
Β 
function loadDocs() {
if ( loaded ) return;
loaded = true;
var accumulated = [];
function fetchBatch( apcontinue ) {
/* List the JSON: namespace β€” the authoritative doc catalogue.
Β  Each JSON: page title IS a document slug, so new docs created
Β  by the editor appear here automatically. */
var params = {
action: 'query', list: 'allpages',
apnamespace: JSON_NS, aplimit: 'max', format: 'json'
};
if ( apcontinue ) params.apcontinue = apcontinue;
$.ajax( { url: mw.util.wikiScript( 'api' ), data: params, dataType: 'json' } )
.then( function ( data ) {
var pages = ( data.query && data.query.allpages ) || [];
pages.forEach( function ( p ) {
/* p.title is like "JSON:Anuvyakhyana" β€” strip the ns
Β  prefix to get the reader page slug. Only top-level
Β  docs (no subpage slash after the slug). */
var slug = p.title.replace( /^[^:]+:/, '' );
if ( slug.indexOf( '/' ) === -1 ) {
accumulated.push( {
title: slug,
display: slug.replace( /_/g, ' ' ),
href: artPath( slug )
} );
}
} );
if ( data.continue && data.continue.apcontinue ) {
fetchBatch( data.continue.apcontinue );
} else {
accumulated.sort( function ( a, b ) { return a.title.localeCompare( b.title ); } );
allDocs = accumulated;
renderList( '' );
}
} ).fail( function () {
var list = document.getElementById( 'se-dn-list' );
if ( list ) list.innerHTML = '<div class="se-dn-status">Could not load documents.</div>';
} );
}
fetchBatch( null );
}
Β 
function openPanel() {
open = true; panel.classList.add( 'se-dn-open' ); loadDocs();
setTimeout( function () { var i = document.getElementById( 'se-dn-input' ); if ( i ) i.focus(); }, 60 );
}
function closePanel() { open = false; panel.classList.remove( 'se-dn-open' ); }
Β 
triggerBtn.addEventListener( 'click', function ( e ) { e.stopPropagation(); open ? closePanel() : openPanel(); } );
panel.querySelector( '.se-dn-close' ).addEventListener( 'click', closePanel );
document.addEventListener( 'click', function ( e ) { if ( open && !panel.contains( e.target ) && e.target !== triggerBtn ) closePanel(); } );
document.addEventListener( 'input', function ( e ) { if ( e.target && e.target.id === 'se-dn-input' ) renderList( e.target.value ); } );
document.addEventListener( 'keydown', function ( e ) { if ( open && ( e.key === 'Escape' || e.keyCode === 27 ) ) closePanel(); } );


/* ── Scroll-to-top (window + Minerva #mw-mf-viewport) ── */
/* common.js calls this after its first applyScript(). */
var _scrollRaf = null;
window.grReaderReady = reveal;
function updateScrollBtn( y ) { scrollBtn.classList.toggle( 'se-scrolltop-visible', y > 300 ); }
function onWindowScroll() {
if ( _scrollRaf ) return;
_scrollRaf = requestAnimationFrame( function () {
_scrollRaf = null;
updateScrollBtn( window.scrollY || window.pageYOffset || 0 );
} );
}
function onViewportScroll() {
if ( _scrollRaf ) return;
_scrollRaf = requestAnimationFrame( function () {
_scrollRaf = null;
var vp = document.getElementById( 'mw-mf-viewport' );
updateScrollBtn( vp ? vp.scrollTop : 0 );
} );
}
scrollBtn.addEventListener( 'click', function () {
var vp = document.getElementById( 'mw-mf-viewport' );
if ( vp && vp.scrollTop > 0 ) { vp.scrollTo( { top: 0, behavior: 'smooth' } ); }
else { window.scrollTo( { top: 0, behavior: 'smooth' } ); }
} );
window.addEventListener( 'scroll', onWindowScroll, { passive: true } );
var _vpEl = document.getElementById( 'mw-mf-viewport' );
if ( _vpEl ) {
_vpEl.addEventListener( 'scroll', onViewportScroll, { passive: true } );
} else {
setTimeout( function () {
var vp = document.getElementById( 'mw-mf-viewport' );
if ( vp ) vp.addEventListener( 'scroll', onViewportScroll, { passive: true } );
}, 500 );
}
onWindowScroll();
onViewportScroll();
}


if ( window.mw && mw.hook ) {
/* Failsafe: never leave the loader up longer than 2.5s, even if the
mw.hook( 'wikipage.content' ).add( boot );
Β  ready signal never arrives (JS error, missing hook, etc.). */
} else if ( document.readyState === 'loading' ) {
setTimeout( reveal, 2500 );
document.addEventListener( 'DOMContentLoaded', boot );
} else {
boot();
}


}() );
}() );

Latest revision as of 18:10, 2 July 2026

/**
 * readerLoader.js β€” brief themed loading overlay for content pages.
 *
 * Replaces the "blank visibility:hidden" gap (used to hide the Devanagari
 * flash-then-transliterate flip) with an intentional-looking loader. The
 * overlay covers only the content area and fades out the moment the page is
 * "ready" β€” either when the first transliteration pass has run, or after a
 * short safety timeout so it can never get stuck.
 *
 * How it coordinates with transliteration:
 *   β€’ This script adds class `gr-loading` to <html> immediately (before paint).
 *     CSS then hides content and shows the overlay.
 *   β€’ When common.js finishes its first applyScript() (or right away if the
 *     saved script is 'deva'), it must call  window.grReaderReady()  β€” that
 *     removes `gr-loading` and adds `gr-ready`, fading the overlay out and
 *     revealing content.
 *   β€’ If grReaderReady() is never called (JS error, etc.), a 2.5s failsafe
 *     reveals the page anyway β€” the reader is never left staring at a loader.
 *
 * Install: load early (top of MediaWiki:Common.js, or a Gadget with
 * "type":"general" so it's in <head>). It self-limits to content article
 * pages and skips Main Page / Special pages.
 */
( function () {
	'use strict';

	/* Only show the loader on real content article pages. */
	function isContentPage() {
		try {
			if ( !window.mw || !mw.config ) return false;
			var ns = mw.config.get( 'wgNamespaceNumber' );
			var pn = mw.config.get( 'wgPageName' ) || '';
			if ( ns !== 0 ) return false;                 // main namespace only
			if ( pn === ( mw.config.get( 'wgMainPageTitle' ) || 'Main_Page' ) ) return false;
			if ( pn === 'Main_Page' || pn === 'Welcome' ) return false;
			return true;
		} catch ( e ) { return false; }
	}

	if ( !isContentPage() ) return;

	var root = document.documentElement;
	root.classList.add( 'gr-loading' );

	/* ── Styles ─────────────────────────────────────────────────────── */
	var css = [
		/* Hide the parsed content while loading, but keep layout height so the
		   page doesn't jump when it reveals. */
		'html.gr-loading .mw-parser-output{visibility:hidden;}',
		'html.gr-ready   .mw-parser-output{visibility:visible;}',

		/* Overlay sits over the content column only (not the header/toolbar). */
		'#gr-reader-loader{',
		'  position:absolute;left:0;right:0;top:0;',
		'  min-height:60vh;height:100%;',
		'  display:flex;flex-direction:column;align-items:center;justify-content:center;',
		'  gap:16px;z-index:50;pointer-events:none;',
		'  background:transparent;',
		'  transition:opacity .3s ease;',
		'}',
		'html.gr-ready #gr-reader-loader{opacity:0;}',

		/* Spinner β€” a rotating ring in the site terracotta. */
		'#gr-reader-loader .gr-rl-ring{',
		'  width:38px;height:38px;border-radius:50%;',
		'  border:3px solid rgba(181,69,27,0.18);',
		'  border-top-color:#b5451b;',
		'  animation:gr-rl-spin .8s linear infinite;',
		'}',
		'@keyframes gr-rl-spin{to{transform:rotate(360deg);}}',

		/* Label β€” quiet, manuscript-toned. */
		'#gr-reader-loader .gr-rl-label{',
		'  font-family:"Adishila","Noto Serif Devanagari",system-ui,serif;',
		'  font-size:15px;color:#a07040;letter-spacing:.02em;',
		'}',

		/* Reduced-motion: no spin, just a gentle pulse. */
		'@media (prefers-reduced-motion: reduce){',
		'  #gr-reader-loader .gr-rl-ring{animation:gr-rl-pulse 1.2s ease-in-out infinite;border-top-color:rgba(181,69,27,0.18);}',
		'  @keyframes gr-rl-pulse{0%,100%{opacity:.4}50%{opacity:1}}',
		'}'
	].join( '' );

	var style = document.createElement( 'style' );
	style.id = 'gr-reader-loader-css';
	style.textContent = css;
	( document.head || document.documentElement ).appendChild( style );

	/* ── Overlay element (inserted into the content container) ──────── */
	function mountOverlay() {
		if ( document.getElementById( 'gr-reader-loader' ) ) return;
		var host = document.getElementById( 'mw-content-text' ) ||
			document.querySelector( '.mw-body-content' ) ||
			document.body;
		if ( !host ) return;
		/* host needs positioning context so the absolute overlay covers it */
		var pos = window.getComputedStyle( host ).position;
		if ( pos === 'static' ) host.style.position = 'relative';

		var ov = document.createElement( 'div' );
		ov.id = 'gr-reader-loader';
		ov.setAttribute( 'aria-hidden', 'true' );
		ov.innerHTML =
			'<div class="gr-rl-ring"></div>' +
			'<div class="gr-rl-label">ΰ€²ΰ₯‹ΰ€‘ΰ₯ΰ€―ΰ€€ΰ₯‡β€¦</div>';   /* "loading…" in Sanskrit */
		host.appendChild( ov );
	}

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

	/* ── Reveal logic ───────────────────────────────────────────────── */
	var revealed = false;
	function reveal() {
		if ( revealed ) return;
		revealed = true;
		root.classList.remove( 'gr-loading' );
		root.classList.add( 'gr-ready' );
		/* Remove the overlay node after the fade so it leaves no trace. */
		setTimeout( function () {
			var ov = document.getElementById( 'gr-reader-loader' );
			if ( ov && ov.parentNode ) ov.parentNode.removeChild( ov );
		}, 320 );
	}

	/* common.js calls this after its first applyScript(). */
	window.grReaderReady = reveal;

	/* Failsafe: never leave the loader up longer than 2.5s, even if the
	   ready signal never arrives (JS error, missing hook, etc.). */
	setTimeout( reveal, 2500 );

}() );