Jump to content

MediaWiki:Common.js: Difference between revisions

From ZelocoreCMS Wiki
ZelocoreCMS JS enhancements — initial deploy
 
Light-theme JS rewrite
Line 1: Line 1:
/* ============================================================
/* ============================================================
   ZelocoreCMS Wiki — MediaWiki:Common.js
   ZelocoreCMS Wiki — MediaWiki:Common.js
   Animations, UX Enhancements, Interactive Features
   Light-theme compatible enhancements
   ============================================================ */
   ============================================================ */
 
/* global mw, $ */
( function () {
( function () {
     'use strict';
     'use strict';


     /* ── Utility ─────────────────────────────────────────── */
     var qs  = function ( s, c ) { return ( c || document ).querySelector( s ); };
    function qs( sel, ctx ) { return ( ctx || document ).querySelector( sel ); }
     var qsa = function ( s, c ) { return Array.prototype.slice.call( ( c || document ).querySelectorAll( s ) ); };
     function qsa( sel, ctx ) { return [ ...( ctx || document ).querySelectorAll( sel ) ]; }
    function ready( fn )    { if ( document.readyState !== 'loading' ) fn(); else document.addEventListener( 'DOMContentLoaded', fn ); }
 
    /* ── 1. Scroll-triggered Fade-in Animations ──────────── */
    function initScrollAnimations() {
        var observer = new IntersectionObserver( function( entries ) {
            entries.forEach( function( entry ) {
                if ( entry.isIntersecting ) {
                    entry.target.style.opacity    = '1';
                    entry.target.style.transform  = 'translateY(0)';
                    observer.unobserve( entry.target );
                }
            });
        }, { threshold: 0.12 } );


        qsa( '.zc-cms-card, .zc-category-box, .zc-stat-card, .zc-profile-card, .wikitable, h2, h3' )
    function ready( fn ) {
            .forEach( function( el ) {
        if ( document.readyState !== 'loading' ) { fn(); } else { document.addEventListener( 'DOMContentLoaded', fn ); }
                el.style.opacity    = '0';
                el.style.transform  = 'translateY(24px)';
                el.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
                observer.observe( el );
            });
     }
     }


     /* ── 2. Animated Counter Numbers ─────────────────────── */
     /* ── 1. Reading Progress Bar ─────────────────────────── */
     function animateCounter( el, target, duration ) {
     function initProgressBar() {
         var start    = 0;
         var bar = document.createElement( 'div' );
         var startTime = null;
         bar.id = 'zc-reading-progress';
         var suffix    = el.dataset.suffix || '';
         Object.assign( bar.style, {
 
            position: 'fixed', top: '0', left: '0', height: '3px', width: '0%',
         function step( timestamp ) {
            background: '#3366cc', zIndex: '9999', transition: 'width 0.1s linear',
             if ( !startTime ) startTime = timestamp;
            pointerEvents: 'none'
             var progress = Math.min( ( timestamp - startTime ) / duration, 1 );
        } );
             var ease    = 1 - Math.pow( 1 - progress, 3 ); // ease-out cubic
        document.body.appendChild( bar );
            el.textContent = Math.floor( ease * target ).toLocaleString() + suffix;
         window.addEventListener( 'scroll', function () {
            if ( progress < 1 ) requestAnimationFrame( step );
             var doc = document.documentElement;
         }
             var pct = ( window.scrollY / ( doc.scrollHeight - window.innerHeight ) ) * 100;
        requestAnimationFrame( step );
             bar.style.width = Math.min( pct, 100 ) + '%';
         }, { passive: true } );
     }
     }


     function initCounters() {
    /* ── 2. Back-to-Top Button ───────────────────────────── */
         var counterObs = new IntersectionObserver( function( entries ) {
     function initBackToTop() {
             entries.forEach( function( entry ) {
         var btn = document.createElement( 'button' );
                if ( entry.isIntersecting ) {
        btn.id = 'zc-back-to-top';
                    var el  = entry.target;
        btn.textContent = '↑';
                    var val = parseInt( el.dataset.count, 10 );
        btn.title = 'Back to top';
                    animateCounter( el, val, 1800 );
        Object.assign( btn.style, {
                    counterObs.unobserve( el );
             position: 'fixed', bottom: '24px', right: '24px',
                }
            width: '40px', height: '40px', borderRadius: '50%',
             });
            background: '#3366cc', border: 'none', color: '#fff',
         }, { threshold: 0.5 } );
            fontSize: '1.1rem', fontWeight: '700', cursor: 'pointer',
 
            opacity: '0', transition: 'opacity 0.25s ease, transform 0.25s ease',
         qsa( '.zc-count' ).forEach( function( el ) { counterObs.observe( el ); } );
            boxShadow: '0 2px 8px rgba(0,0,0,0.25)', zIndex: '998', transform: 'scale(0.8)'
        } );
        document.body.appendChild( btn );
        window.addEventListener( 'scroll', function () {
            var show = window.scrollY > 400;
            btn.style.opacity  = show ? '1' : '0';
             btn.style.transform = show ? 'scale(1)' : 'scale(0.8)';
         }, { passive: true } );
         btn.addEventListener( 'click', function () {
            window.scrollTo( { top: 0, behavior: 'smooth' } );
        } );
     }
     }


     /* ── 3. Enhanced Search Bar ──────────────────────────── */
     /* ── 3. Keyboard Shortcut: / or Ctrl+K focuses search ── */
     function initSearch() {
     function initSearchShortcut() {
         var input = qs( '#searchInput, .cdx-text-input__input, input[name="search"]' );
         var input = qs( '#searchInput, .cdx-text-input__input, input[name="search"]' );
         if ( !input ) return;
         if ( !input ) { return; }
 
         document.addEventListener( 'keydown', function ( e ) {
        // Quick-search shortcuts (Ctrl+K or /)
            var active = document.activeElement;
         document.addEventListener( 'keydown', function( e ) {
            var isEditing = active && ( active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable );
             if ( ( e.ctrlKey || e.metaKey ) && e.key === 'k' ) {
             if ( ( ( e.ctrlKey || e.metaKey ) && e.key === 'k' ) || ( e.key === '/' && !isEditing ) ) {
                 e.preventDefault();
                 e.preventDefault();
                 input.focus();
                 input.focus();
                 input.select();
                 input.select();
            }
            if ( e.key === '/' && document.activeElement.tagName !== 'INPUT' &&
                document.activeElement.tagName !== 'TEXTAREA' ) {
                e.preventDefault();
                input.focus();
             }
             }
         } );
         } );
        // Keyboard shortcut hint
        if ( !qs( '.zc-search-hint' ) ) {
            var hint = document.createElement( 'span' );
            hint.className = 'zc-search-hint';
            hint.textContent = 'Ctrl+K';
            hint.style.cssText = [
                'position:absolute', 'right:10px', 'top:50%', 'transform:translateY(-50%)',
                'font-size:0.7rem', 'color:var(--zc-text-dim)',
                'background:var(--zc-surface2)', 'border:1px solid var(--zc-border)',
                'border-radius:4px', 'padding:1px 6px', 'pointer-events:none'
            ].join(';');
            var wrap = input.closest( '.cdx-text-input, .mw-search-form, .vector-search-box' );
            if ( wrap ) {
                wrap.style.position = 'relative';
                wrap.appendChild( hint );
            }
        }
     }
     }


     /* ── 4. Active Sidebar Link Highlight ────────────────── */
     /* ── 4. Code Block Copy Button ───────────────────────── */
    function initSidebarHighlight() {
     function addCopyButtons() {
        var currentPath = window.location.pathname;
         qsa( 'pre, .mw-highlight' ).forEach( function ( block ) {
        qsa( '#mw-panel a, .mw-sidebar a, .vector-menu-content-list a' ).forEach( function( a ) {
             if ( qs( '.zc-copy-btn', block ) ) { return; }
            if ( a.pathname && a.pathname !== '/' && currentPath.startsWith( a.pathname ) ) {
                a.style.color      = 'var(--zc-cyan)';
                a.style.borderLeft  = '2px solid var(--zc-cyan)';
                a.style.background  = 'var(--zc-cyan-dim)';
                a.style.fontWeight  = '600';
            }
        });
    }
 
    /* ── 5. Code Block Copy Button ───────────────────────── */
     function initCodeCopy() {
         qsa( 'pre, .mw-highlight' ).forEach( function( block ) {
             if ( qs( '.zc-copy-btn', block ) ) return;
 
             var btn = document.createElement( 'button' );
             var btn = document.createElement( 'button' );
             btn.className   = 'zc-copy-btn';
             btn.className = 'zc-copy-btn';
             btn.textContent = 'Copy';
             btn.textContent = 'Copy';
             btn.style.cssText = [
             Object.assign( btn.style, {
                 'position:absolute', 'top:10px', 'right:10px',
                 position: 'absolute', top: '6px', right: '6px',
                 'background:var(--zc-surface2)', 'border:1px solid var(--zc-border)',
                 background: '#f8f9fa', border: '1px solid #a2a9b1',
                 'color:var(--zc-text-muted)', 'font-size:0.75rem', 'font-weight:600',
                 color: '#54595d', fontSize: '0.75rem', padding: '2px 10px',
                 'padding:3px 10px', 'border-radius:4px', 'cursor:pointer',
                 borderRadius: '2px', cursor: 'pointer', fontFamily: 'inherit'
                'transition:all 0.2s ease', 'font-family:var(--zc-font-body)'
             } );
            ].join(';');
             btn.addEventListener( 'click', function () {
 
                 var text = block.textContent.replace( /^Copy$|^✓ Copied$/m, '' ).trim();
            btn.addEventListener( 'mouseenter', function() {
                 navigator.clipboard.writeText( text ).then( function () {
                this.style.borderColor = 'var(--zc-cyan)';
                     btn.textContent = '✓ Copied';
                this.style.color      = 'var(--zc-cyan)';
                     btn.style.color = '#14866d';
            });
                     btn.style.borderColor = '#14866d';
            btn.addEventListener( 'mouseleave', function() {
                     setTimeout( function () {
                this.style.borderColor = 'var(--zc-border)';
                         btn.textContent = 'Copy';
                this.style.color      = 'var(--zc-text-muted)';
                         btn.style.color = '#54595d';
             });
                         btn.style.borderColor = '#a2a9b1';
             btn.addEventListener( 'click', function() {
                 var text = block.textContent.replace( /Copy|✓ Copied/g, '' ).trim();
                 navigator.clipboard.writeText( text ).then( function() {
                     btn.textContent     = '✓ Copied';
                     btn.style.color     = 'var(--zc-emerald)';
                     btn.style.borderColor = 'var(--zc-emerald)';
                     setTimeout( function() {
                         btn.textContent     = 'Copy';
                         btn.style.color     = 'var(--zc-text-muted)';
                         btn.style.borderColor = 'var(--zc-border)';
                     }, 2000 );
                     }, 2000 );
                 });
                 } );
             });
             } );
 
             block.style.position = 'relative';
             block.style.position = 'relative';
             block.appendChild( btn );
             block.appendChild( btn );
         });
        } );
    }
 
    /* ── 5. Scroll-reveal for elements ──────────────────── */
    function initScrollReveal() {
        if ( !window.IntersectionObserver ) { return; }
        var obs = new IntersectionObserver( function ( entries ) {
            entries.forEach( function ( entry ) {
                if ( entry.isIntersecting ) {
                    entry.target.style.opacity = '1';
                    entry.target.style.transform = 'translateY(0)';
                    obs.unobserve( entry.target );
                }
            } );
        }, { threshold: 0.08 } );
        qsa( '.mp-portal-cell' ).forEach( function ( el ) {
            el.style.opacity = '0';
            el.style.transform = 'translateY(12px)';
            el.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
            obs.observe( el );
         } );
     }
     }


     /* ── 6. Smooth Anchor Scrolling with Offset ──────────── */
     /* ── 6. Smooth anchor scrolling ─────────────────────── */
     function initSmoothScroll() {
     function initSmoothScroll() {
         qsa( 'a[href^="#"]' ).forEach( function( a ) {
         qsa( 'a[href^="#"]' ).forEach( function ( a ) {
             a.addEventListener( 'click', function( e ) {
             a.addEventListener( 'click', function ( e ) {
                 var target = qs( decodeURIComponent( this.hash ) );
                 var id = decodeURIComponent( this.hash.slice( 1 ) );
                 if ( !target ) return;
                var target = document.getElementById( id );
                 if ( !target ) { return; }
                 e.preventDefault();
                 e.preventDefault();
                 window.scrollTo( {
                 window.scrollTo( {
                     top:     target.getBoundingClientRect().top + window.scrollY - 80,
                     top: target.getBoundingClientRect().top + window.scrollY - 70,
                     behavior: 'smooth'
                     behavior: 'smooth'
                 } );
                 } );
             });
             } );
         });
         } );
     }
     }


     /* ── 7. TOC Sticky Scroll Spy ────────────────────────── */
     /* ── 7. TOC scroll-spy (highlight current heading) ──── */
     function initTOCSpy() {
     function initTOCSpy() {
        var toc = qs( '#toc, .toc' );
        if ( !toc ) return;
         var headings = qsa( '.mw-body-content h2, .mw-body-content h3' );
         var headings = qsa( '.mw-body-content h2, .mw-body-content h3' );
         var links    = qsa( '#toc a, .toc a' );
         var tocLinks = qsa( '#toc a, .toc a' );
         if ( !headings.length || !links.length ) return;
         if ( !headings.length || !tocLinks.length ) { return; }
 
        var ticking = false;
         window.addEventListener( 'scroll', mw.util.debounce( function() {
         window.addEventListener( 'scroll', function () {
            var scrollY = window.scrollY + 100;
            if ( ticking ) { return; }
            var current = '';
            ticking = true;
 
            requestAnimationFrame( function () {
            headings.forEach( function( h ) {
                var scrollY = window.scrollY + 80;
                if ( h.offsetTop <= scrollY ) current = '#' + h.id;
                var current = '';
            });
                headings.forEach( function ( h ) {
 
                    if ( h.getBoundingClientRect().top + window.scrollY <= scrollY ) {
            links.forEach( function( link ) {
                        current = '#' + h.id;
                link.style.color      = 'var(--zc-text-muted)';
                    }
                link.style.fontWeight = '400';
                } );
                if ( link.getAttribute( 'href' ) === current ) {
                tocLinks.forEach( function ( link ) {
                     link.style.color      = 'var(--zc-cyan)';
                    var isCurrent = link.getAttribute( 'href' ) === current;
                     link.style.fontWeight = '600';
                     link.style.fontWeight = isCurrent ? 'bold' : '';
                 }
                     link.style.color = isCurrent ? '#202122' : '';
             });
                 } );
         }, 100 ) );
                ticking = false;
             } );
         }, { passive: true } );
     }
     }


     /* ── 8. Developer Profile Tab Switcher ───────────────── */
     /* ── 8. Table row highlight ─────────────────────────── */
     function initProfileTabs() {
     function initTableHighlight() {
         qsa( '.zc-tabs' ).forEach( function( tabGroup ) {
         qsa( '.wikitable tbody tr' ).forEach( function ( row ) {
             var tabs    = qsa( '.zc-tab-btn', tabGroup );
             row.addEventListener( 'mouseenter', function () {
            var panels  = qsa( '.zc-tab-panel', tabGroup );
                 qsa( 'td', this ).forEach( function ( td ) {
 
                    td.style.background = '#eaf3fb';
            tabs.forEach( function( tab, i ) {
                } );
                 tab.addEventListener( 'click', function() {
            } );
                    tabs.forEach( function( t ) {
            row.addEventListener( 'mouseleave', function () {
                        t.style.color      = 'var(--zc-text-muted)';
                qsa( 'td', this ).forEach( function ( td ) {
                        t.style.borderColor = 'transparent';
                     td.style.background = '';
                        t.style.background  = 'transparent';
                 } );
                    });
             } );
                    panels.forEach( function( p ) { p.style.display = 'none'; } );
         } );
 
                    tab.style.color      = 'var(--zc-cyan)';
                    tab.style.borderColor = 'var(--zc-cyan)';
                     tab.style.background = 'var(--zc-cyan-dim)';
                    if ( panels[ i ] ) panels[ i ].style.display = 'block';
                 });
             });
 
            if ( tabs[ 0 ] ) tabs[ 0 ].click();
         });
     }
     }


     /* ── 9. Verified Badge Glow Pulse on Hover ───────────── */
     /* ── 9. Auto external link icon ─────────────────────── */
     function initVerifiedHover() {
     function initExternalLinks() {
         qsa( '.zc-verified-banner' ).forEach( function( el ) {
         qsa( '.mw-body-content a.external' ).forEach( function ( a ) {
             el.addEventListener( 'mouseenter', function() {
             if ( !qs( '.ext-icon', a ) ) {
                 this.style.boxShadow = '0 0 40px rgba(0,232,143,0.5)';
                 var icon = document.createElement( 'span' );
            });
                icon.className = 'ext-icon';
            el.addEventListener( 'mouseleave', function() {
                icon.setAttribute( 'aria-hidden', 'true' );
                 this.style.boxShadow = '';
                icon.textContent = ' ';
             });
                 icon.style.cssText = 'font-size:0.75em; color:#72777d; margin-left:1px;';
         });
                a.appendChild( icon );
             }
         } );
     }
     }


     /* ── 10. Reading Progress Bar ────────────────────────── */
     /* ── 10. Sidebar active link highlight ──────────────── */
     function initProgressBar() {
     function initSidebarHighlight() {
         var bar = document.createElement( 'div' );
         var currentPath = window.location.pathname;
        bar.id = 'zc-reading-progress';
         qsa( '#mw-panel a, .vector-menu-content-list a' ).forEach( function ( a ) {
         bar.style.cssText = [
             if ( a.pathname && a.pathname !== '/' && currentPath === a.pathname ) {
            'position:fixed', 'top:0', 'left:0', 'height:2px', 'width:0%',
                a.style.fontWeight = 'bold';
            'background:linear-gradient(90deg, var(--zc-cyan), var(--zc-violet))',
                a.style.color = '#202122';
            'z-index:9999', 'transition:width 0.1s linear',
            }
             'box-shadow:0 0 8px rgba(0,200,255,0.6)'
         } );
        ].join(';');
        document.body.appendChild( bar );
 
        window.addEventListener( 'scroll', function() {
            var docHeight    = document.documentElement.scrollHeight - window.innerHeight;
            var scrolled    = ( window.scrollY / docHeight ) * 100;
            bar.style.width  = Math.min( scrolled, 100 ) + '%';
         });
     }
     }


    /* ── 11. Back-to-Top Button ──────────────────────────── */
     /* ── Bootstrap ──────────────────────────────────────── */
    function initBackToTop() {
     ready( function () {
        var btn = document.createElement( 'button' );
        btn.id          = 'zc-back-to-top';
        btn.textContent = '↑';
        btn.title      = 'Back to top';
        btn.style.cssText = [
            'position:fixed', 'bottom:28px', 'right:28px',
            'width:42px', 'height:42px', 'border-radius:50%',
            'background:linear-gradient(135deg,var(--zc-cyan),var(--zc-violet))',
            'border:none', 'color:#000', 'font-size:1.2rem', 'font-weight:700',
            'cursor:pointer', 'opacity:0', 'transition:opacity 0.3s ease, transform 0.3s ease',
            'box-shadow:0 4px 20px rgba(0,200,255,0.4)', 'z-index:999'
        ].join(';');
 
        document.body.appendChild( btn );
 
        window.addEventListener( 'scroll', function() {
            btn.style.opacity  = window.scrollY > 400 ? '1' : '0';
            btn.style.transform = window.scrollY > 400 ? 'scale(1)' : 'scale(0.8)';
        });
 
        btn.addEventListener( 'click', function() {
            window.scrollTo( { top: 0, behavior: 'smooth' } );
        });
    }
 
    /* ── 12. Dark/Light mode toggle hint ────────────────── */
    // Wiki stays dark — but we persist the preference
    function initTheme() {
        document.documentElement.setAttribute( 'data-zc-theme', 'dark' );
    }
 
     /* ── Bootstrap ───────────────────────────────────────── */
     ready( function() {
        initTheme();
         initProgressBar();
         initProgressBar();
         initBackToTop();
         initBackToTop();
         initSearch();
         initSearchShortcut();
        initSidebarHighlight();
        initScrollAnimations();
        initCounters();
         initSmoothScroll();
         initSmoothScroll();
         initTOCSpy();
         initTOCSpy();
         initProfileTabs();
         initSidebarHighlight();
         initVerifiedHover();
         initScrollReveal();
        initTableHighlight();
        initExternalLinks();
        setTimeout( addCopyButtons, 400 );


        // Code copy needs a slight delay for SyntaxHighlight to render
         // Re-run after VisualEditor deactivates
        setTimeout( initCodeCopy, 500 );
         if ( mw && mw.hook ) {
 
            mw.hook( 've.deactivate' ).add( function () {
         // Re-run code copy after VE switches back to read mode
                setTimeout( addCopyButtons, 600 );
         mw.hook( 've.deactivate' ).add( function() {
            } );
            setTimeout( initCodeCopy, 800 );
        }
        });
     } );
     });


}() );
}() );

