跳转到内容
打开/关闭菜单
  • 450 条目
  • 805 文件
  • 22 用户
  • 9101 编辑
Blockland Wiki
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
无编辑摘要
无编辑摘要
 
(未显示同一用户的2个中间版本)
第1行: 第1行:
/* =================================================================
/* ====================================================================
   KURANOKA PAGE TRANSITION V3.1 — ASHES / REBIRTH
   KURANOKA / KURONATA V6 — CATACLYSM / DAWN
   Paste into MediaWiki:KuranokaPageTransition.js (replace V2).
   MediaWiki:KuranokaPageTransition.js
   Load ONLY on wiki pages Kuranoka and Kuronata via Citizen.js.
  Same-origin iframe + history.pushState: NO full reload on toggles.
   Needs the matching KuranokaPageTransition.css. No libraries.
   Irregular tear + WebGL combustion + layered turbulent smoke/impact.
   ================================================================= */
  Wiki easter eggs: [[Kuronata|...]] / [[Kuranoka|...]].
   Backgrounds are prewarmed on page load. Zero external dependencies.
   ==================================================================== */
(function () {
(function () {
     'use strict';
     'use strict';


     // The destination iframe loads this script too. NEVER nest previews.
     // The preview is a complete MediaWiki page. It must NOT spawn another.
     if (window !== window.top || window.__kkptV3) return;
     if (window !== window.top || window.__kkptV6) return;
    window.__kkptV3 = true;
    window.__kkptVersion = '3.1';
     if (!window.mw || !mw.config) return;
     if (!window.mw || !mw.config) return;
    if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;


     const page = mw.config.get('wgPageName');
    // The URL is the source of truth if a skin/extension restored a page
     if (page !== 'Kuranoka' && page !== 'Kuronata') return;
    // without immediately updating mw.config (soft navigation / bfcache).
     const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/);
    const urlPage = pathMatch ? pathMatch[1] : new URLSearchParams(location.search).get('title');
    const basePage = ['Kuranoka', 'Kuronata'].includes(urlPage)
        ? urlPage : mw.config.get('wgPageName');
    const action = new URLSearchParams(location.search).get('action') || 'view';
     if (!['Kuranoka', 'Kuronata'].includes(basePage) || action !== 'view') return;


     const burn = page === 'Kuranoka';
     window.__kkptV6 = true;
     const target = burn ? 'Kuronata' : 'Kuranoka';
    window.__kkptVersion = '6.0';
     const targetUrl = '/wiki/' + encodeURIComponent(target);
 
     const DURATION = burn ? 3900 : 3200;
    // For the easter egg: links inside the Wiki are the default trigger.
     const startClip = burn ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
    // Change to true only if you also want the fixed switch button.
     const warmedImages = new Map();
    const SHOW_FLOATING_BUTTON = false;
    const rand = (min, max) => min + Math.random() * (max - min);
 
     const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka';
     const baseUrl = location.href;
    const baseTitle = document.title;
    const otherUrl = new URL('/wiki/' + encodeURIComponent(otherPage), location.href);
     const clipSide = basePage === 'Kuranoka' ? 'right' : 'left';
     const hideClip = clipSide === 'right' ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
     const sleep = ms => new Promise(r => setTimeout(r, ms));
     const smooth = t => t * t * (3 - 2 * t);
     const smooth = t => t * t * (3 - 2 * t);
     const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
     const imagePool = new Map();
    const frame = () => new Promise(resolve => requestAnimationFrame(resolve));


     let iframe = null;
     let active = false; // false: original top-level doc; true: otherPage iframe
     let warmPromise = null;
     let animating = false;
     let loaded = false;
     let ready = false;
     let busy = false;
     let loading = null;
     let button = null;
     let button, labelTitle, labelSub, labelIcon, frame;
     let previewIssue = '';
     let otherTitle = otherPage + ' - Blockland Wiki';


     // Look at actual CSS in BOTH documents. This finds Background template
     function whichPage() { return active ? otherPage : basePage; }
    // body backgrounds as well as CSS pseudo-element backdrops.
    function destination() { return active ? basePage : otherPage; }
     function findBackgroundUrls(doc) {
    function isDark(page) { return page === 'Kuronata'; }
         const urls = new Set();
    function pathPage() {
         const nodes = [doc.documentElement, doc.body];
        const url = new URL(location.href);
         const selectors = [
        if (url.pathname.startsWith('/wiki/')) {
            '#mw-content-text', '.mw-parser-output', '.citizen-page-container',
            try { return decodeURIComponent(url.pathname.slice(6)).replace(/_/g, ' '); }
             '.citizen-body-container', '.mw-page-container',
            catch (e) { return ''; }
             '.background-theme', '[class*="background"]', '[id*="background"]',
        }
             '[style*="background"]', '[data-background]'
        return url.searchParams.get('title') || '';
         ];
    }
 
     function updateButton() {
        if (!button) return;
         const to = destination();
         button.href = to === basePage ? baseUrl : otherUrl.href;
        labelTitle.textContent = to.toUpperCase();
        labelSub.textContent = isDark(to) ? 'BURN THE SKY' : 'RECLAIM THE SKY';
         labelIcon.textContent = isDark(to) ? '✦' : '❄';
        button.classList.toggle('kkpt-dark', isDark(whichPage()));
        button.setAttribute('aria-label', isDark(to) ? '燃尽天空,进入 Kuronata' : '驱散黑暗,回到 Kuranoka');
    }
 
    // Accept the two standard MediaWiki link forms, while rejecting
    // edit/action links, external sites and similarly named articles.
    function wikiLinkPage(anchor, doc) {
        try {
             const url = new URL(anchor.href, doc.baseURI);
            if (url.origin !== location.origin) return null;
            const action = url.searchParams.get('action');
             if (action && action !== 'view') return null;
            let title = url.searchParams.get('title');
            if (!title) {
                const matched = url.pathname.match(/^\/wiki\/([^/?#]+)\/?$/);
                if (matched) title = decodeURIComponent(matched[1]);
            }
            title = (title || '').replace(/_/g, ' ');
             return title === 'Kuranoka' || title === 'Kuronata' ? title : null;
         } catch (_) {
            return null;
        }
    }


         selectors.forEach(selector => {
    function decoratePortalLinks(doc, targetPage) {
            try {
        // Use only the article: sidebar, edit tools and site-wide links
                doc.querySelectorAll(selector).forEach((node, index) => {
        // must retain their usual navigation behavior.
                    if (index < 45 && !nodes.includes(node)) nodes.push(node);
         const content = doc.querySelector('#mw-content-text,.mw-parser-output');
                });
        if (!content) return;
             } catch (e) { /* selector may not exist in an older skin */ }
        content.querySelectorAll('a[href]').forEach(anchor => {
            if (wikiLinkPage(anchor, doc) !== targetPage) return;
            anchor.classList.add('kkpt-portal-link');
            anchor.classList.toggle('kkpt-portal-red', targetPage === 'Kuronata');
            anchor.classList.toggle('kkpt-portal-blue', targetPage === 'Kuranoka');
            const caption = (anchor.textContent || '').trim();
             if (caption) anchor.dataset.kkptText = caption.slice(0, 120);
            anchor.setAttribute('aria-label', caption + ',切换至 ' + targetPage);
         });
         });
    }


    function watchPortalLinks(doc, targetPage) {
        decoratePortalLinks(doc, targetPage);
        if (doc.__kkptPortalObserver) return;
        const root = doc.querySelector('#mw-content-text,.mw-parser-output');
        if (!root) return;
        const observer = new MutationObserver(() => decoratePortalLinks(doc, targetPage));
        observer.observe(root, {childList:true,subtree:true});
        doc.__kkptPortalObserver = observer;
    }
    function isPlainClick(event) {
        return !event.defaultPrevented && event.button === 0 &&
            !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
    }
    function findAnchor(event, doc) {
        const target = event.target;
        return target && target.nodeType === 1 &&
            typeof target.closest === 'function'
            ? target.closest('a[href]') : null;
    }
    function portalClick(event, doc, targetPage, nextActive) {
        if (!isPlainClick(event)) return false;
        const anchor = findAnchor(event, doc);
        if (!anchor || (anchor.target && anchor.target !== '_self') ||
            anchor.hasAttribute('download') ||
            wikiLinkPage(anchor, doc) !== targetPage) return false;
        event.preventDefault();
        requestSwap(nextActive);
        return true;
    }
    // Read backgrounds after background-template code populates --bg-image.
    function backgroundUrls(doc) {
        const urls = new Set();
        const selector = [doc.documentElement, doc.body];
        try {
            doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container')
                .forEach((el, i) => { if (i < 65) selector.push(el); });
        } catch (e) { /* use html/body */ }
         function add(value) {
         function add(value) {
             if (!value || typeof value !== 'string') return;
             if (!value) return;
             const urlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
             const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
             let match;
             let match;
            let hasCssUrl = false;
             while ((match = pattern.exec(value))) {
             while ((match = urlPattern.exec(value))) {
                 try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
                 hasCssUrl = true;
                if (match[2]) {
                    try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
                }
             }
             }
             if (!hasCssUrl && /^(https?:\/\/|\/|\.\/|\.\.\/).+\.(?:png|jpe?g|webp|avif|gif|svg)(?:[?#].*)?$/i.test(value)) {
             if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) {
                 try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
                 try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
             }
             }
         }
         }
 
         selector.forEach(el => {
         nodes.forEach(node => {
             if (!el) return;
             if (!node) return;
             try {
             try {
                const win = doc.defaultView;
                 [null, '::before', '::after'].forEach(pseudo => {
                 [null, '::before', '::after'].forEach(pseudo => {
                     const style = win.getComputedStyle(node, pseudo);
                     const style = doc.defaultView.getComputedStyle(el, pseudo);
                     if (style) {
                     if (!style) return;
                        add(style.backgroundImage);
                    add(style.backgroundImage);
                        add(style.maskImage);
                    add(style.getPropertyValue('--bg-image'));
                        add(style.getPropertyValue('--background-image'));
                    add(style.getPropertyValue('--background-image'));
                        add(style.getPropertyValue('--bg-image'));
                    }
                 });
                 });
            } catch (e) { /* ignore unsupported pseudo styles */ }
                if (el.dataset) add(el.dataset.bgImage);
 
            } catch (e) { /* style not yet ready */ }
            // MediaWiki Background templates often expose URLs as data-*.
            if (node.dataset) Object.entries(node.dataset).forEach(([key, value]) => {
                if (/background|wallpaper|image|backdrop|bg/i.test(key)) add(value);
            });
         });
         });
         return [...urls];
         return [...urls];
     }
     }


     function decodeBackground(url) {
     function warmImage(url) {
         if (warmedImages.has(url)) return warmedImages.get(url);
         if (imagePool.has(url)) return imagePool.get(url).promise;
         const image = new Image();
         const image = new Image();
        image.decoding = 'async';
        image.fetchPriority = 'high';
         const promise = new Promise(resolve => {
         const promise = new Promise(resolve => {
            // An unreachable wallpaper must not freeze the whole transition.
             let finished = false;
             let finished = false;
             const timeout = setTimeout(() => finish(false), 4500);
             const timeout = setTimeout(() => done(false), 5000);
             function finish(ok) {
             function done(ok) {
                 if (finished) return;
                 if (finished) return;
                 finished = true;
                 finished = true;
第111行: 第186行:
                 resolve(ok);
                 resolve(ok);
             }
             }
             image.onload = () => finish(true);
             image.onload = () => done(true);
             image.onerror = () => finish(false);
             image.onerror = () => done(false);
             image.src = url;
             image.src = url;
             if (image.complete) finish(image.naturalWidth > 0);
             if (image.complete) done(image.naturalWidth > 0);
         }).then(async success => {
         }).then(ok => ok && image.decode
            if (success && image.decode) {
            ? Promise.race([image.decode().then(() => true).catch(() => true),sleep(900).then(() => true)])
                try {
            : ok);
                    await Promise.race([image.decode(), pause(900)]);
         imagePool.set(url, { image, promise });
                } catch (e) { /* already loaded */ }
            }
            return success;
        });
        // Keep an Image object alive so the browser can reuse its decoded data.
        warmedImages.set(url, promise);
        if (!window.__kkptImagePool) window.__kkptImagePool = [];
         window.__kkptImagePool.push(image);
         return promise;
         return promise;
     }
     }


     async function warmBackgrounds(doc) {
     async function warmBackgrounds(doc) {
         const urls = findBackgroundUrls(doc);
         const urls = backgroundUrls(doc);
         if (urls.length) await Promise.allSettled(urls.map(decodeBackground));
         await Promise.allSettled(urls.map(warmImage));
        return urls.length;
     }
     }


     function createPreviewEarly() {
     function wireFrame() {
         if (warmPromise) return warmPromise;
         try {
            const doc = frame.contentDocument;
            if (!doc) return;
            otherTitle = doc.title || otherTitle;
            watchPortalLinks(doc, basePage);
            if (doc.__kkptFrameBridge) return;
            doc.__kkptFrameBridge = true;
 
            // Non-anchor navigation should leave the iframe and open normally
            // in the top window, so the browser's URL doesn't become stale.
            doc.addEventListener('click', event => {
                if (!active || !isPlainClick(event)) return;
                if (portalClick(event, doc, basePage, false)) return;
                const el = findAnchor(event, doc);
                if (!el || (el.target && el.target !== '_self')) return;
                const href = el.href;
                if (!href || href.startsWith('javascript:')) return;
                const next = new URL(href, doc.baseURI);
                // Same-document fragments stay inside the iframe.
                const currentFrame = new URL(frame.contentWindow.location.href);
                if (next.origin === currentFrame.origin &&
                    next.pathname === currentFrame.pathname &&
                    next.search === currentFrame.search && next.hash) {
                    history.replaceState(history.state, '', next.href);
                    return;
                }
                event.preventDefault();
                window.location.assign(next.href);
            }, true);
 
            doc.addEventListener('submit', event => {
                if (active && event.target instanceof doc.defaultView.HTMLFormElement) {
                    event.target.target = '_top';
                }
            }, true);
        } catch (err) {
            console.warn('[KuranokaPageTransition] Frame link bridge unavailable:', err);
        }
    }


         iframe = document.createElement('iframe');
    function warmFrame() {
         iframe.id = 'kkpt-preview';
        if (loading) return loading;
         iframe.title = target + ' — 预载画面';
         frame = document.createElement('iframe');
         iframe.tabIndex = -1;
         frame.id = 'kkpt-preview';
         iframe.setAttribute('aria-hidden', 'true');
         frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop
         iframe.setAttribute('loading', 'eager');
        frame.title = otherPage + ' 预载页面';
         iframe.style.clipPath = startClip;
         frame.tabIndex = -1;
         frame.setAttribute('aria-hidden', 'true');
         frame.setAttribute('loading', 'eager');
         frame.style.clipPath = hideClip;


         warmPromise = new Promise(resolve => {
         loading = new Promise(resolve => {
             let settled = false;
             let settled = false;
             let preparing = false;
             let preparing = false;
             let pollHandle = 0;
             let poll = null;
             function done(ok) {
             function finish(ok, reason) {
                 if (settled) return;
                 if (settled) return;
                 settled = true;
                 settled = true;
                 if (pollHandle) clearInterval(pollHandle);
                 clearInterval(poll);
                 loaded = ok;
                 ready = ok;
                 if (!ok) console.warn('[KuranokaPageTransition] Preview unavailable:', previewIssue || 'unknown');
                 if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason);
                 resolve(ok);
                 resolve(ok);
             }
             }
 
             async function inspect() {
            // A MediaWiki document can be usable LONG before iframe.onload:
            // onload waits for every huge illustration and third-party asset.
            // Poll for the actual article DOM instead.
             async function checkArticleReady() {
                 if (settled || preparing) return;
                 if (settled || preparing) return;
                let doc;
                 try {
                 try {
                     doc = iframe.contentDocument;
                     const doc = frame.contentDocument;
                     if (!doc || !doc.querySelector('#mw-content-text, .mw-parser-output')) return;
                     if (!doc || !doc.querySelector('#mw-content-text,.mw-parser-output')) return;
                     preparing = true;
                     preparing = true;
                     clearInterval(pollHandle);
                     clearInterval(poll);
                    // Allow background JavaScript / async MediaWiki styles to run.
                     await sleep(320); // Background.js / wiki style scripts
                     await pause(200);
                     await Promise.race([warmBackgrounds(doc), sleep(3600)]);
                    const fonts = doc.fonts && doc.fonts.ready;
                     await sleep(120);
                    if (fonts) await Promise.race([fonts.catch(() => {}), pause(1300)]);
                     await Promise.race([warmBackgrounds(doc), sleep(1300)]);
                     await Promise.race([warmBackgrounds(doc), pause(2500)]);
                     frame.contentWindow.scrollTo(0, 0);
                     await pause(170);
                     wireFrame();
                     await Promise.race([warmBackgrounds(doc), pause(1500)]);
                    finish(true);
                     iframe.contentWindow.scrollTo(0, 0);
                 } catch (err) { finish(false, String(err)); }
                     done(true);
                 } catch (err) {
                    previewIssue = String(err);
                    console.warn('[KuranokaPageTransition] Unable to pre-render the next page:', err);
                    done(false);
                }
             }
             }
 
             frame.addEventListener('load', () => {
             iframe.addEventListener('load', () => {
                 if (!settled && !preparing) inspect();
                 if (settled || preparing) return;
             });
                checkArticleReady();
             frame.addEventListener('error', () => finish(false,'iframe 网络加载错误'));
                // The load event fired, but it is not a MediaWiki article.
             document.body.append(frame);
                setTimeout(() => {
             poll = setInterval(inspect, 130);
                    if (settled || preparing) return;
             setTimeout(() => { if (!settled && !preparing) finish(false,'18 秒仍未找到目标正文'); }, 18000);
                    previewIssue = iframe.contentDocument
                        ? '目标 iframe 返回了非正文页面:' + iframe.contentDocument.URL
                        : '目标 iframe 被框架安全策略阻止或跨域跳转';
                    done(false);
                }, 800);
             }, { once: true });
             iframe.addEventListener('error', () => {
                previewIssue = '目标 iframe 网络加载失败';
                done(false);
            }, { once: true });
             document.body.appendChild(iframe);
             iframe.src = targetUrl;
            pollHandle = setInterval(checkArticleReady, 120);
            // Even if some resource never fires `load`, a parsed Wiki article
            // will now be detected by the periodic DOM check.
             setTimeout(() => {
                if (settled || preparing) return;
                previewIssue = '等待目标正文超过 18 秒(请检查网络或 iframe 限制)';
                done(false);
            }, 18000);
         });
         });


        // Warm the current page as well, in case an incoming navigation needs it.
         warmBackgrounds(document).catch(() => {});
         warmBackgrounds(document).catch(() => {});
         setTimeout(() => warmBackgrounds(document).catch(() => {}), 500);
         setTimeout(() => warmBackgrounds(document).catch(() => {}), 700);
         return warmPromise;
         return loading;
     }
     }


     function makeSprite(stops) {
    // This edge is used by BOTH the DOM clip and the shader.
         const canvas = document.createElement('canvas');
     function edgeAt(y, seconds, progress, width) {
         canvas.width = canvas.height = 96;
         const eased = smooth(progress);
         const c = canvas.getContext('2d');
        const toDark = window.__kkptVisualDark;
         const g = c.createRadialGradient(48, 48, 0, 48, 48, 48);
        const center = toDark
         stops.forEach(stop => g.addColorStop(stop[0], stop[1]));
            ? width + 110 - (width + 220) * eased
         c.fillStyle = g;
            : -110 + (width + 220) * eased;
        c.fillRect(0, 0, 96, 96);
        const wave = Math.sin(Math.PI * progress);
         return canvas;
         return center + wave * (
            28 * Math.sin(y * .012 + seconds * 3.1) +
            17 * Math.sin(y * .041 - seconds * 5.7) +
            7 * Math.sin(y * .10 + seconds * 10.0) +
            11 * Math.sin(y * .073 + seconds * 6.13 + 2 * Math.sin(y * .017 - seconds * 2.3))
        );
    }
 
    function clipFrame(seconds, progress) {
        const width = innerWidth, height = innerHeight;
        const pts = [];
         const count = Math.max(35, Math.ceil(height / 11));
         for (let i = 0; i <= count; i++) {
            const y = height * i / count;
            pts.push([edgeAt(y, seconds, progress, width), y]);
        }
        const supportsPath = CSS.supports('clip-path', 'path("M 0 0 L 8 0 L 8 8 Z")');
         if (supportsPath) {
            const startX = clipSide === 'right' ? width + 4 : -4;
            let d = `M ${startX} -4 L ${startX} ${height + 4} L ${pts[count][0].toFixed(1)} ${height}`;
            // Quadratic smoothing eliminates the geometric triangular teeth.
            for (let i = count - 1; i >= 0; i--) {
                const point = pts[i];
                const previous = pts[Math.max(0, i - 1)];
                const mx = (point[0] + previous[0]) * .5;
                const my = (point[1] + previous[1]) * .5;
                d += ` Q ${point[0].toFixed(1)} ${point[1].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
            }
            d += ` L ${pts[0][0].toFixed(1)} 0 Z`;
            frame.style.clipPath = `path("${d}")`;
         } else {
            const points = pts.slice().reverse().map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(',');
            frame.style.clipPath = clipSide === 'right'
                ? `polygon(100% 0,100% 100%,${points})`
                : `polygon(0 0,0 100%,${points})`;
         }
     }
     }


     const SPRITES = {
    // Tiny fullscreen quad + procedural fBm fire. No sprites or dependencies.
         fire: makeSprite([[0, '#ffffff'], [.11, '#fff6b3'], [.35, '#ffb71e'], [.68, '#f7330988'], [1, '#a3000000']]),
     const VERTEX = `
         spark: makeSprite([[0, '#ffffff'], [.16, '#fff3b8'], [.48, '#ff7900bb'], [1, '#ff230000']]),
         attribute vec2 aPosition;
        smoke: makeSprite([[0, '#170c0de0'], [.36, '#201b20a1'], [.72, '#302b3260'], [1, '#11111100']]),
         void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }
        ash: makeSprite([[0, '#d2b2a9'], [.28, '#897471bb'], [1, '#55555500']]),
     `;
        sky: makeSprite([[0, '#ffffff'], [.16, '#eeffff'], [.44, '#79d5ffbb'], [1, '#3c92ff00']])
     };


     function runAnimation() {
     const FRAGMENT = `
         return new Promise(resolve => {
         precision highp float;
            const canvas = document.createElement('canvas');
        uniform vec2 uSize;
            canvas.id = 'kkpt-canvas';
        uniform vec2 uBuffer;
            canvas.setAttribute('aria-hidden', 'true');
        uniform float uTime;
            document.body.append(canvas);
        uniform float uProgress;
            const ctx = canvas.getContext('2d', { alpha: true });
        uniform float uDark;
            if (!ctx) { canvas.remove(); resolve(); return; }


             let width = 0, height = 0;
        float random2(vec2 p) {
            function resize() {
             return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123);
                width = window.innerWidth;
        }
                height = window.innerHeight;
        float noise(vec2 p) {
                const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.6);
            vec2 i = floor(p), f = fract(p);
                canvas.width = Math.round(width * pixelRatio);
            vec2 u = f*f*(3.0-2.0*f);
                 canvas.height = Math.round(height * pixelRatio);
            return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x),
                 ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
                      mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y);
        }
        float fbm(vec2 p) {
            float result = 0.0, amp = 0.5;
            for (int i = 0; i < 5; i++) {
                 result += amp * noise(p);
                 p = mat2(1.6,-1.2,1.2,1.6) * p;
                amp *= 0.5;
             }
             }
             resize();
             return result;
        }
        float smoothProgress(float t) { return t*t*(3.0-2.0*t); }
        float edge(float y) {
            float w = uSize.x;
            float t = smoothProgress(uProgress);
            float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t;
            float wave = sin(3.14159265*uProgress);
            return c+wave*(28.0*sin(y*.012+uTime*3.1)+
                17.0*sin(y*.041-uTime*5.7)+7.0*sin(y*.10+uTime*10.0)+
                11.0*sin(y*.073+uTime*6.13+2.0*sin(y*.017-uTime*2.3)));
        }
        void main() {
            vec2 p = vec2(gl_FragCoord.x*uSize.x/uBuffer.x,
                          uSize.y-gl_FragCoord.y*uSize.y/uBuffer.y);
            float d = p.x-edge(p.y);
            float oldSide = uDark > .5 ? -d : d;
            float t = uTime;
            float impact = sin(3.14159265*uProgress);
            vec3 col = vec3(0.0);
            float alpha = 0.0;


             const particles = [];
             if (uDark > .5) {
            const shockwaves = [];
                // Deep smoke blankets a 350px belt BEHIND the incandescent
            const maxParticles = width < 700 ? 580 : 1100;
                // edge. Multiple warped noise scales break up smooth bands.
            const start = performance.now();
                vec2 sq = vec2(p.y*.007-t*.29,oldSide*.006+t*.12);
            let previous = start;
                vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)),
            let burstClock = 0;
                                fbm(sq*1.9+vec2(0.0,-t*.21)));
                float smokeCloud = fbm(sq*2.0+warp*2.2);
                float smokeDetail = fbm(sq*5.1-warp*.8);
                float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34);
                // A thick, drifting smoke WALL. The lighter old world
                // goes visibly dark before the red page consumes it.
                float soot = smoothstep(-110.0,-5.0,oldSide)*
                    (1.0-smoothstep(260.0,555.0,oldSide))*
                    (.55+.58*billow);
                vec3 sootColor = mix(vec3(.011,.012,.022),vec3(.18,.055,.041),smokeCloud);


            function addParticle(x, y) {
                // High-frequency fBm produces uneven, flame-like tongues,
                 if (particles.length >= maxParticles) return;
                // with a broad ember-red zone instead of a white rope.
                 const r = Math.random();
                vec2 q = vec2(p.y*.014-t*.60,oldSide*.015-t*.43);
                 const type = burn
                 float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25));
                    ? (r < .49 ? 'fire' : r < .70 ? 'spark' : r < .90 ? 'smoke' : 'ash')
                 float shredded = fbm(q*3.6+vec2(t*.24,-t*.34));
                     : (r < .68 ? 'sky' : r < .85 ? 'spark' : 'smoke');
                 float lengthOfTongue = 55.0+215.0*turbulence+65.0*shredded;
                 const isSmoke = type === 'smoke';
                float raggedEdge = 1.0-smoothstep(lengthOfTongue-62.0,lengthOfTongue+15.0,oldSide);
                 const isDust = type === 'ash';
                float flame = raggedEdge*smoothstep(-75.0,7.0,oldSide)*
                 const life = isSmoke ? rand(.9, 1.85) : isDust ? rand(.8, 2.3) : rand(.35, 1.05);
                     smoothstep(.21,.68,turbulence+.23*shredded);
                 particles.push({
                 float deepEmber = exp(-max(0.0,oldSide)/86.0);
                    type, x: x + rand(-24, 24), y: y + rand(-22, 22),
                float core = exp(-abs(d)/12.0);
                    vx: burn ? rand(-255, 96) : rand(-86, 210),
                float outer = exp(-abs(d)/78.0);
                    vy: isSmoke ? rand(-115, -25) : isDust ? rand(-95, 25) : rand(-280, -52),
                float whiteCore = core*max(.2,shredded);
                     size: isSmoke ? rand(38, 102) : isDust ? rand(3, 11) :
                 float heat = clamp(flame*.83+core*.69,0.0,1.0);
                        type === 'spark' ? rand(3, 10) : rand(14, 42),
                vec3 fireColor = mix(vec3(.30,.011,.009),vec3(1.0,.105,.016),smoothstep(.10,.57,heat));
                    life, remain: life, angle: rand(-Math.PI, Math.PI), spin: rand(-1.2, 1.2)
                 fireColor = mix(fireColor,vec3(1.0,.47,.048),smoothstep(.43,.85,heat));
                 });
                fireColor = mix(fireColor,vec3(1.0,.93,.63),smoothstep(.80,1.0,whiteCore));
                 col = mix(sootColor,fireColor,clamp(flame*.95+core+deepEmber*.20,0.0,1.0));
                alpha = clamp(soot*.93+flame*.92+outer*.32+core*.68,0.0,.99);
 
                // Sharp micron-scale cinders; the real ember trails live
                // on the separate high-resolution atmosphere canvas.
                vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0);
                vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5;
                float sparks = (1.0-smoothstep(.015,.12,length(uv)))*
                    step(.991,random2(cell))*exp(-abs(d)/240.0);
                col = mix(col,vec3(1.0,.78,.23),sparks);
                alpha = max(alpha,sparks*.97);
            } else {
                // Returning to blue is ELECTRIC AURORA / COLD MIST, NEVER FIRE.
                vec2 q = vec2(p.y*.009-t*.25,oldSide*.013+t*.18);
                float cloud = fbm(q+vec2(fbm(q*1.7),-t*.25));
                float finer = fbm(q*3.1+vec2(-t*.15,t*.19));
                float mist = exp(-abs(d)/208.0)*(cloud*.7+finer*.18+.23);
                float ion = exp(-abs(d)/37.0);
                float arcWobble = 25.0*sin(p.y*.043-t*6.0)+
                    10.0*sin(p.y*.14+t*13.0)+5.0*sin(p.y*.37-t*19.0);
                float arc = exp(-abs(d+arcWobble)/2.7)*
                    (.7+.3*sin(p.y*.065+t*7.0));
                float branches = exp(-abs(d+52.0*sin(p.y*.018+t*2.4))/1.9)*
                     smoothstep(.63,.92,noise(vec2(floor(p.y/54.0),floor(t*9.0))));
                col = mix(vec3(.008,.07,.19),vec3(.15,.52,.96),cloud+.27);
                col += vec3(.23,.45,.60)*ion;
                col = mix(col,vec3(.90,1.0,1.0),clamp(arc*.93+branches*.55,0.0,1.0));
                 alpha = clamp(mist*.85+ion*.43+arc*.97+branches*.65,0.0,.91);
             }
             }


             function paint(now) {
             // No long-lasting screen tint after the burn has finished.
                const dt = Math.min(.05, Math.max(.001, (now - previous) / 1000));
            alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) *
                 previous = now;
                 (1.0-smoothstep(.94,1.0,uProgress));
                const progress = Math.min(1, (now - start) / DURATION);
            gl_FragColor = vec4(col,alpha);
                const ease = smooth(progress);
        }
                const wobble = Math.pow(Math.sin(Math.PI * progress), .7);
    `;
                const direction = burn ? -1 : 1;
                const center = burn
                    ? width + 120 - (width + 260) * ease
                    : -120 + (width + 260) * ease;


                 function edge(y) {
    function createRenderer(canvas) {
                    return center + wobble * (
        const gl = canvas.getContext('webgl', {
                        32 * Math.sin(y * .012 + now * .0037) +
            alpha: true, antialias: false, depth: false, stencil: false,
                        21 * Math.sin(y * .035 - now * .0061) +
            preserveDrawingBuffer: false, premultipliedAlpha: false
                        12 * Math.sin(y * .079 + now * .0107) +
        });
                        7 * Math.sin(y * .156 - now * .014)
        if (!gl) return null;
                    );
        function compile(type, source) {
                }
            const shader = gl.createShader(type);
            gl.shaderSource(shader, source);
            gl.compileShader(shader);
            if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
                 console.error('[KKPT WebGL] Shader:', gl.getShaderInfoLog(shader));
                return null;
            }
            return shader;
        }
        const vs = compile(gl.VERTEX_SHADER, VERTEX);
        const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
        if (!vs || !fs) return null;
        const program = gl.createProgram();
        gl.attachShader(program, vs);
        gl.attachShader(program, fs);
        gl.linkProgram(program);
        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
            console.error('[KKPT WebGL] Program:', gl.getProgramInfoLog(program));
            return null;
        }
        gl.useProgram(program);
        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
        const pos = gl.getAttribLocation(program,'aPosition');
        gl.enableVertexAttribArray(pos);
        gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
        const sizeUniform = gl.getUniformLocation(program,'uSize');
        const bufferUniform = gl.getUniformLocation(program,'uBuffer');
        const timeUniform = gl.getUniformLocation(program,'uTime');
        const progressUniform = gl.getUniformLocation(program,'uProgress');
        const modeUniform = gl.getUniformLocation(program,'uDark');
        // This is a single fullscreen pass to a transparent canvas.
        // Blending here would multiply alpha twice and wash out the fire.
        gl.disable(gl.BLEND);
        return function draw(seconds,progress,dark) {
            const w=innerWidth,h=innerHeight;
            // Keep a stable GPU budget on high-DPI/mobile displays.
            const scale = w < 700 ? .68 : .84;
            const bw=Math.round(w*scale),bh=Math.round(h*scale);
            if (canvas.width!==bw || canvas.height!==bh) {
                canvas.width=bw;canvas.height=bh;gl.viewport(0,0,bw,bh);
            }
            gl.clearColor(0,0,0,0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.uniform2f(sizeUniform,w,h);
            gl.uniform2f(bufferUniform,bw,bh);
            gl.uniform1f(timeUniform,seconds);
            gl.uniform1f(progressUniform,progress);
            gl.uniform1f(modeUniform,dark?1:0);
            gl.drawArrays(gl.TRIANGLES,0,6);
        };
    }


                const pts = [];
    function createFallback(canvas) {
                const count = Math.max(35, Math.ceil(height / 17));
        const ctx = canvas.getContext('2d');
                for (let i = 0; i <= count; i++) {
        return function draw(seconds,progress,dark) {
                    const y = height * i / count;
            const w=innerWidth,h=innerHeight;
                    pts.push([edge(y), y]);
            if (canvas.width!==w || canvas.height!==h) {canvas.width=w;canvas.height=h;}
                 }
            ctx.clearRect(0,0,w,h);
                 const pathToEdge = pts.slice().reverse().map(p =>
            const x = edgeAt(h/2,seconds,progress,w);
                    p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'
            const gradient = ctx.createLinearGradient(x-115,0,x+115,0);
                 ).join(',');
            if (dark) {
                 iframe.style.clipPath = burn
                gradient.addColorStop(0,'#180d0d00');
                    ? 'polygon(100% 0,100% 100%,' + pathToEdge + ')'
                 gradient.addColorStop(.40,'#67180677');
                    : 'polygon(0 0,0 100%,' + pathToEdge + ')';
                 gradient.addColorStop(.50,'#ffe186cc');
                gradient.addColorStop(.62,'#ef3f0955');
                gradient.addColorStop(1,'#180d0d00');
            } else {
                gradient.addColorStop(0,'#5abcf600');
                gradient.addColorStop(.46,'#7cdbff77');
                 gradient.addColorStop(.5,'#ffffffbb');
                 gradient.addColorStop(.56,'#63c9ff77');
                gradient.addColorStop(1,'#5abcf600');
            }
            ctx.fillStyle=gradient;ctx.fillRect(x-115,0,230,h);
        };
    }


                ctx.clearRect(0, 0, width, height);
    // A second canvas adds real, textured soot / rolling vapor and violent
 
    // short-lived pressure waves. It is deliberately independent of WebGL,
                // Flare spreading over the whole screen makes this feel like
    // so even browsers with GPU disabled still get the smoke and impact.
                // the world's light changes, rather than a moving divider.
    function createAtmosphere(canvas) {
                const flash = .025 * (1 + Math.sin(now * .048)) +
        const ctx = canvas.getContext('2d', {alpha:true});
                    .07 * Math.pow(Math.max(0, Math.sin(now * .009)), 12);
        const rand = n => {
                ctx.fillStyle = burn
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
                    ? 'rgba(108,13,5,' + (flash * wobble).toFixed(3) + ')'
            return x-Math.floor(x);
                    : 'rgba(48,135,205,' + (flash * wobble).toFixed(3) + ')';
        };
                ctx.fillRect(0, 0, width, height);
        const clamp01 = n => Math.max(0,Math.min(1,n));
 
        function valueNoise(x,y,seed) {
                 function trace(offset) {
            const ix=Math.floor(x), iy=Math.floor(y);
                    ctx.beginPath();
            let fx=x-ix,fy=y-iy;
                    pts.forEach((p, i) => {
            fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
                        if (i === 0) ctx.moveTo(p[0] + offset, p[1]);
            const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
                        else ctx.lineTo(p[0] + offset, p[1]);
            const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
                     });
            const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
            return a*(1-fy)+b*fy;
        }
        function smokeTexture(seed,dark) {
            const tex=document.createElement('canvas');
            tex.width=tex.height=112;
            const g=tex.getContext('2d');
            const data=g.createImageData(112,112);
            for(let y=0;y<112;y++) for(let x=0;x<112;x++){
                const u=(x-56)/56,v=(y-56)/56;
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
                const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
                 const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
                const envelope=clamp01((1.05-radius)/.56);
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
                    envelope*envelope;
                const i=(y*112+x)*4;
                const variation=low*28+high*15;
                if(dark){
                    data.data[i]=18+variation;
                    data.data[i+1]=15+variation*.58;
                    data.data[i+2]=19+variation*.48;
                }else{
                    data.data[i]=44+variation;
                    data.data[i+1]=91+variation;
                     data.data[i+2]=125+variation;
                 }
                 }
 
                data.data[i+3]=Math.round(240*density);
                 // A broad charred-paper zone is consumed BEFORE the bright core.
            }
                 // Its gradient is on the remaining old page side of the flame.
            g.putImageData(data,0,0);
            return tex;
        }
        const sootSprites=[0,1,2,3].map(n=>smokeTexture(n+17,true));
        const mistSprites=[0,1,2,3].map(n=>smokeTexture(n+51,false));
        const particles=Array.from({length:innerWidth<700?72:124},(_,i)=>({
            seed:rand(i*4+71),height:rand(i*5+13),
            lifetime:.5+rand(i*8+37)*.7,
            phase:rand(i*9+21),sprite:i%4,
            scale:.72+rand(i*7+9)*1.25,
            bend:rand(i*15+23)*6.28
        }));
        const motes=Array.from({length:innerWidth<700?47:100},(_,i)=>({
            y:rand(i*3+17),phase:rand(i*9+43),
            speed:85+rand(i*12+5)*260,
            drift:75+rand(i*15+2)*170,
            length:3+rand(i*17+7)*11
        }));
        const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({
            angle:rand(i*17+131)*Math.PI*2,
            velocity:190+rand(i*11+51)*900,
            seed:rand(i*5+79),
            length:6+rand(i*7+17)*26
        }));
        function pressureWave(x,y,radius,power,dark,time) {
            if(power<=.005)return;
            const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
            if(dark){
                 core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
                core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
                core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')');
                core.addColorStop(1,'rgba(30,0,0,0)');
            }else{
                core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')');
                core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')');
                core.addColorStop(1,'rgba(0,29,100,0)');
            }
            ctx.globalCompositeOperation='screen';
            ctx.fillStyle=core;
            ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
            ctx.globalCompositeOperation='source-over';
        }
        return function drawAtmosphere(seconds,progress,dark,elapsed) {
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.67:.79;
            const bw=Math.max(1,Math.round(w*scale));
            const bh=Math.max(1,Math.round(h*scale));
            if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;}
            ctx.setTransform(scale,0,0,scale,0,0);
            ctx.clearRect(0,0,w,h);
            const envelope=Math.sin(Math.PI*progress);
            const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
            const sprites=dark?sootSprites:mistSprites;
            const direction=dark?-1:1;
            // Continuous textured smoke wall: not isolated oval puffs.
            // The base layer makes the plume visible on a pale blue sky.
            ctx.globalCompositeOperation='source-over';
            const bands=Math.max(10,Math.ceil(h/(dark?49:73)));
            for(let i=0;i<bands;i++){
                 const baseY=(i+.5)*h/bands;
                const y=baseY+24*Math.sin(seconds*2.5+i*.7);
                const x=edgeAt(y,seconds,progress,w)+direction*
                    (dark?118:72)+Math.sin(seconds*1.2+i*1.3)*36;
                const width=(dark?400:280)*(1+.19*Math.sin(i*2.9));
                const height=(dark?315:260);
                 ctx.save();
                 ctx.save();
                 const x0 = burn ? -133 : 133;
                 ctx.globalAlpha=(dark?.62:.36)*fade*(.65+.35*envelope);
                const x1 = burn ? 18 : -18;
                 ctx.translate(x,y);
                const dark = ctx.createLinearGradient(center + x0, 0, center + x1, 0);
                 ctx.rotate(.16*Math.sin(i*2.1+seconds*1.5));
                if (burn) {
                 ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height);
                    dark.addColorStop(0, '#130d0d00');
                    dark.addColorStop(.35, '#150c0eaa');
                    dark.addColorStop(.70, '#10080ce9');
                    dark.addColorStop(.91, '#48170df5');
                    dark.addColorStop(1, '#fa42156a');
                 } else {
                    dark.addColorStop(0, '#0b1f3100');
                    dark.addColorStop(.35, '#052744aa');
                    dark.addColorStop(.7, '#041c35e9');
                    dark.addColorStop(.91, '#215b8bf5');
                    dark.addColorStop(1, '#8ce8ff77');
                }
                 ctx.beginPath();
                pts.forEach((p, i) => {
                    const jitter = 10 * Math.sin(p[1] * .11 + now * .007);
                    const x = p[0] + x0 + jitter;
                    if (i === 0) ctx.moveTo(x, p[1]);
                    else ctx.lineTo(x, p[1]);
                });
                 pts.slice().reverse().forEach(p => ctx.lineTo(p[0] + x1, p[1]));
                ctx.closePath();
                ctx.fillStyle = dark;
                ctx.fill();
                 ctx.restore();
                 ctx.restore();
 
            }
                 // Smoke is below the light-emitting flames.
            // Independent turbulent billows peel away and rise.
            for(const item of particles){
                 const age=((elapsed/item.lifetime+item.phase)%1);
                const baseY=item.height*h;
                const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
                if(y< -230||y>h+230)continue;
                const frontier=edgeAt(baseY,seconds,progress,w);
                const x=frontier+direction*(age*(dark?225:135)+
                    30*Math.sin(age*6.3+item.bend));
                const size=(dark?175:145)*item.scale*(.68+age*1.9);
                const opacity=(dark?.85:.42)*Math.sin(Math.PI*age)*fade*
                    (.55+.45*envelope);
                if(opacity<=.005)continue;
                 ctx.save();
                 ctx.save();
                 for (let i = particles.length - 1; i >= 0; i--) {
                 ctx.globalAlpha=opacity;
                    const p = particles[i];
                ctx.translate(x,y);
                    p.remain -= dt;
                ctx.rotate((item.bend-3.14)*.14+age*.85*direction);
                    if (p.remain <= 0) { particles.splice(i, 1); continue; }
                ctx.scale(1.18,.84);
                    p.x += p.vx * dt;
                ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size);
                    p.y += p.vy * dt;
                    p.vx += (p.type === 'smoke' ? -4 : direction * 15) * dt;
                    p.vy -= 11 * dt;
                    p.angle += p.spin * dt;
                    if (p.type !== 'smoke' && p.type !== 'ash') continue;
                    const age = 1 - p.remain / p.life;
                    const size = p.size * (1 + age * (p.type === 'smoke' ? 1.7 : .6));
                    ctx.globalAlpha = Math.min(1, p.remain * 2) *
                        (p.type === 'smoke' ? .52 : .35);
                    ctx.drawImage(SPRITES[p.type], p.x - size, p.y - size, 2 * size, 2 * size);
                }
                 ctx.restore();
                 ctx.restore();
 
            }
                // Broken filament cracks penetrating the unburnt page.
            // The impact happens at ignition and again as the rupture reaches
                // They flicker independently instead of making a tidy wall.
            // the middle of the screen; pulses are intentionally fleeting.
                if (burn) {
            for(const moment of (dark?[.10,.48]:[.18,.58])){
                    ctx.save();
                const age=(progress-moment)/.125;
                    for (let y = 38; y < height; y += 103) {
                if(age<0||age>1)continue;
                        const a = edge(y), skew = Math.sin(y * .13 + now * .0017);
                const intensity=(1-age)*(1-age)*fade;
                        ctx.lineWidth = 1.6;
                // A single-frame-ish exposure bloom punches through
                        ctx.strokeStyle = 'rgba(38,6,4,.8)';
                // the entire page; the tone is red or icy blue by direction.
                        ctx.beginPath();
                ctx.globalCompositeOperation='screen';
                        ctx.moveTo(a - 8, y);
                ctx.fillStyle=dark
                        ctx.lineTo(a - 35, y - 13 * skew);
                    ? 'rgba(255,81,26,'+(.095*intensity)+')'
                        ctx.lineTo(a - 59, y + 12);
                    : 'rgba(150,219,255,'+(.080*intensity)+')';
                        ctx.lineTo(a - 93 - 20 * skew, y - 19);
                ctx.fillRect(0,0,w,h);
                        ctx.lineTo(a - 127, y - 6);
                ctx.globalCompositeOperation='source-over';
                        ctx.stroke();
                const x=edgeAt(h*.51,seconds,progress,w);
                        ctx.lineWidth = .7;
                const y=h*.51;
                        ctx.strokeStyle = 'rgba(255,99,23,.66)';
                const radius=95+age*Math.max(w,h)*.9;
                        ctx.stroke();
                pressureWave(x,y,radius,intensity,dark,seconds);
                        ctx.beginPath();
                ctx.globalAlpha=(dark?.20:.28)*intensity;
                        ctx.moveTo(a - 61, y + 12);
                ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
                         ctx.lineTo(a - 77, y + 31);
                ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
                        ctx.lineTo(a - 91, y + 40);
                ctx.beginPath();
                        ctx.stroke();
                for(let i=0;i<=110;i++){
                    }
                    const a=i/110*Math.PI*2;
                    ctx.restore();
                    const jag=1+.03*Math.sin(a*29+seconds*12)+
                         .05*Math.sin(a*13-seconds*5);
                    const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag;
                    if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry);
                 }
                 }
                // Blooming outer corona, molten core, then white-hot ignition.
                ctx.save();
                ctx.lineJoin = 'round';
                ctx.lineCap = 'round';
                ctx.shadowColor = burn ? '#fb2509' : '#86e6ff';
                ctx.shadowBlur = burn ? 79 : 88;
                trace(0);
                ctx.strokeStyle = burn ? 'rgba(242,35,6,.6)' : 'rgba(74,168,255,.65)';
                ctx.lineWidth = burn ? 65 : 73;
                 ctx.stroke();
                 ctx.stroke();
                 ctx.shadowBlur = 39;
                 ctx.globalAlpha=1;
                 ctx.strokeStyle = burn ? 'rgba(255,110,10,.88)' : 'rgba(130,223,255,.91)';
                 // Fragment trails explode outward from the rupture point.
                 ctx.lineWidth = burn ? 29 : 35;
                // They follow independent directions and speeds; no circles.
                ctx.globalCompositeOperation='screen';
                for(const bit of shockDebris){
                    const f=age*(.5+bit.seed*.5);
                    const travel=bit.velocity*f;
                    const px=x+Math.cos(bit.angle)*travel;
                    const py=y+Math.sin(bit.angle)*travel;
                    const vx=Math.cos(bit.angle)*bit.length*(1-age);
                    const vy=Math.sin(bit.angle)*bit.length*(1-age);
                    ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7);
                    ctx.strokeStyle=dark?'#ffdd9b':'#e0faff';
                    ctx.lineWidth=.6+bit.seed*1.3;
                    ctx.beginPath();
                    ctx.moveTo(px,py);
                    ctx.lineTo(px-vx,py-vy);
                    ctx.stroke();
                }
                ctx.globalCompositeOperation='source-over';
                ctx.globalAlpha=1;
            }
            // Sharp, directional embers / crystalline fragments, not blobs.
            ctx.save();
            ctx.globalCompositeOperation='screen';
            for(const mote of motes){
                const age=(elapsed*(dark?.74:.94)+mote.phase)%1;
                const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
                const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
                if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
                const opacity=(1-age)*fade*(dark?.89:.61);
                ctx.globalAlpha=opacity;
                ctx.strokeStyle=dark?'#ffb45d':'#b2efff';
                 ctx.lineWidth=dark?.8:1.15;
                ctx.beginPath();
                ctx.moveTo(x,yy);
                ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
                 ctx.stroke();
                 ctx.stroke();
                ctx.shadowBlur = 18;
            }
                ctx.strokeStyle = burn ? '#ffe564' : '#e5fbff';
            ctx.restore();
                ctx.lineWidth = 9;
        };
                ctx.stroke();
    }
                ctx.shadowBlur = 9;
                ctx.strokeStyle = '#fffdf0';
                ctx.lineWidth = 3;
                ctx.stroke();
                ctx.restore();


                // Aggressive curling tongues. Flame tips tear leftwards into
    function setDisplayed(nextActive) {
                // old Kuranoka; on the way back, electric shards tear right.
        active = nextActive;
                ctx.save();
        frame.classList.toggle('kkpt-active', active);
                ctx.globalCompositeOperation = 'lighter';
        frame.style.clipPath = active ? 'inset(0)' : hideClip;
                for (let y = -60; y < height + 60; y += burn ? 17 : 26) {
        frame.tabIndex = active ? 0 : -1;
                    const x = edge(y);
        frame.setAttribute('aria-hidden', active ? 'false' : 'true');
                    const flicker = Math.sin(now * .015 + y * .093);
        document.title = active ? otherTitle : baseTitle;
                    const lift = (burn ? 55 : 68) +
        updateButton();
                        (burn ? 65 : 43) * Math.abs(Math.sin(y * .043 - now * .009)) +
    }
                        18 * (flicker + 1);
                    const lean = (burn ? -1 : 1) * lift;


                    ctx.fillStyle = burn ? 'rgba(255,53,8,.44)' : 'rgba(31,138,255,.33)';
    async function animateSwap(nextActive) {
                    ctx.beginPath();
        if (animating) return;
                    ctx.moveTo(x - lean * .05, y + 19);
        animating = true;
                    ctx.bezierCurveTo(x + lean * .14, y - 18,
        button.classList.add('kkpt-busy');
                        x + lean * .88, y - lift * .47, x + lean, y - lift);
                    ctx.bezierCurveTo(x + lean * .35, y - lift * .53,
                        x - lean * .13, y + 13, x + 16, y + 27);
                    ctx.closePath();
                    ctx.fill();


                    ctx.fillStyle = burn ? 'rgba(255,231,89,.47)' : 'rgba(222,250,255,.52)';
        // Warm-up should be done already; prepare if the user clicked early.
                    ctx.beginPath();
        labelSub.textContent = 'PREPARING THE OTHER SIDE...';
                    ctx.moveTo(x + 4, y + 11);
        if (!loading) warmFrame();
                    ctx.quadraticCurveTo(x + lean * .32, y - lift * .31,
        const canUseFrame = await Promise.race([loading,sleep(11000).then(() => false)]);
                        x + lean * .70, y - lift * .67);
        if (!canUseFrame || !ready) {
                    ctx.quadraticCurveTo(x - 6, y - lift * .21, x + 7, y + 11);
            // No mock background: fall back to the genuine Wiki page.
                    ctx.closePath();
            window.location.assign(nextActive ? otherUrl.href : baseUrl);
                    ctx.fill();
            return;
                }
        }
                ctx.restore();


                // Impacts regularly burst along the travelling front.
        const destinationDark = isDark(nextActive ? otherPage : basePage);
                burstClock += dt;
        window.__kkptVisualDark = destinationDark;
                if (burstClock > (burn ? .18 : .26) && progress > .05 && progress < .95) {
        button.classList.remove('kkpt-quiet');
                    burstClock = 0;
        labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING';
                    const y = rand(0, height);
                    shockwaves.push({x: edge(y), y, age: 0, max: rand(52, 115)});
                    for (let j = 0; j < (burn ? 18 : 12); j++) addParticle(edge(y), y);
                }
                for (let i = shockwaves.length - 1; i >= 0; i--) {
                    const s = shockwaves[i];
                    s.age += dt;
                    if (s.age > .52) { shockwaves.splice(i, 1); continue; }
                    const t = s.age / .52;
                    ctx.save();
                    ctx.globalCompositeOperation = 'lighter';
                    ctx.globalAlpha = (1 - t) * .67;
                    ctx.strokeStyle = burn ? '#ffce75' : '#d3ffff';
                    ctx.lineWidth = 4 * (1 - t) + .3;
                    ctx.shadowBlur = 32;
                    ctx.shadowColor = burn ? '#ff3f05' : '#72d5ff';
                    ctx.beginPath();
                    ctx.ellipse(s.x, s.y, s.max * t, s.max * t * .7, 0, 0, Math.PI * 2);
                    ctx.stroke();
                    ctx.restore();
                }


                const density = width < 700 ? 13 : 29;
        // Freeze frame interaction while its silhouette is animated.
                for (let i = 0; i < density; i++) {
        frame.classList.remove('kkpt-active');
                    const y = rand(0, height);
        frame.style.pointerEvents = 'none';
                    addParticle(edge(y), y);
        frame.style.clipPath = active ? 'inset(0)' : hideClip;
                }


                ctx.save();
        const canvas = document.createElement('canvas');
                ctx.globalCompositeOperation = 'lighter';
        canvas.id = 'kkpt-vfx';
                particles.forEach(p => {
        document.body.append(canvas);
                    if (p.type === 'smoke' || p.type === 'ash') return;
        const atmosphere = document.createElement('canvas');
                    const age = 1 - p.remain / p.life;
        atmosphere.id = 'kkpt-atmosphere';
                    const fade = Math.min(1, p.remain * 3) * (1 - age * .65);
        document.body.append(atmosphere);
                    ctx.globalAlpha = fade * (p.type === 'spark' ? .97 : .72);
        const drawAtmosphere = createAtmosphere(atmosphere);
                    ctx.translate(p.x, p.y);
        const blast = document.createElement('div');
                    ctx.rotate(p.angle);
        blast.id = 'kkpt-impact';
                    const xsize = p.size * (p.type === 'fire' ? .8 : 1);
        blast.className = destinationDark ? 'kkpt-impact-red' : 'kkpt-impact-blue';
                    const ysize = p.size * (p.type === 'fire' ? 1.7 : 1);
        blast.setAttribute('aria-hidden','true');
                    ctx.drawImage(SPRITES[p.type], -xsize, -ysize, xsize * 2, ysize * 2);
        document.body.append(blast);
                    ctx.rotate(-p.angle);
        let draw = createRenderer(canvas);
                    ctx.translate(-p.x, -p.y);
        if (!draw) {
                });
            canvas.remove();
                ctx.restore();
            const fallbackCanvas = document.createElement('canvas');
            fallbackCanvas.id = 'kkpt-vfx';
            document.body.append(fallbackCanvas);
            draw = createFallback(fallbackCanvas);
        }
        const fxCanvas = document.getElementById('kkpt-vfx');
        const duration = destinationDark ? 3650 : 2950;
        const started = performance.now();


                 if (progress < 1) {
        await new Promise(resolve => {
                    requestAnimationFrame(paint);
            function tick(now) {
                 } else {
                const seconds = now / 1000;
                    // The iframe is fully visible BEFORE the old document
                 const t = Math.min(1,(now-started)/duration);
                    // navigates. No handoff cover, no blue/red solid flash.
                clipFrame(seconds,t);
                    iframe.style.clipPath = 'inset(0)';
                drawAtmosphere(seconds,t,destinationDark,(now-started)/1000);
                    canvas.style.opacity = '0';
                draw(seconds,t,destinationDark);
                    resolve();
                 const first=destinationDark?.10:.18;
                 }
                const second=destinationDark?.48:.58;
                const shockA=Math.exp(-Math.pow((t-first)/.041,2));
                const shockB=Math.exp(-Math.pow((t-second)/.056,2));
                const shock=Math.min(1,shockA*.98+shockB*.80);
                blast.style.opacity=String(shock);
                blast.style.setProperty('--kkpt-impact-x',
                    edgeAt(innerHeight*.51,seconds,t,innerWidth).toFixed(1)+'px');
                frame.style.filter=shock>.01
                    ? 'brightness('+(1+.48*shock).toFixed(3)+') saturate('+
                        (1+.25*shock).toFixed(3)+')'
                    : '';
                if (t < 1) requestAnimationFrame(tick);
                 else resolve();
             }
             }
             requestAnimationFrame(paint);
             requestAnimationFrame(tick);
         });
         });
        setDisplayed(nextActive);
        fxCanvas.style.opacity = '0';
        atmosphere.style.opacity = '0';
        blast.style.opacity = '0';
        await sleep(190);
        fxCanvas.remove();
        atmosphere.remove();
        blast.remove();
        frame.style.filter = '';
        frame.style.pointerEvents = '';
        animating = false;
        button.classList.remove('kkpt-busy','kkpt-quiet');
        // No location.assign here: the rendered target stays alive.
        history.pushState({ kkptV6: true, active }, '', active ? otherUrl.href : baseUrl);
     }
     }


     async function begin() {
     function requestSwap(nextActive) {
         if (!warmPromise) createPreviewEarly();
         if (animating || nextActive === active) return;
         button.classList.add('kkpt-loading');
         if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
        button.querySelector('.kkpt-sub').textContent = 'PREPARING THE OTHER SIDE...';
             setDisplayed(nextActive);
 
             history.pushState({kkptV6:true,active},'',active?otherUrl.href:baseUrl);
        // Usually instantaneous: the iframe and both wallpapers began loading
        // when the page itself was opened, not when this button was clicked.
        const ready = await Promise.race([warmPromise, pause(12000).then(() => {
            previewIssue = '目标页面加载超过 12 秒';
            return false;
        })]);
        if (!ready || !loaded) {
             button.classList.remove('kkpt-loading');
             button.querySelector('.kkpt-sub').textContent = '预览未就绪 · 点击普通跳转';
            // Failure path is a normal link, not a fake blue page reveal.
            button.dataset.kkptFallback = '1';
            busy = false;
             return;
             return;
         }
         }
 
         animateSwap(nextActive).catch(err => {
         button.classList.remove('kkpt-loading');
            console.error('[KuranokaPageTransition] V6:',err);
        button.classList.add('kkpt-running');
            document.getElementById('kkpt-vfx')?.remove();
        button.querySelector('.kkpt-sub').textContent = burn ? 'THE WORLD IS BURNING' : 'BREAK THROUGH THE DARK';
            document.getElementById('kkpt-atmosphere')?.remove();
        await runAnimation();
            document.getElementById('kkpt-impact')?.remove();
 
            if (frame) frame.style.filter = '';
        // Let the compositor present the REAL destination iframe for a few
            window.location.assign(nextActive ? otherUrl.href : baseUrl);
        // frames. On navigation, native cross-document View Transition takes
        });
        // its old snapshot from this already-revealed destination.
        button.classList.add('kkpt-vanish');
        await frame();
        await frame();
        await pause(90);
        window.location.assign(targetUrl);
     }
     }


     function makeButton() {
     function createButton() {
         if (!document.body || document.getElementById('kkpt-toggle')) return;
         if (!document.body || document.getElementById('kkpt-toggle')) return;
         button = document.createElement('a');
         button=document.createElement('a');
         button.id = 'kkpt-toggle';
         button.id='kkpt-toggle';
         button.href = targetUrl;
         labelIcon=document.createElement('span');
        button.setAttribute('aria-label', burn ? '燃尽当前世界,进入 Kuronata' : '破晓,返回 Kuranoka');
         labelIcon.className='kkpt-icon';
 
         labelIcon.setAttribute('aria-hidden','true');
        const icon = document.createElement('span');
         const text=document.createElement('span');
         icon.className = 'kkpt-icon';
         text.className='kkpt-text';
         icon.setAttribute('aria-hidden', 'true');
         labelTitle=document.createElement('span');
        icon.textContent = burn ? '✦' : '☀';
         labelSub=document.createElement('span');
         const label = document.createElement('span');
         labelSub.className='kkpt-sub';
         label.className = 'kkpt-text';
         text.append(labelTitle,labelSub);
         const title = document.createElement('span');
         button.append(labelIcon,text);
         title.textContent = target.toUpperCase();
        const sub = document.createElement('span');
         sub.className = 'kkpt-sub';
         sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        label.append(title, sub);
         button.append(icon, label);
         document.body.append(button);
         document.body.append(button);
 
        button.hidden=!SHOW_FLOATING_BUTTON;
         // Preload starts right now, not on click.
         updateButton();
         createPreviewEarly();
        watchPortalLinks(document, otherPage);
         warmFrame();


         button.addEventListener('click', event => {
         button.addEventListener('click', event => {
             if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
             if (!isPlainClick(event)) return;
                event.metaKey || event.shiftKey || event.altKey) return;
            if (button.dataset.kkptFallback === '1') return; // normal navigation
             event.preventDefault();
             event.preventDefault();
             if (busy) return;
             requestSwap(!active);
            busy = true;
 
            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
                window.location.assign(targetUrl);
                return;
            }
            begin().catch(err => {
                console.error('[KuranokaPageTransition] V3 error:', err);
                // Unexpected errors go to the real page, never a blank cover.
                window.location.assign(targetUrl);
            });
         });
         });
     }
     }
    // A plain [[Kuronata|your mysterious text]] now triggers everything.
    // The inverse [[Kuranoka|...]] works inside the already-loaded iframe.
    document.addEventListener('click', event => {
        if (active) return;
        portalClick(event, document, otherPage, true);
    }, true);
    mw.hook('wikipage.content').add(() => {
        if (document.readyState !== 'loading') watchPortalLinks(document, otherPage);
    });
    // Back and Forward restore the two already-loaded documents instantly.
    window.addEventListener('popstate', () => {
        if (!frame || !ready || animating) return;
        const route = pathPage();
        if (route === basePage || route === otherPage) {
            setDisplayed(route === otherPage);
        }
    });
    window.addEventListener('pageshow', event => {
        if (!event.persisted || !frame || !ready) return;
        animating=false;
        document.getElementById('kkpt-vfx')?.remove();
        document.getElementById('kkpt-atmosphere')?.remove();
        document.getElementById('kkpt-impact')?.remove();
        frame.style.filter = '';
        setDisplayed(pathPage() === otherPage);
        button?.classList.remove('kkpt-busy','kkpt-quiet');
    });


     if (document.readyState === 'loading') {
     if (document.readyState === 'loading') {
         document.addEventListener('DOMContentLoaded', makeButton, { once: true });
         document.addEventListener('DOMContentLoaded',createButton,{once:true});
     } else {
     } else {
         makeButton();
         createButton();
     }
     }
    // Back/forward cache can revive a page with the iframe still fully open.
    // Restore the original scene when users return with the Back button.
    window.addEventListener('pageshow', event => {
        if (!event.persisted) return;
        document.getElementById('kkpt-canvas')?.remove();
        if (iframe) iframe.style.clipPath = startClip;
        if (button) {
            button.classList.remove('kkpt-vanish', 'kkpt-running', 'kkpt-loading');
            const subtitle = button.querySelector('.kkpt-sub');
            if (subtitle) subtitle.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        }
        busy = false;
    });
}());
}());

2026年10月23日 (五) 13:34的最新版本

/* ====================================================================
   KURANOKA / KURONATA V6 — CATACLYSM / DAWN
   MediaWiki:KuranokaPageTransition.js
   Same-origin iframe + history.pushState: NO full reload on toggles.
   Irregular tear + WebGL combustion + layered turbulent smoke/impact.
   Wiki easter eggs: [[Kuronata|...]] / [[Kuranoka|...]].
   Backgrounds are prewarmed on page load. Zero external dependencies.
   ==================================================================== */
(function () {
    'use strict';

    // The preview is a complete MediaWiki page. It must NOT spawn another.
    if (window !== window.top || window.__kkptV6) return;
    if (!window.mw || !mw.config) return;

    // The URL is the source of truth if a skin/extension restored a page
    // without immediately updating mw.config (soft navigation / bfcache).
    const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/);
    const urlPage = pathMatch ? pathMatch[1] : new URLSearchParams(location.search).get('title');
    const basePage = ['Kuranoka', 'Kuronata'].includes(urlPage)
        ? urlPage : mw.config.get('wgPageName');
    const action = new URLSearchParams(location.search).get('action') || 'view';
    if (!['Kuranoka', 'Kuronata'].includes(basePage) || action !== 'view') return;

    window.__kkptV6 = true;
    window.__kkptVersion = '6.0';

    // For the easter egg: links inside the Wiki are the default trigger.
    // Change to true only if you also want the fixed switch button.
    const SHOW_FLOATING_BUTTON = false;

    const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka';
    const baseUrl = location.href;
    const baseTitle = document.title;
    const otherUrl = new URL('/wiki/' + encodeURIComponent(otherPage), location.href);
    const clipSide = basePage === 'Kuranoka' ? 'right' : 'left';
    const hideClip = clipSide === 'right' ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const smooth = t => t * t * (3 - 2 * t);
    const imagePool = new Map();

    let active = false; // false: original top-level doc; true: otherPage iframe
    let animating = false;
    let ready = false;
    let loading = null;
    let button, labelTitle, labelSub, labelIcon, frame;
    let otherTitle = otherPage + ' - Blockland Wiki';

    function whichPage() { return active ? otherPage : basePage; }
    function destination() { return active ? basePage : otherPage; }
    function isDark(page) { return page === 'Kuronata'; }
    function pathPage() {
        const url = new URL(location.href);
        if (url.pathname.startsWith('/wiki/')) {
            try { return decodeURIComponent(url.pathname.slice(6)).replace(/_/g, ' '); }
            catch (e) { return ''; }
        }
        return url.searchParams.get('title') || '';
    }

    function updateButton() {
        if (!button) return;
        const to = destination();
        button.href = to === basePage ? baseUrl : otherUrl.href;
        labelTitle.textContent = to.toUpperCase();
        labelSub.textContent = isDark(to) ? 'BURN THE SKY' : 'RECLAIM THE SKY';
        labelIcon.textContent = isDark(to) ? '✦' : '❄';
        button.classList.toggle('kkpt-dark', isDark(whichPage()));
        button.setAttribute('aria-label', isDark(to) ? '燃尽天空,进入 Kuronata' : '驱散黑暗,回到 Kuranoka');
    }

    // Accept the two standard MediaWiki link forms, while rejecting
    // edit/action links, external sites and similarly named articles.
    function wikiLinkPage(anchor, doc) {
        try {
            const url = new URL(anchor.href, doc.baseURI);
            if (url.origin !== location.origin) return null;
            const action = url.searchParams.get('action');
            if (action && action !== 'view') return null;
            let title = url.searchParams.get('title');
            if (!title) {
                const matched = url.pathname.match(/^\/wiki\/([^/?#]+)\/?$/);
                if (matched) title = decodeURIComponent(matched[1]);
            }
            title = (title || '').replace(/_/g, ' ');
            return title === 'Kuranoka' || title === 'Kuronata' ? title : null;
        } catch (_) {
            return null;
        }
    }

    function decoratePortalLinks(doc, targetPage) {
        // Use only the article: sidebar, edit tools and site-wide links
        // must retain their usual navigation behavior.
        const content = doc.querySelector('#mw-content-text,.mw-parser-output');
        if (!content) return;
        content.querySelectorAll('a[href]').forEach(anchor => {
            if (wikiLinkPage(anchor, doc) !== targetPage) return;
            anchor.classList.add('kkpt-portal-link');
            anchor.classList.toggle('kkpt-portal-red', targetPage === 'Kuronata');
            anchor.classList.toggle('kkpt-portal-blue', targetPage === 'Kuranoka');
            const caption = (anchor.textContent || '').trim();
            if (caption) anchor.dataset.kkptText = caption.slice(0, 120);
            anchor.setAttribute('aria-label', caption + ',切换至 ' + targetPage);
        });
    }

    function watchPortalLinks(doc, targetPage) {
        decoratePortalLinks(doc, targetPage);
        if (doc.__kkptPortalObserver) return;
        const root = doc.querySelector('#mw-content-text,.mw-parser-output');
        if (!root) return;
        const observer = new MutationObserver(() => decoratePortalLinks(doc, targetPage));
        observer.observe(root, {childList:true,subtree:true});
        doc.__kkptPortalObserver = observer;
    }

    function isPlainClick(event) {
        return !event.defaultPrevented && event.button === 0 &&
            !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
    }

    function findAnchor(event, doc) {
        const target = event.target;
        return target && target.nodeType === 1 &&
            typeof target.closest === 'function'
            ? target.closest('a[href]') : null;
    }

    function portalClick(event, doc, targetPage, nextActive) {
        if (!isPlainClick(event)) return false;
        const anchor = findAnchor(event, doc);
        if (!anchor || (anchor.target && anchor.target !== '_self') ||
            anchor.hasAttribute('download') ||
            wikiLinkPage(anchor, doc) !== targetPage) return false;
        event.preventDefault();
        requestSwap(nextActive);
        return true;
    }

    // Read backgrounds after background-template code populates --bg-image.
    function backgroundUrls(doc) {
        const urls = new Set();
        const selector = [doc.documentElement, doc.body];
        try {
            doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container')
                .forEach((el, i) => { if (i < 65) selector.push(el); });
        } catch (e) { /* use html/body */ }
        function add(value) {
            if (!value) return;
            const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
            let match;
            while ((match = pattern.exec(value))) {
                try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
            }
            if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) {
                try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
            }
        }
        selector.forEach(el => {
            if (!el) return;
            try {
                [null, '::before', '::after'].forEach(pseudo => {
                    const style = doc.defaultView.getComputedStyle(el, pseudo);
                    if (!style) return;
                    add(style.backgroundImage);
                    add(style.getPropertyValue('--bg-image'));
                    add(style.getPropertyValue('--background-image'));
                });
                if (el.dataset) add(el.dataset.bgImage);
            } catch (e) { /* style not yet ready */ }
        });
        return [...urls];
    }

    function warmImage(url) {
        if (imagePool.has(url)) return imagePool.get(url).promise;
        const image = new Image();
        const promise = new Promise(resolve => {
            let finished = false;
            const timeout = setTimeout(() => done(false), 5000);
            function done(ok) {
                if (finished) return;
                finished = true;
                clearTimeout(timeout);
                resolve(ok);
            }
            image.onload = () => done(true);
            image.onerror = () => done(false);
            image.src = url;
            if (image.complete) done(image.naturalWidth > 0);
        }).then(ok => ok && image.decode
            ? Promise.race([image.decode().then(() => true).catch(() => true),sleep(900).then(() => true)])
            : ok);
        imagePool.set(url, { image, promise });
        return promise;
    }

    async function warmBackgrounds(doc) {
        const urls = backgroundUrls(doc);
        await Promise.allSettled(urls.map(warmImage));
    }

    function wireFrame() {
        try {
            const doc = frame.contentDocument;
            if (!doc) return;
            otherTitle = doc.title || otherTitle;
            watchPortalLinks(doc, basePage);
            if (doc.__kkptFrameBridge) return;
            doc.__kkptFrameBridge = true;

            // Non-anchor navigation should leave the iframe and open normally
            // in the top window, so the browser's URL doesn't become stale.
            doc.addEventListener('click', event => {
                if (!active || !isPlainClick(event)) return;
                if (portalClick(event, doc, basePage, false)) return;
                const el = findAnchor(event, doc);
                if (!el || (el.target && el.target !== '_self')) return;
                const href = el.href;
                if (!href || href.startsWith('javascript:')) return;
                const next = new URL(href, doc.baseURI);
                // Same-document fragments stay inside the iframe.
                const currentFrame = new URL(frame.contentWindow.location.href);
                if (next.origin === currentFrame.origin &&
                    next.pathname === currentFrame.pathname &&
                    next.search === currentFrame.search && next.hash) {
                    history.replaceState(history.state, '', next.href);
                    return;
                }
                event.preventDefault();
                window.location.assign(next.href);
            }, true);

            doc.addEventListener('submit', event => {
                if (active && event.target instanceof doc.defaultView.HTMLFormElement) {
                    event.target.target = '_top';
                }
            }, true);
        } catch (err) {
            console.warn('[KuranokaPageTransition] Frame link bridge unavailable:', err);
        }
    }

    function warmFrame() {
        if (loading) return loading;
        frame = document.createElement('iframe');
        frame.id = 'kkpt-preview';
        frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop
        frame.title = otherPage + ' 预载页面';
        frame.tabIndex = -1;
        frame.setAttribute('aria-hidden', 'true');
        frame.setAttribute('loading', 'eager');
        frame.style.clipPath = hideClip;

        loading = new Promise(resolve => {
            let settled = false;
            let preparing = false;
            let poll = null;
            function finish(ok, reason) {
                if (settled) return;
                settled = true;
                clearInterval(poll);
                ready = ok;
                if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason);
                resolve(ok);
            }
            async function inspect() {
                if (settled || preparing) return;
                try {
                    const doc = frame.contentDocument;
                    if (!doc || !doc.querySelector('#mw-content-text,.mw-parser-output')) return;
                    preparing = true;
                    clearInterval(poll);
                    await sleep(320); // Background.js / wiki style scripts
                    await Promise.race([warmBackgrounds(doc), sleep(3600)]);
                    await sleep(120);
                    await Promise.race([warmBackgrounds(doc), sleep(1300)]);
                    frame.contentWindow.scrollTo(0, 0);
                    wireFrame();
                    finish(true);
                } catch (err) { finish(false, String(err)); }
            }
            frame.addEventListener('load', () => {
                if (!settled && !preparing) inspect();
            });
            frame.addEventListener('error', () => finish(false,'iframe 网络加载错误'));
            document.body.append(frame);
            poll = setInterval(inspect, 130);
            setTimeout(() => { if (!settled && !preparing) finish(false,'18 秒仍未找到目标正文'); }, 18000);
        });

        warmBackgrounds(document).catch(() => {});
        setTimeout(() => warmBackgrounds(document).catch(() => {}), 700);
        return loading;
    }

    // This edge is used by BOTH the DOM clip and the shader.
    function edgeAt(y, seconds, progress, width) {
        const eased = smooth(progress);
        const toDark = window.__kkptVisualDark;
        const center = toDark
            ? width + 110 - (width + 220) * eased
            : -110 + (width + 220) * eased;
        const wave = Math.sin(Math.PI * progress);
        return center + wave * (
            28 * Math.sin(y * .012 + seconds * 3.1) +
            17 * Math.sin(y * .041 - seconds * 5.7) +
             7 * Math.sin(y * .10 + seconds * 10.0) +
            11 * Math.sin(y * .073 + seconds * 6.13 + 2 * Math.sin(y * .017 - seconds * 2.3))
        );
    }

    function clipFrame(seconds, progress) {
        const width = innerWidth, height = innerHeight;
        const pts = [];
        const count = Math.max(35, Math.ceil(height / 11));
        for (let i = 0; i <= count; i++) {
            const y = height * i / count;
            pts.push([edgeAt(y, seconds, progress, width), y]);
        }
        const supportsPath = CSS.supports('clip-path', 'path("M 0 0 L 8 0 L 8 8 Z")');
        if (supportsPath) {
            const startX = clipSide === 'right' ? width + 4 : -4;
            let d = `M ${startX} -4 L ${startX} ${height + 4} L ${pts[count][0].toFixed(1)} ${height}`;
            // Quadratic smoothing eliminates the geometric triangular teeth.
            for (let i = count - 1; i >= 0; i--) {
                const point = pts[i];
                const previous = pts[Math.max(0, i - 1)];
                const mx = (point[0] + previous[0]) * .5;
                const my = (point[1] + previous[1]) * .5;
                d += ` Q ${point[0].toFixed(1)} ${point[1].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
            }
            d += ` L ${pts[0][0].toFixed(1)} 0 Z`;
            frame.style.clipPath = `path("${d}")`;
        } else {
            const points = pts.slice().reverse().map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(',');
            frame.style.clipPath = clipSide === 'right'
                ? `polygon(100% 0,100% 100%,${points})`
                : `polygon(0 0,0 100%,${points})`;
        }
    }

    // Tiny fullscreen quad + procedural fBm fire. No sprites or dependencies.
    const VERTEX = `
        attribute vec2 aPosition;
        void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }
    `;

    const FRAGMENT = `
        precision highp float;
        uniform vec2 uSize;
        uniform vec2 uBuffer;
        uniform float uTime;
        uniform float uProgress;
        uniform float uDark;

        float random2(vec2 p) {
            return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123);
        }
        float noise(vec2 p) {
            vec2 i = floor(p), f = fract(p);
            vec2 u = f*f*(3.0-2.0*f);
            return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x),
                       mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y);
        }
        float fbm(vec2 p) {
            float result = 0.0, amp = 0.5;
            for (int i = 0; i < 5; i++) {
                result += amp * noise(p);
                p = mat2(1.6,-1.2,1.2,1.6) * p;
                amp *= 0.5;
            }
            return result;
        }
        float smoothProgress(float t) { return t*t*(3.0-2.0*t); }
        float edge(float y) {
            float w = uSize.x;
            float t = smoothProgress(uProgress);
            float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t;
            float wave = sin(3.14159265*uProgress);
            return c+wave*(28.0*sin(y*.012+uTime*3.1)+
                17.0*sin(y*.041-uTime*5.7)+7.0*sin(y*.10+uTime*10.0)+
                11.0*sin(y*.073+uTime*6.13+2.0*sin(y*.017-uTime*2.3)));
        }
        void main() {
            vec2 p = vec2(gl_FragCoord.x*uSize.x/uBuffer.x,
                           uSize.y-gl_FragCoord.y*uSize.y/uBuffer.y);
            float d = p.x-edge(p.y);
            float oldSide = uDark > .5 ? -d : d;
            float t = uTime;
            float impact = sin(3.14159265*uProgress);
            vec3 col = vec3(0.0);
            float alpha = 0.0;

            if (uDark > .5) {
                // Deep smoke blankets a 350px belt BEHIND the incandescent
                // edge. Multiple warped noise scales break up smooth bands.
                vec2 sq = vec2(p.y*.007-t*.29,oldSide*.006+t*.12);
                vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)),
                                 fbm(sq*1.9+vec2(0.0,-t*.21)));
                float smokeCloud = fbm(sq*2.0+warp*2.2);
                float smokeDetail = fbm(sq*5.1-warp*.8);
                float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34);
                // A thick, drifting smoke WALL. The lighter old world
                // goes visibly dark before the red page consumes it.
                float soot = smoothstep(-110.0,-5.0,oldSide)*
                    (1.0-smoothstep(260.0,555.0,oldSide))*
                    (.55+.58*billow);
                vec3 sootColor = mix(vec3(.011,.012,.022),vec3(.18,.055,.041),smokeCloud);

                // High-frequency fBm produces uneven, flame-like tongues,
                // with a broad ember-red zone instead of a white rope.
                vec2 q = vec2(p.y*.014-t*.60,oldSide*.015-t*.43);
                float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25));
                float shredded = fbm(q*3.6+vec2(t*.24,-t*.34));
                float lengthOfTongue = 55.0+215.0*turbulence+65.0*shredded;
                float raggedEdge = 1.0-smoothstep(lengthOfTongue-62.0,lengthOfTongue+15.0,oldSide);
                float flame = raggedEdge*smoothstep(-75.0,7.0,oldSide)*
                    smoothstep(.21,.68,turbulence+.23*shredded);
                float deepEmber = exp(-max(0.0,oldSide)/86.0);
                float core = exp(-abs(d)/12.0);
                float outer = exp(-abs(d)/78.0);
                float whiteCore = core*max(.2,shredded);
                float heat = clamp(flame*.83+core*.69,0.0,1.0);
                vec3 fireColor = mix(vec3(.30,.011,.009),vec3(1.0,.105,.016),smoothstep(.10,.57,heat));
                fireColor = mix(fireColor,vec3(1.0,.47,.048),smoothstep(.43,.85,heat));
                fireColor = mix(fireColor,vec3(1.0,.93,.63),smoothstep(.80,1.0,whiteCore));
                col = mix(sootColor,fireColor,clamp(flame*.95+core+deepEmber*.20,0.0,1.0));
                alpha = clamp(soot*.93+flame*.92+outer*.32+core*.68,0.0,.99);

                // Sharp micron-scale cinders; the real ember trails live
                // on the separate high-resolution atmosphere canvas.
                vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0);
                vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5;
                float sparks = (1.0-smoothstep(.015,.12,length(uv)))*
                    step(.991,random2(cell))*exp(-abs(d)/240.0);
                col = mix(col,vec3(1.0,.78,.23),sparks);
                alpha = max(alpha,sparks*.97);
            } else {
                // Returning to blue is ELECTRIC AURORA / COLD MIST, NEVER FIRE.
                vec2 q = vec2(p.y*.009-t*.25,oldSide*.013+t*.18);
                float cloud = fbm(q+vec2(fbm(q*1.7),-t*.25));
                float finer = fbm(q*3.1+vec2(-t*.15,t*.19));
                float mist = exp(-abs(d)/208.0)*(cloud*.7+finer*.18+.23);
                float ion = exp(-abs(d)/37.0);
                float arcWobble = 25.0*sin(p.y*.043-t*6.0)+
                    10.0*sin(p.y*.14+t*13.0)+5.0*sin(p.y*.37-t*19.0);
                float arc = exp(-abs(d+arcWobble)/2.7)*
                    (.7+.3*sin(p.y*.065+t*7.0));
                float branches = exp(-abs(d+52.0*sin(p.y*.018+t*2.4))/1.9)*
                    smoothstep(.63,.92,noise(vec2(floor(p.y/54.0),floor(t*9.0))));
                col = mix(vec3(.008,.07,.19),vec3(.15,.52,.96),cloud+.27);
                col += vec3(.23,.45,.60)*ion;
                col = mix(col,vec3(.90,1.0,1.0),clamp(arc*.93+branches*.55,0.0,1.0));
                alpha = clamp(mist*.85+ion*.43+arc*.97+branches*.65,0.0,.91);
            }

            // No long-lasting screen tint after the burn has finished.
            alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) *
                (1.0-smoothstep(.94,1.0,uProgress));
            gl_FragColor = vec4(col,alpha);
        }
    `;

    function createRenderer(canvas) {
        const gl = canvas.getContext('webgl', {
            alpha: true, antialias: false, depth: false, stencil: false,
            preserveDrawingBuffer: false, premultipliedAlpha: false
        });
        if (!gl) return null;
        function compile(type, source) {
            const shader = gl.createShader(type);
            gl.shaderSource(shader, source);
            gl.compileShader(shader);
            if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
                console.error('[KKPT WebGL] Shader:', gl.getShaderInfoLog(shader));
                return null;
            }
            return shader;
        }
        const vs = compile(gl.VERTEX_SHADER, VERTEX);
        const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
        if (!vs || !fs) return null;
        const program = gl.createProgram();
        gl.attachShader(program, vs);
        gl.attachShader(program, fs);
        gl.linkProgram(program);
        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
            console.error('[KKPT WebGL] Program:', gl.getProgramInfoLog(program));
            return null;
        }
        gl.useProgram(program);
        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
        const pos = gl.getAttribLocation(program,'aPosition');
        gl.enableVertexAttribArray(pos);
        gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
        const sizeUniform = gl.getUniformLocation(program,'uSize');
        const bufferUniform = gl.getUniformLocation(program,'uBuffer');
        const timeUniform = gl.getUniformLocation(program,'uTime');
        const progressUniform = gl.getUniformLocation(program,'uProgress');
        const modeUniform = gl.getUniformLocation(program,'uDark');
        // This is a single fullscreen pass to a transparent canvas.
        // Blending here would multiply alpha twice and wash out the fire.
        gl.disable(gl.BLEND);
        return function draw(seconds,progress,dark) {
            const w=innerWidth,h=innerHeight;
            // Keep a stable GPU budget on high-DPI/mobile displays.
            const scale = w < 700 ? .68 : .84;
            const bw=Math.round(w*scale),bh=Math.round(h*scale);
            if (canvas.width!==bw || canvas.height!==bh) {
                canvas.width=bw;canvas.height=bh;gl.viewport(0,0,bw,bh);
            }
            gl.clearColor(0,0,0,0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.uniform2f(sizeUniform,w,h);
            gl.uniform2f(bufferUniform,bw,bh);
            gl.uniform1f(timeUniform,seconds);
            gl.uniform1f(progressUniform,progress);
            gl.uniform1f(modeUniform,dark?1:0);
            gl.drawArrays(gl.TRIANGLES,0,6);
        };
    }

    function createFallback(canvas) {
        const ctx = canvas.getContext('2d');
        return function draw(seconds,progress,dark) {
            const w=innerWidth,h=innerHeight;
            if (canvas.width!==w || canvas.height!==h) {canvas.width=w;canvas.height=h;}
            ctx.clearRect(0,0,w,h);
            const x = edgeAt(h/2,seconds,progress,w);
            const gradient = ctx.createLinearGradient(x-115,0,x+115,0);
            if (dark) {
                gradient.addColorStop(0,'#180d0d00');
                gradient.addColorStop(.40,'#67180677');
                gradient.addColorStop(.50,'#ffe186cc');
                gradient.addColorStop(.62,'#ef3f0955');
                gradient.addColorStop(1,'#180d0d00');
            } else {
                gradient.addColorStop(0,'#5abcf600');
                gradient.addColorStop(.46,'#7cdbff77');
                gradient.addColorStop(.5,'#ffffffbb');
                gradient.addColorStop(.56,'#63c9ff77');
                gradient.addColorStop(1,'#5abcf600');
            }
            ctx.fillStyle=gradient;ctx.fillRect(x-115,0,230,h);
        };
    }

    // A second canvas adds real, textured soot / rolling vapor and violent
    // short-lived pressure waves. It is deliberately independent of WebGL,
    // so even browsers with GPU disabled still get the smoke and impact.
    function createAtmosphere(canvas) {
        const ctx = canvas.getContext('2d', {alpha:true});
        const rand = n => {
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
            return x-Math.floor(x);
        };
        const clamp01 = n => Math.max(0,Math.min(1,n));
        function valueNoise(x,y,seed) {
            const ix=Math.floor(x), iy=Math.floor(y);
            let fx=x-ix,fy=y-iy;
            fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
            const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
            const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
            const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
            return a*(1-fy)+b*fy;
        }
        function smokeTexture(seed,dark) {
            const tex=document.createElement('canvas');
            tex.width=tex.height=112;
            const g=tex.getContext('2d');
            const data=g.createImageData(112,112);
            for(let y=0;y<112;y++) for(let x=0;x<112;x++){
                const u=(x-56)/56,v=(y-56)/56;
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
                const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
                const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
                const envelope=clamp01((1.05-radius)/.56);
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
                    envelope*envelope;
                const i=(y*112+x)*4;
                const variation=low*28+high*15;
                if(dark){
                    data.data[i]=18+variation;
                    data.data[i+1]=15+variation*.58;
                    data.data[i+2]=19+variation*.48;
                }else{
                    data.data[i]=44+variation;
                    data.data[i+1]=91+variation;
                    data.data[i+2]=125+variation;
                }
                data.data[i+3]=Math.round(240*density);
            }
            g.putImageData(data,0,0);
            return tex;
        }
        const sootSprites=[0,1,2,3].map(n=>smokeTexture(n+17,true));
        const mistSprites=[0,1,2,3].map(n=>smokeTexture(n+51,false));
        const particles=Array.from({length:innerWidth<700?72:124},(_,i)=>({
            seed:rand(i*4+71),height:rand(i*5+13),
            lifetime:.5+rand(i*8+37)*.7,
            phase:rand(i*9+21),sprite:i%4,
            scale:.72+rand(i*7+9)*1.25,
            bend:rand(i*15+23)*6.28
        }));
        const motes=Array.from({length:innerWidth<700?47:100},(_,i)=>({
            y:rand(i*3+17),phase:rand(i*9+43),
            speed:85+rand(i*12+5)*260,
            drift:75+rand(i*15+2)*170,
            length:3+rand(i*17+7)*11
        }));
        const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({
            angle:rand(i*17+131)*Math.PI*2,
            velocity:190+rand(i*11+51)*900,
            seed:rand(i*5+79),
            length:6+rand(i*7+17)*26
        }));
        function pressureWave(x,y,radius,power,dark,time) {
            if(power<=.005)return;
            const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
            if(dark){
                core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
                core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
                core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')');
                core.addColorStop(1,'rgba(30,0,0,0)');
            }else{
                core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')');
                core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')');
                core.addColorStop(1,'rgba(0,29,100,0)');
            }
            ctx.globalCompositeOperation='screen';
            ctx.fillStyle=core;
            ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
            ctx.globalCompositeOperation='source-over';
        }
        return function drawAtmosphere(seconds,progress,dark,elapsed) {
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.67:.79;
            const bw=Math.max(1,Math.round(w*scale));
            const bh=Math.max(1,Math.round(h*scale));
            if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;}
            ctx.setTransform(scale,0,0,scale,0,0);
            ctx.clearRect(0,0,w,h);
            const envelope=Math.sin(Math.PI*progress);
            const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
            const sprites=dark?sootSprites:mistSprites;
            const direction=dark?-1:1;
            // Continuous textured smoke wall: not isolated oval puffs.
            // The base layer makes the plume visible on a pale blue sky.
            ctx.globalCompositeOperation='source-over';
            const bands=Math.max(10,Math.ceil(h/(dark?49:73)));
            for(let i=0;i<bands;i++){
                const baseY=(i+.5)*h/bands;
                const y=baseY+24*Math.sin(seconds*2.5+i*.7);
                const x=edgeAt(y,seconds,progress,w)+direction*
                    (dark?118:72)+Math.sin(seconds*1.2+i*1.3)*36;
                const width=(dark?400:280)*(1+.19*Math.sin(i*2.9));
                const height=(dark?315:260);
                ctx.save();
                ctx.globalAlpha=(dark?.62:.36)*fade*(.65+.35*envelope);
                ctx.translate(x,y);
                ctx.rotate(.16*Math.sin(i*2.1+seconds*1.5));
                ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height);
                ctx.restore();
            }
            // Independent turbulent billows peel away and rise.
            for(const item of particles){
                const age=((elapsed/item.lifetime+item.phase)%1);
                const baseY=item.height*h;
                const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
                if(y< -230||y>h+230)continue;
                const frontier=edgeAt(baseY,seconds,progress,w);
                const x=frontier+direction*(age*(dark?225:135)+
                    30*Math.sin(age*6.3+item.bend));
                const size=(dark?175:145)*item.scale*(.68+age*1.9);
                const opacity=(dark?.85:.42)*Math.sin(Math.PI*age)*fade*
                    (.55+.45*envelope);
                if(opacity<=.005)continue;
                ctx.save();
                ctx.globalAlpha=opacity;
                ctx.translate(x,y);
                ctx.rotate((item.bend-3.14)*.14+age*.85*direction);
                ctx.scale(1.18,.84);
                ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size);
                ctx.restore();
            }
            // The impact happens at ignition and again as the rupture reaches
            // the middle of the screen; pulses are intentionally fleeting.
            for(const moment of (dark?[.10,.48]:[.18,.58])){
                const age=(progress-moment)/.125;
                if(age<0||age>1)continue;
                const intensity=(1-age)*(1-age)*fade;
                // A single-frame-ish exposure bloom punches through
                // the entire page; the tone is red or icy blue by direction.
                ctx.globalCompositeOperation='screen';
                ctx.fillStyle=dark
                    ? 'rgba(255,81,26,'+(.095*intensity)+')'
                    : 'rgba(150,219,255,'+(.080*intensity)+')';
                ctx.fillRect(0,0,w,h);
                ctx.globalCompositeOperation='source-over';
                const x=edgeAt(h*.51,seconds,progress,w);
                const y=h*.51;
                const radius=95+age*Math.max(w,h)*.9;
                pressureWave(x,y,radius,intensity,dark,seconds);
                ctx.globalAlpha=(dark?.20:.28)*intensity;
                ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
                ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
                ctx.beginPath();
                for(let i=0;i<=110;i++){
                    const a=i/110*Math.PI*2;
                    const jag=1+.03*Math.sin(a*29+seconds*12)+
                        .05*Math.sin(a*13-seconds*5);
                    const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag;
                    if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry);
                }
                ctx.stroke();
                ctx.globalAlpha=1;
                // Fragment trails explode outward from the rupture point.
                // They follow independent directions and speeds; no circles.
                ctx.globalCompositeOperation='screen';
                for(const bit of shockDebris){
                    const f=age*(.5+bit.seed*.5);
                    const travel=bit.velocity*f;
                    const px=x+Math.cos(bit.angle)*travel;
                    const py=y+Math.sin(bit.angle)*travel;
                    const vx=Math.cos(bit.angle)*bit.length*(1-age);
                    const vy=Math.sin(bit.angle)*bit.length*(1-age);
                    ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7);
                    ctx.strokeStyle=dark?'#ffdd9b':'#e0faff';
                    ctx.lineWidth=.6+bit.seed*1.3;
                    ctx.beginPath();
                    ctx.moveTo(px,py);
                    ctx.lineTo(px-vx,py-vy);
                    ctx.stroke();
                }
                ctx.globalCompositeOperation='source-over';
                ctx.globalAlpha=1;
            }
            // Sharp, directional embers / crystalline fragments, not blobs.
            ctx.save();
            ctx.globalCompositeOperation='screen';
            for(const mote of motes){
                const age=(elapsed*(dark?.74:.94)+mote.phase)%1;
                const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
                const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
                if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
                const opacity=(1-age)*fade*(dark?.89:.61);
                ctx.globalAlpha=opacity;
                ctx.strokeStyle=dark?'#ffb45d':'#b2efff';
                ctx.lineWidth=dark?.8:1.15;
                ctx.beginPath();
                ctx.moveTo(x,yy);
                ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
                ctx.stroke();
            }
            ctx.restore();
        };
    }

    function setDisplayed(nextActive) {
        active = nextActive;
        frame.classList.toggle('kkpt-active', active);
        frame.style.clipPath = active ? 'inset(0)' : hideClip;
        frame.tabIndex = active ? 0 : -1;
        frame.setAttribute('aria-hidden', active ? 'false' : 'true');
        document.title = active ? otherTitle : baseTitle;
        updateButton();
    }

    async function animateSwap(nextActive) {
        if (animating) return;
        animating = true;
        button.classList.add('kkpt-busy');

        // Warm-up should be done already; prepare if the user clicked early.
        labelSub.textContent = 'PREPARING THE OTHER SIDE...';
        if (!loading) warmFrame();
        const canUseFrame = await Promise.race([loading,sleep(11000).then(() => false)]);
        if (!canUseFrame || !ready) {
            // No mock background: fall back to the genuine Wiki page.
            window.location.assign(nextActive ? otherUrl.href : baseUrl);
            return;
        }

        const destinationDark = isDark(nextActive ? otherPage : basePage);
        window.__kkptVisualDark = destinationDark;
        button.classList.remove('kkpt-quiet');
        labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING';

        // Freeze frame interaction while its silhouette is animated.
        frame.classList.remove('kkpt-active');
        frame.style.pointerEvents = 'none';
        frame.style.clipPath = active ? 'inset(0)' : hideClip;

        const canvas = document.createElement('canvas');
        canvas.id = 'kkpt-vfx';
        document.body.append(canvas);
        const atmosphere = document.createElement('canvas');
        atmosphere.id = 'kkpt-atmosphere';
        document.body.append(atmosphere);
        const drawAtmosphere = createAtmosphere(atmosphere);
        const blast = document.createElement('div');
        blast.id = 'kkpt-impact';
        blast.className = destinationDark ? 'kkpt-impact-red' : 'kkpt-impact-blue';
        blast.setAttribute('aria-hidden','true');
        document.body.append(blast);
        let draw = createRenderer(canvas);
        if (!draw) {
            canvas.remove();
            const fallbackCanvas = document.createElement('canvas');
            fallbackCanvas.id = 'kkpt-vfx';
            document.body.append(fallbackCanvas);
            draw = createFallback(fallbackCanvas);
        }
        const fxCanvas = document.getElementById('kkpt-vfx');
        const duration = destinationDark ? 3650 : 2950;
        const started = performance.now();

        await new Promise(resolve => {
            function tick(now) {
                const seconds = now / 1000;
                const t = Math.min(1,(now-started)/duration);
                clipFrame(seconds,t);
                drawAtmosphere(seconds,t,destinationDark,(now-started)/1000);
                draw(seconds,t,destinationDark);
                const first=destinationDark?.10:.18;
                const second=destinationDark?.48:.58;
                const shockA=Math.exp(-Math.pow((t-first)/.041,2));
                const shockB=Math.exp(-Math.pow((t-second)/.056,2));
                const shock=Math.min(1,shockA*.98+shockB*.80);
                blast.style.opacity=String(shock);
                blast.style.setProperty('--kkpt-impact-x',
                    edgeAt(innerHeight*.51,seconds,t,innerWidth).toFixed(1)+'px');
                frame.style.filter=shock>.01
                    ? 'brightness('+(1+.48*shock).toFixed(3)+') saturate('+
                        (1+.25*shock).toFixed(3)+')'
                    : '';
                if (t < 1) requestAnimationFrame(tick);
                else resolve();
            }
            requestAnimationFrame(tick);
        });

        setDisplayed(nextActive);
        fxCanvas.style.opacity = '0';
        atmosphere.style.opacity = '0';
        blast.style.opacity = '0';
        await sleep(190);
        fxCanvas.remove();
        atmosphere.remove();
        blast.remove();
        frame.style.filter = '';
        frame.style.pointerEvents = '';
        animating = false;
        button.classList.remove('kkpt-busy','kkpt-quiet');
        // No location.assign here: the rendered target stays alive.
        history.pushState({ kkptV6: true, active }, '', active ? otherUrl.href : baseUrl);
    }

    function requestSwap(nextActive) {
        if (animating || nextActive === active) return;
        if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
            setDisplayed(nextActive);
            history.pushState({kkptV6:true,active},'',active?otherUrl.href:baseUrl);
            return;
        }
        animateSwap(nextActive).catch(err => {
            console.error('[KuranokaPageTransition] V6:',err);
            document.getElementById('kkpt-vfx')?.remove();
            document.getElementById('kkpt-atmosphere')?.remove();
            document.getElementById('kkpt-impact')?.remove();
            if (frame) frame.style.filter = '';
            window.location.assign(nextActive ? otherUrl.href : baseUrl);
        });
    }

    function createButton() {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
        button=document.createElement('a');
        button.id='kkpt-toggle';
        labelIcon=document.createElement('span');
        labelIcon.className='kkpt-icon';
        labelIcon.setAttribute('aria-hidden','true');
        const text=document.createElement('span');
        text.className='kkpt-text';
        labelTitle=document.createElement('span');
        labelSub=document.createElement('span');
        labelSub.className='kkpt-sub';
        text.append(labelTitle,labelSub);
        button.append(labelIcon,text);
        document.body.append(button);
        button.hidden=!SHOW_FLOATING_BUTTON;
        updateButton();
        watchPortalLinks(document, otherPage);
        warmFrame();

        button.addEventListener('click', event => {
            if (!isPlainClick(event)) return;
            event.preventDefault();
            requestSwap(!active);
        });
    }

    // A plain [[Kuronata|your mysterious text]] now triggers everything.
    // The inverse [[Kuranoka|...]] works inside the already-loaded iframe.
    document.addEventListener('click', event => {
        if (active) return;
        portalClick(event, document, otherPage, true);
    }, true);
    mw.hook('wikipage.content').add(() => {
        if (document.readyState !== 'loading') watchPortalLinks(document, otherPage);
    });

    // Back and Forward restore the two already-loaded documents instantly.
    window.addEventListener('popstate', () => {
        if (!frame || !ready || animating) return;
        const route = pathPage();
        if (route === basePage || route === otherPage) {
            setDisplayed(route === otherPage);
        }
    });

    window.addEventListener('pageshow', event => {
        if (!event.persisted || !frame || !ready) return;
        animating=false;
        document.getElementById('kkpt-vfx')?.remove();
        document.getElementById('kkpt-atmosphere')?.remove();
        document.getElementById('kkpt-impact')?.remove();
        frame.style.filter = '';
        setDisplayed(pathPage() === otherPage);
        button?.classList.remove('kkpt-busy','kkpt-quiet');
    });

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded',createButton,{once:true});
    } else {
        createButton();
    }
}());