MediaWiki:Common.js
Appearance
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* ============================================================
GOLDEN GATE BRIDGE — MediaWiki Theme JavaScript
Paste into: MediaWiki:Common.js
Features:
1. Reading progress bar
2. Fog canvas animation
3. Scroll-reveal for content sections
4. Back-to-top button
5. Anchor links on headings
6. Time-based dynamic header gradient
7. Sidebar cable decoration
8. Smooth image lazy-load effect
============================================================ */
( function () {
'use strict';
/* ─────────────────────────────────────────────────────────
1. READING PROGRESS BAR
A slim orange bar at the very top of the viewport that
fills as the user scrolls through the page.
───────────────────────────────────────────────────────── */
function initProgress() {
var bar = document.createElement( 'div' );
bar.id = 'ggb-progress';
document.body.insertBefore( bar, document.body.firstChild );
function update() {
var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
var docH = document.documentElement.scrollHeight - window.innerHeight;
var pct = docH > 0 ? Math.min( ( scrollTop / docH ) * 100, 100 ) : 0;
bar.style.width = pct + '%';
}
window.addEventListener( 'scroll', update, { passive: true } );
update();
}
/* ─────────────────────────────────────────────────────────
2. FOG CANVAS ANIMATION
A canvas layered over the page renders softly drifting
fog puffs that become visible as the user scrolls —
simulating San Francisco's famous marine layer.
───────────────────────────────────────────────────────── */
function initFog() {
var canvas = document.createElement( 'canvas' );
canvas.id = 'ggb-fog-canvas';
Object.assign( canvas.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: '9998',
opacity: '0',
transition: 'opacity 2.5s ease',
} );
document.body.appendChild( canvas );
var ctx = canvas.getContext( '2d' );
var W, H, particles = [], raf;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
// A single fog puff
function Puff() {
var self = this;
self.reset = function () {
self.x = Math.random() * W * 1.5 - W * 0.25;
self.y = Math.random() * H;
self.r = 100 + Math.random() * 220;
self.alpha = 0.008 + Math.random() * 0.028;
self.vx = -0.06 - Math.random() * 0.1;
self.vy = -0.01 + Math.random() * 0.018;
};
self.reset();
self.update = function () {
self.x += self.vx;
self.y += self.vy;
if ( self.x + self.r < 0 ) self.reset();
};
self.draw = function () {
var g = ctx.createRadialGradient( self.x, self.y, 0, self.x, self.y, self.r );
g.addColorStop( 0, 'rgba(245,242,236,' + self.alpha + ')' );
g.addColorStop( 0.5, 'rgba(240,236,228,' + ( self.alpha * 0.5 ) + ')' );
g.addColorStop( 1, 'rgba(245,242,236,0)' );
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc( self.x, self.y, self.r, 0, Math.PI * 2 );
ctx.fill();
};
}
function loop() {
ctx.clearRect( 0, 0, W, H );
for ( var i = 0; i < particles.length; i++ ) {
particles[ i ].update();
particles[ i ].draw();
}
raf = requestAnimationFrame( loop );
}
function init() {
resize();
particles = [];
for ( var i = 0; i < 24; i++ ) {
var p = new Puff();
// Stagger starting positions so they don't all start at the same x
p.x = Math.random() * W;
particles.push( p );
}
loop();
}
window.addEventListener( 'resize', resize, { passive: true } );
init();
// Fog fades in after first scroll, fades out back to top
var fogVisible = false;
window.addEventListener( 'scroll', function () {
var shouldShow = window.pageYOffset > 250;
if ( shouldShow !== fogVisible ) {
fogVisible = shouldShow;
canvas.style.opacity = fogVisible ? '1' : '0';
}
}, { passive: true } );
}
/* ─────────────────────────────────────────────────────────
3. SCROLL REVEAL
Uses IntersectionObserver to slide content elements in
as they enter the viewport — staggered for a wave feel.
───────────────────────────────────────────────────────── */
function initScrollReveal() {
if ( !window.IntersectionObserver ) return;
var selectors = [
'.mw-body-content h2',
'.mw-body-content h3',
'.mw-body-content table.wikitable',
'.mw-body-content .thumb',
'.mw-body-content blockquote',
'.mw-body-content .infobox',
'.mw-body-content .hatnote',
'#toc',
].join( ', ' );
var targets = document.querySelectorAll( selectors );
var observer = new IntersectionObserver( function ( entries ) {
entries.forEach( function ( entry, i ) {
if ( entry.isIntersecting ) {
var el = entry.target;
var delay = ( i % 4 ) * 70;
setTimeout( function () {
el.classList.add( 'ggb-in' );
if ( i % 2 === 1 ) el.classList.add( 'ggb-in-delay' );
}, delay );
observer.unobserve( el );
}
} );
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
} );
targets.forEach( function ( el ) {
el.classList.add( 'ggb-reveal' );
observer.observe( el );
} );
}
/* ─────────────────────────────────────────────────────────
4. BACK-TO-TOP BUTTON
An orange circular button appears after scrolling 400px.
Clicking it smoothly returns to the top of the page.
───────────────────────────────────────────────────────── */
function initBackToTop() {
var btn = document.createElement( 'button' );
btn.id = 'ggb-top-btn';
btn.title = 'Back to top';
btn.textContent = '↑';
btn.setAttribute( 'aria-label', 'Back to top' );
document.body.appendChild( btn );
btn.addEventListener( 'click', function () {
window.scrollTo( { top: 0, behavior: 'smooth' } );
} );
window.addEventListener( 'scroll', function () {
if ( window.pageYOffset > 400 ) {
btn.classList.add( 'visible' );
} else {
btn.classList.remove( 'visible' );
}
}, { passive: true } );
}
/* ─────────────────────────────────────────────────────────
5. HEADING ANCHOR LINKS
Adds a ⚓ anchor icon that appears on heading hover,
making it easy to copy links to specific sections.
───────────────────────────────────────────────────────── */
function initAnchorLinks() {
var headlines = document.querySelectorAll( '.mw-headline[id]' );
headlines.forEach( function ( el ) {
var icon = document.createElement( 'a' );
icon.href = '#' + el.id;
icon.className = 'ggb-anchor-link';
icon.textContent = ' ¶';
icon.title = 'Link to this section';
icon.setAttribute( 'aria-label', 'Link to section: ' + el.textContent );
Object.assign( icon.style, {
color: 'rgba(200,92,43,0)',
fontSize: '0.65em',
fontFamily: "'Josefin Sans', sans-serif",
letterSpacing: '0',
marginLeft: '8px',
textDecoration: 'none',
transition: 'color 0.2s ease',
verticalAlign: 'middle',
} );
var heading = el.parentElement;
heading.addEventListener( 'mouseenter', function () {
icon.style.color = 'rgba(200,92,43,0.55)';
} );
heading.addEventListener( 'mouseleave', function () {
icon.style.color = 'rgba(200,92,43,0)';
} );
icon.addEventListener( 'mouseenter', function () {
this.style.color = '#C85C2B';
} );
icon.addEventListener( 'mouseleave', function () {
this.style.color = 'rgba(200,92,43,0.55)';
} );
el.appendChild( icon );
} );
}
/* ─────────────────────────────────────────────────────────
6. TIME-BASED DYNAMIC GRADIENT
The header and page background shift colour based on
the time of day — from cold foggy dawn to warm sunset
to deep bay-at-night, just like the real bridge.
───────────────────────────────────────────────────────── */
function initDynamicGradient() {
var hour = new Date().getHours();
var gradient;
if ( hour >= 5 && hour < 8 ) {
// Pre-dawn / dawn: lavender sky, orange horizon
gradient = 'linear-gradient(150deg, #2A1A38 0%, #7A3020 45%, #C85C2B 100%)';
} else if ( hour >= 8 && hour < 11 ) {
// Morning: fog burning off, cool deep navy
gradient = 'linear-gradient(150deg, #0B1B2B 0%, #1A3A58 60%, #162840 100%)';
} else if ( hour >= 11 && hour < 16 ) {
// Midday: classic deep bay navy
gradient = 'linear-gradient(150deg, #0B1B2B 0%, #162840 50%, #0B1B2B 100%)';
} else if ( hour >= 16 && hour < 19 ) {
// Golden hour: warm amber bleeds in from right
gradient = 'linear-gradient(150deg, #0B1B2B 0%, #5A2010 55%, #C9A84C 100%)';
} else if ( hour >= 19 && hour < 21 ) {
// Sunset: deep vermillion, spectacular
gradient = 'linear-gradient(150deg, #180808 0%, #6A2010 35%, #C85C2B 80%, #C9A84C 100%)';
} else {
// Night: starless black bay
gradient = 'linear-gradient(150deg, #04090E 0%, #0B1B2B 55%, #0F2235 100%)';
}
// Apply to header elements
var targets = [
document.getElementById( 'mw-head' ),
document.getElementById( 'mw-head-base' ),
document.getElementById( 'mw-page-base' ),
document.querySelector( '.vector-header' ),
];
targets.forEach( function ( el ) {
if ( el ) el.style.background = gradient;
} );
// Also subtly tint the sidebar top
var panel = document.getElementById( 'mw-panel' ) ||
document.querySelector( '.vector-column-start' );
if ( panel ) {
panel.style.background =
'linear-gradient(180deg, #0D1F30 0%, #0B1B2B 20%)';
}
}
/* ─────────────────────────────────────────────────────────
7. SIDEBAR CABLE DECORATION
Injects an animated SVG suspension cable graphic into
the sidebar — a subtle architectural nod to the bridge.
───────────────────────────────────────────────────────── */
function initSidebarCable() {
var panel = document.getElementById( 'mw-panel' ) ||
document.querySelector( '.vector-column-start' );
if ( !panel ) return;
var svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
svg.setAttribute( 'viewBox', '0 0 20 300' );
svg.setAttribute( 'preserveAspectRatio', 'none' );
Object.assign( svg.style, {
position: 'absolute',
top: '0',
right: '0',
width: '20px',
height: '100%',
opacity: '0.6',
pointerEvents: 'none',
} );
// Main cable strand
var cable = document.createElementNS( 'http://www.w3.org/2000/svg', 'line' );
cable.setAttribute( 'x1', '10' ); cable.setAttribute( 'y1', '0' );
cable.setAttribute( 'x2', '10' ); cable.setAttribute( 'y2', '300' );
cable.setAttribute( 'stroke', '#C85C2B' );
cable.setAttribute( 'stroke-width', '1.5' );
cable.setAttribute( 'stroke-opacity', '0.5' );
svg.appendChild( cable );
// Horizontal suspender ticks
for ( var y = 20; y < 300; y += 28 ) {
var tick = document.createElementNS( 'http://www.w3.org/2000/svg', 'line' );
tick.setAttribute( 'x1', '6' ); tick.setAttribute( 'y1', String( y ) );
tick.setAttribute( 'x2', '14' ); tick.setAttribute( 'y2', String( y ) );
tick.setAttribute( 'stroke', '#C85C2B' );
tick.setAttribute( 'stroke-width', '0.8' );
tick.setAttribute( 'stroke-opacity', '0.35' );
svg.appendChild( tick );
}
// Animate: gentle cable-sway using CSS animation on the element
var style = document.createElement( 'style' );
style.textContent = [
'@keyframes ggbCableSway {',
' 0%,100% { transform: scaleY(1.0) translateY(0px); }',
' 50% { transform: scaleY(1.002) translateY(-2px); }',
'}',
'#ggb-cable-svg {',
' animation: ggbCableSway 6s ease-in-out infinite;',
' transform-origin: top center;',
'}',
].join( '\n' );
document.head.appendChild( style );
svg.id = 'ggb-cable-svg';
panel.style.position = 'relative';
panel.style.overflow = 'hidden';
panel.appendChild( svg );
}
/* ─────────────────────────────────────────────────────────
8. IMAGE LAZY-LOAD FADE
Images in the article content fade in as they load,
rather than popping in abruptly.
───────────────────────────────────────────────────────── */
function initImageFade() {
var images = document.querySelectorAll( '.mw-body-content img' );
var fadeStyle = document.createElement( 'style' );
fadeStyle.textContent = [
'.mw-body-content img { opacity: 0; transition: opacity 0.6s ease; }',
'.mw-body-content img.ggb-img-loaded { opacity: 1; }',
].join( '\n' );
document.head.appendChild( fadeStyle );
images.forEach( function ( img ) {
if ( img.complete ) {
img.classList.add( 'ggb-img-loaded' );
} else {
img.addEventListener( 'load', function () {
img.classList.add( 'ggb-img-loaded' );
} );
img.addEventListener( 'error', function () {
img.classList.add( 'ggb-img-loaded' ); // show broken icon
} );
}
} );
}
/* ─────────────────────────────────────────────────────────
9. ACTIVE TOC HIGHLIGHT
Highlights the current section in the Table of Contents
as the user scrolls through the article.
───────────────────────────────────────────────────────── */
function initTocHighlight() {
var toc = document.getElementById( 'toc' ) || document.querySelector( '.toc' );
if ( !toc ) return;
var headings = Array.from(
document.querySelectorAll( '.mw-body-content h2[id], .mw-body-content h3[id]' )
);
if ( !headings.length ) return;
// Inject highlight style
var style = document.createElement( 'style' );
style.textContent = [
'.toc a.ggb-toc-active {',
' color: #C85C2B !important;',
' font-weight: 600;',
' padding-left: 4px;',
' border-left: 2px solid #C85C2B;',
'}',
].join( '\n' );
document.head.appendChild( style );
function onScroll() {
var scrollY = window.pageYOffset + 100;
var current = headings[ 0 ];
for ( var i = 0; i < headings.length; i++ ) {
if ( headings[ i ].getBoundingClientRect().top + window.pageYOffset <= scrollY ) {
current = headings[ i ];
}
}
// Find the matching TOC link
var id = current.id || ( current.querySelector( '.mw-headline' ) && current.querySelector( '.mw-headline' ).id );
if ( !id ) return;
toc.querySelectorAll( 'a' ).forEach( function ( a ) {
a.classList.remove( 'ggb-toc-active' );
} );
var active = toc.querySelector( 'a[href="#' + id + '"]' );
if ( active ) active.classList.add( 'ggb-toc-active' );
}
window.addEventListener( 'scroll', onScroll, { passive: true } );
onScroll();
}
/* ─────────────────────────────────────────────────────────
10. SEARCH BOX ENHANCEMENT
Adds a keyboard shortcut (/) to focus the search box,
and shows a subtle hint label near the input.
───────────────────────────────────────────────────────── */
function initSearchShortcut() {
var searchInput = document.getElementById( 'searchInput' ) ||
document.querySelector( '.vector-search-box input[type="search"]' );
if ( !searchInput ) return;
// Keyboard shortcut: / focuses search (unless user is in an input)
document.addEventListener( 'keydown', function ( e ) {
if ( e.key === '/' &&
document.activeElement.tagName !== 'INPUT' &&
document.activeElement.tagName !== 'TEXTAREA' ) {
e.preventDefault();
searchInput.focus();
searchInput.select();
}
} );
// ESC blurs search
searchInput.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Escape' ) searchInput.blur();
} );
// Hint label
var hint = document.createElement( 'span' );
hint.textContent = '/';
Object.assign( hint.style, {
background: 'rgba(138,155,176,0.15)',
border: '1px solid rgba(138,155,176,0.3)',
borderRadius: '3px',
color: 'rgba(138,155,176,0.7)',
fontFamily: "'Josefin Sans', sans-serif",
fontSize: '10px',
fontWeight: '600',
letterSpacing: '0.05em',
padding: '1px 5px',
pointerEvents: 'none',
position: 'absolute',
right: '40px',
top: '50%',
transform: 'translateY(-50%)',
transition: 'opacity 0.2s ease',
} );
var searchForm = searchInput.closest( 'form' ) || searchInput.parentElement;
if ( searchForm ) {
searchForm.style.position = 'relative';
searchForm.appendChild( hint );
}
searchInput.addEventListener( 'focus', function () { hint.style.opacity = '0'; } );
searchInput.addEventListener( 'blur', function () { hint.style.opacity = '1'; } );
}
/* ─────────────────────────────────────────────────────────
11. EXTERNAL LINK ICON
Adds a small external link indicator after links that
point outside the wiki.
───────────────────────────────────────────────────────── */
function initExternalLinkIcons() {
var style = document.createElement( 'style' );
style.textContent = [
'.mw-body-content a.external::after {',
' content: " ↗";',
' color: rgba(200,92,43,0.6);',
' font-size: 0.75em;',
' vertical-align: super;',
' margin-left: 1px;',
'}',
].join( '\n' );
document.head.appendChild( style );
}
/* ─────────────────────────────────────────────────────────
INIT — run everything after DOM is ready
───────────────────────────────────────────────────────── */
function run() {
// Core UI enhancements
initProgress();
initBackToTop();
initDynamicGradient();
initSidebarCable();
// Content enhancements
initScrollReveal();
initAnchorLinks();
initTocHighlight();
initImageFade();
// Search
initSearchShortcut();
// Link icons
initExternalLinkIcons();
// Fog — init last as it starts a canvas animation loop
initFog();
// Console credit
if ( window.console && console.log ) {
console.log( '%c🌉 Golden Gate Bridge Theme active', 'color:#C85C2B;font-family:Georgia,serif;font-size:14px;' );
}
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', run );
} else {
// mw.loader available: use MediaWiki's ready hook if possible
if ( typeof mw !== 'undefined' && mw.hook ) {
mw.hook( 'wikipage.content' ).add( function () { run(); } );
} else {
run();
}
}
} )();