Revision as of 22:38, 28 July 2026

/* ============================================================
   ZelocoreCMS Wiki — MediaWiki:Common.js
   Light-theme compatible enhancements
   ============================================================ */
/* global mw, $ */
( function () {
    'use strict';

    var qs  = function ( s, c ) { return ( c || document ).querySelector( s ); };
    var qsa = function ( s, c ) { return Array.prototype.slice.call( ( c || document ).querySelectorAll( s ) ); };

    function ready( fn ) {
        if ( document.readyState !== 'loading' ) { fn(); } else { document.addEventListener( 'DOMContentLoaded', fn ); }
    }

    /* ── 1. Reading Progress Bar ─────────────────────────── */
    function initProgressBar() {
        var bar = document.createElement( 'div' );
        bar.id = 'zc-reading-progress';
        Object.assign( bar.style, {
            position: 'fixed', top: '0', left: '0', height: '3px', width: '0%',
            background: '#3366cc', zIndex: '9999', transition: 'width 0.1s linear',
            pointerEvents: 'none'
        } );
        document.body.appendChild( bar );
        window.addEventListener( 'scroll', function () {
            var doc = document.documentElement;
            var pct = ( window.scrollY / ( doc.scrollHeight - window.innerHeight ) ) * 100;
            bar.style.width = Math.min( pct, 100 ) + '%';
        }, { passive: true } );
    }

    /* ── 2. Back-to-Top Button ───────────────────────────── */
    function initBackToTop() {
        var btn = document.createElement( 'button' );
        btn.id = 'zc-back-to-top';
        btn.textContent = '↑';
        btn.title = 'Back to top';
        Object.assign( btn.style, {
            position: 'fixed', bottom: '24px', right: '24px',
            width: '40px', height: '40px', borderRadius: '50%',
            background: '#3366cc', border: 'none', color: '#fff',
            fontSize: '1.1rem', fontWeight: '700', cursor: 'pointer',
            opacity: '0', transition: 'opacity 0.25s ease, transform 0.25s ease',
            boxShadow: '0 2px 8px rgba(0,0,0,0.25)', zIndex: '998', transform: 'scale(0.8)'
        } );
        document.body.appendChild( btn );
        window.addEventListener( 'scroll', function () {
            var show = window.scrollY > 400;
            btn.style.opacity   = show ? '1' : '0';
            btn.style.transform = show ? 'scale(1)' : 'scale(0.8)';
        }, { passive: true } );
        btn.addEventListener( 'click', function () {
            window.scrollTo( { top: 0, behavior: 'smooth' } );
        } );
    }

    /* ── 3. Keyboard Shortcut: / or Ctrl+K focuses search ── */
    function initSearchShortcut() {
        var input = qs( '#searchInput, .cdx-text-input__input, input[name="search"]' );
        if ( !input ) { return; }
        document.addEventListener( 'keydown', function ( e ) {
            var active = document.activeElement;
            var isEditing = active && ( active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable );
            if ( ( ( e.ctrlKey || e.metaKey ) && e.key === 'k' ) || ( e.key === '/' && !isEditing ) ) {
                e.preventDefault();
                input.focus();
                input.select();
            }
        } );
    }

    /* ── 4. Code Block Copy Button ───────────────────────── */
    function addCopyButtons() {
        qsa( 'pre, .mw-highlight' ).forEach( function ( block ) {
            if ( qs( '.zc-copy-btn', block ) ) { return; }
            var btn = document.createElement( 'button' );
            btn.className = 'zc-copy-btn';
            btn.textContent = 'Copy';
            Object.assign( btn.style, {
                position: 'absolute', top: '6px', right: '6px',
                background: '#f8f9fa', border: '1px solid #a2a9b1',
                color: '#54595d', fontSize: '0.75rem', padding: '2px 10px',
                borderRadius: '2px', cursor: 'pointer', fontFamily: 'inherit'
            } );
            btn.addEventListener( 'click', function () {
                var text = block.textContent.replace( /^Copy$|^✓ Copied$/m, '' ).trim();
                navigator.clipboard.writeText( text ).then( function () {
                    btn.textContent = '✓ Copied';
                    btn.style.color = '#14866d';
                    btn.style.borderColor = '#14866d';
                    setTimeout( function () {
                        btn.textContent = 'Copy';
                        btn.style.color = '#54595d';
                        btn.style.borderColor = '#a2a9b1';
                    }, 2000 );
                } );
            } );
            block.style.position = 'relative';
            block.appendChild( btn );
        } );
    }

    /* ── 5. Scroll-reveal for elements ──────────────────── */
    function initScrollReveal() {
        if ( !window.IntersectionObserver ) { return; }
        var obs = new IntersectionObserver( function ( entries ) {
            entries.forEach( function ( entry ) {
                if ( entry.isIntersecting ) {
                    entry.target.style.opacity = '1';
                    entry.target.style.transform = 'translateY(0)';
                    obs.unobserve( entry.target );
                }
            } );
        }, { threshold: 0.08 } );
        qsa( '.mp-portal-cell' ).forEach( function ( el ) {
            el.style.opacity = '0';
            el.style.transform = 'translateY(12px)';
            el.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
            obs.observe( el );
        } );
    }

    /* ── 6. Smooth anchor scrolling ─────────────────────── */
    function initSmoothScroll() {
        qsa( 'a[href^="#"]' ).forEach( function ( a ) {
            a.addEventListener( 'click', function ( e ) {
                var id = decodeURIComponent( this.hash.slice( 1 ) );
                var target = document.getElementById( id );
                if ( !target ) { return; }
                e.preventDefault();
                window.scrollTo( {
                    top: target.getBoundingClientRect().top + window.scrollY - 70,
                    behavior: 'smooth'
                } );
            } );
        } );
    }

    /* ── 7. TOC scroll-spy (highlight current heading) ──── */
    function initTOCSpy() {
        var headings = qsa( '.mw-body-content h2, .mw-body-content h3' );
        var tocLinks = qsa( '#toc a, .toc a' );
        if ( !headings.length || !tocLinks.length ) { return; }
        var ticking = false;
        window.addEventListener( 'scroll', function () {
            if ( ticking ) { return; }
            ticking = true;
            requestAnimationFrame( function () {
                var scrollY = window.scrollY + 80;
                var current = '';
                headings.forEach( function ( h ) {
                    if ( h.getBoundingClientRect().top + window.scrollY <= scrollY ) {
                        current = '#' + h.id;
                    }
                } );
                tocLinks.forEach( function ( link ) {
                    var isCurrent = link.getAttribute( 'href' ) === current;
                    link.style.fontWeight = isCurrent ? 'bold' : '';
                    link.style.color = isCurrent ? '#202122' : '';
                } );
                ticking = false;
            } );
        }, { passive: true } );
    }

    /* ── 8. Table row highlight ─────────────────────────── */
    function initTableHighlight() {
        qsa( '.wikitable tbody tr' ).forEach( function ( row ) {
            row.addEventListener( 'mouseenter', function () {
                qsa( 'td', this ).forEach( function ( td ) {
                    td.style.background = '#eaf3fb';
                } );
            } );
            row.addEventListener( 'mouseleave', function () {
                qsa( 'td', this ).forEach( function ( td ) {
                    td.style.background = '';
                } );
            } );
        } );
    }

    /* ── 9. Auto external link icon ─────────────────────── */
    function initExternalLinks() {
        qsa( '.mw-body-content a.external' ).forEach( function ( a ) {
            if ( !qs( '.ext-icon', a ) ) {
                var icon = document.createElement( 'span' );
                icon.className = 'ext-icon';
                icon.setAttribute( 'aria-hidden', 'true' );
                icon.textContent = ' ↗';
                icon.style.cssText = 'font-size:0.75em; color:#72777d; margin-left:1px;';
                a.appendChild( icon );
            }
        } );
    }

    /* ── 10. Sidebar active link highlight ──────────────── */
    function initSidebarHighlight() {
        var currentPath = window.location.pathname;
        qsa( '#mw-panel a, .vector-menu-content-list a' ).forEach( function ( a ) {
            if ( a.pathname && a.pathname !== '/' && currentPath === a.pathname ) {
                a.style.fontWeight = 'bold';
                a.style.color = '#202122';
            }
        } );
    }

    /* ── Bootstrap ──────────────────────────────────────── */
    ready( function () {
        initProgressBar();
        initBackToTop();
        initSearchShortcut();
        initSmoothScroll();
        initTOCSpy();
        initSidebarHighlight();
        initScrollReveal();
        initTableHighlight();
        initExternalLinks();
        setTimeout( addCopyButtons, 400 );

        // Re-run after VisualEditor deactivates
        if ( mw && mw.hook ) {
            mw.hook( 've.deactivate' ).add( function () {
                setTimeout( addCopyButtons, 600 );
            } );
        }
    } );

}() );