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

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
无编辑摘要
无编辑摘要
第1行: 第1行:
/* =================================================================
/* ====================================================================
   KURANOKA PAGE TRANSITION V3.1 — ASHES / REBIRTH
   KURANOKA / KURONATA V4 — EMBER / AURORA
   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.
  Smooth SVG-path clipping + WebGL procedural flames / cold lightning.
   ================================================================= */
   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.__kkptV4) 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.__kkptV4 = true;
     const target = burn ? 'Kuronata' : 'Kuranoka';
    window.__kkptVersion = '4.0';
     const targetUrl = '/wiki/' + encodeURIComponent(target);
 
     const DURATION = burn ? 3900 : 3200;
     const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka';
     const startClip = burn ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
     const baseUrl = location.href;
     const warmedImages = new Map();
    const baseTitle = document.title;
    const rand = (min, max) => min + Math.random() * (max - min);
    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') || '';
        ];
    }


        selectors.forEach(selector => {
    function updateButton() {
            try {
        if (!button) return;
                doc.querySelectorAll(selector).forEach((node, index) => {
        const to = destination();
                    if (index < 45 && !nodes.includes(node)) nodes.push(node);
        button.href = to === basePage ? baseUrl : otherUrl.href;
                });
        labelTitle.textContent = to.toUpperCase();
            } catch (e) { /* selector may not exist in an older skin */ }
        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');
    }


    // 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行: 第112行:
                 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;
 
            // 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 || event.defaultPrevented || event.button !== 0 ||
                    event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
                const el = event.target instanceof doc.defaultView.Element
                    ? event.target.closest('a[href]') : null;
                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))
         );
     }
     }


     const SPRITES = {
     function clipFrame(seconds, progress) {
         fire: makeSprite([[0, '#ffffff'], [.11, '#fff6b3'], [.35, '#ffb71e'], [.68, '#f7330988'], [1, '#a3000000']]),
        const width = innerWidth, height = innerHeight;
         spark: makeSprite([[0, '#ffffff'], [.16, '#fff3b8'], [.48, '#ff7900bb'], [1, '#ff230000']]),
         const pts = [];
        smoke: makeSprite([[0, '#170c0de0'], [.36, '#201b20a1'], [.72, '#302b3260'], [1, '#11111100']]),
        const count = Math.max(35, Math.ceil(height / 11));
        ash: makeSprite([[0, '#d2b2a9'], [.28, '#897471bb'], [1, '#55555500']]),
        for (let i = 0; i <= count; i++) {
         sky: makeSprite([[0, '#ffffff'], [.16, '#eeffff'], [.44, '#79d5ffbb'], [1, '#3c92ff00']])
            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})`;
        }
     }


     function runAnimation() {
     // Tiny fullscreen quad + procedural fBm fire. No sprites or dependencies.
         return new Promise(resolve => {
    const VERTEX = `
            const canvas = document.createElement('canvas');
         attribute vec2 aPosition;
            canvas.id = 'kkpt-canvas';
        void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }
            canvas.setAttribute('aria-hidden', 'true');
    `;
            document.body.append(canvas);
            const ctx = canvas.getContext('2d', { alpha: true });
            if (!ctx) { canvas.remove(); resolve(); return; }


            let width = 0, height = 0;
    const FRAGMENT = `
            function resize() {
        precision highp float;
                width = window.innerWidth;
        uniform vec2 uSize;
                height = window.innerHeight;
        uniform vec2 uBuffer;
                const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.6);
        uniform float uTime;
                canvas.width = Math.round(width * pixelRatio);
        uniform float uProgress;
                canvas.height = Math.round(height * pixelRatio);
        uniform float uDark;
                ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
            }
            resize();


            const particles = [];
        float random2(vec2 p) {
            const shockwaves = [];
             return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123);
            const maxParticles = width < 700 ? 580 : 1100;
        }
            const start = performance.now();
        float noise(vec2 p) {
             let previous = start;
            vec2 i = floor(p), f = fract(p);
            let burstClock = 0;
            vec2 u = f*f*(3.0-2.0*f);
 
            return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x),
            function addParticle(x, y) {
                      mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y);
                if (particles.length >= maxParticles) return;
        }
                const r = Math.random();
        float fbm(vec2 p) {
                const type = burn
            float result = 0.0, amp = 0.5;
                    ? (r < .49 ? 'fire' : r < .70 ? 'spark' : r < .90 ? 'smoke' : 'ash')
            for (int i = 0; i < 5; i++) {
                    : (r < .68 ? 'sky' : r < .85 ? 'spark' : 'smoke');
                result += amp * noise(p);
                const isSmoke = type === 'smoke';
                p = mat2(1.6,-1.2,1.2,1.6) * p;
                const isDust = type === 'ash';
                 amp *= 0.5;
                const life = isSmoke ? rand(.9, 1.85) : isDust ? rand(.8, 2.3) : rand(.35, 1.05);
                particles.push({
                    type, x: x + rand(-24, 24), y: y + rand(-22, 22),
                    vx: burn ? rand(-255, 96) : rand(-86, 210),
                    vy: isSmoke ? rand(-115, -25) : isDust ? rand(-95, 25) : rand(-280, -52),
                    size: isSmoke ? rand(38, 102) : isDust ? rand(3, 11) :
                        type === 'spark' ? rand(3, 10) : rand(14, 42),
                    life, remain: life, angle: rand(-Math.PI, Math.PI), spin: rand(-1.2, 1.2)
                 });
             }
             }
            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;


             function paint(now) {
             if (uDark > .5) {
                 const dt = Math.min(.05, Math.max(.001, (now - previous) / 1000));
                 // Fluid, upward-drifting fBm and domain warping.
                 previous = now;
                vec2 q = vec2(p.y*.012-t*.44, oldSide*.017-t*.68);
                 const progress = Math.min(1, (now - start) / DURATION);
                float warp1 = fbm(q+vec2(0.0,t*.4));
                 const ease = smooth(progress);
                 float warp2 = fbm(q*2.3+warp1*2.2);
                 const wobble = Math.pow(Math.sin(Math.PI * progress), .7);
                 float curl = fbm(q*1.35+vec2(warp2*2.8,-warp1*1.7));
                 const direction = burn ? -1 : 1;
                 float domain = mix(warp1,warp2,.5);
                 const center = burn
                 float tongues = 140.0*clamp((domain*.76+curl*.53),0.0,1.0);
                    ? width + 120 - (width + 260) * ease
                 float fire = clamp((tongues-oldSide)/68.0,0.0,1.0);
                    : -120 + (width + 260) * ease;
                 fire *= smoothstep(-55.0,8.0,oldSide);
                float core = exp(-abs(d)/9.0);
                float outer = exp(-abs(d)/48.0);


                 function edge(y) {
                 vec2 sq = vec2(p.y*.010+t*.14,oldSide*.013-t*.24);
                    return center + wobble * (
                float sootNoise = fbm(sq+fbm(sq*2.0));
                        32 * Math.sin(y * .012 + now * .0037) +
                float soot = (1.0-smoothstep(45.0,270.0,oldSide))*
                        21 * Math.sin(y * .035 - now * .0061) +
                    smoothstep(-28.0,17.0,oldSide)*(sootNoise*.75+.20);
                        12 * Math.sin(y * .079 + now * .0107) +
                vec3 sootColor = mix(vec3(.028,.015,.016),vec3(.14,.040,.027),sootNoise);
                        7 * Math.sin(y * .156 - now * .014)
                float heat = clamp(fire*.85+core*.6,0.0,1.0);
                    );
                vec3 fireColor = mix(vec3(.36,.015,.008),vec3(1.0,.20,.025),smoothstep(.15,.58,heat));
                }
                fireColor = mix(fireColor,vec3(1.0,.79,.21),smoothstep(.55,.88,heat));
                fireColor = mix(fireColor,vec3(1.0,.97,.74),smoothstep(.85,1.0,heat));
                col = mix(sootColor,fireColor,clamp(fire*1.10+core,0.0,1.0));
                alpha = clamp(soot*.58+fire*.90+outer*.19+core*.64,0.0,.96);


                 const pts = [];
                 // Fine orange embers, not giant elliptical blobs.
                 const count = Math.max(35, Math.ceil(height / 17));
                vec2 cell = floor((p+vec2(t*27.0,-t*100.0))/13.0);
                 for (let i = 0; i <= count; i++) {
                 vec2 uv = fract((p+vec2(t*27.0,-t*100.0))/13.0)-.5;
                    const y = height * i / count;
                float rand = random2(cell);
                    pts.push([edge(y), y]);
                float sparks = (1.0-smoothstep(0.02,.17,length(uv)))*
                 }
                    step(.987,rand)*exp(-abs(d)/210.0);
                 const pathToEdge = pts.slice().reverse().map(p =>
                 col = mix(col,vec3(1.0,.78,.30),sparks);
                     p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'
                alpha = max(alpha,sparks*.93);
                ).join(',');
            } else {
                 iframe.style.clipPath = burn
                // The return is ELECTRIC AURORA / COLD MIST, NOT BLUE FIRE.
                    ? 'polygon(100% 0,100% 100%,' + pathToEdge + ')'
                vec2 q = vec2(p.y*.009-t*.25,oldSide*.013+t*.18);
                    : 'polygon(0 0,0 100%,' + pathToEdge + ')';
                float cloud = fbm(q+vec2(fbm(q*1.7),-t*.25));
                 float mist = exp(-abs(d)/115.0)*(cloud*.7+.13);
                 float ion = exp(-abs(d)/26.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(.012,.10,.24),vec3(.20,.64,1.0),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*.70+ion*.36+arc*.95+branches*.61,0.0,.89);
            }


                 ctx.clearRect(0, 0, width, height);
            // 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);
        }
    `;


                // Flare spreading over the whole screen makes this feel like
    function createRenderer(canvas) {
                 // the world's light changes, rather than a moving divider.
        const gl = canvas.getContext('webgl', {
                 const flash = .025 * (1 + Math.sin(now * .048)) +
            alpha: true, antialias: false, depth: false, stencil: false,
                    .07 * Math.pow(Math.max(0, Math.sin(now * .009)), 12);
            preserveDrawingBuffer: false, premultipliedAlpha: false
                ctx.fillStyle = burn
        });
                    ? 'rgba(108,13,5,' + (flash * wobble).toFixed(3) + ')'
        if (!gl) return null;
                    : 'rgba(48,135,205,' + (flash * wobble).toFixed(3) + ')';
        function compile(type, source) {
                ctx.fillRect(0, 0, width, height);
            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');
        gl.enable(gl.BLEND);
        gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
        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 trace(offset) {
    function createFallback(canvas) {
                    ctx.beginPath();
        const ctx = canvas.getContext('2d');
                    pts.forEach((p, i) => {
        return function draw(seconds,progress,dark) {
                        if (i === 0) ctx.moveTo(p[0] + offset, p[1]);
            const w=innerWidth,h=innerHeight;
                        else ctx.lineTo(p[0] + offset, p[1]);
            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 broad charred-paper zone is consumed BEFORE the bright core.
    function setDisplayed(nextActive) {
                // Its gradient is on the remaining old page side of the flame.
        active = nextActive;
                ctx.save();
        frame.classList.toggle('kkpt-active', active);
                const x0 = burn ? -133 : 133;
        frame.style.clipPath = active ? 'inset(0)' : hideClip;
                const x1 = burn ? 18 : -18;
        frame.tabIndex = active ? 0 : -1;
                const dark = ctx.createLinearGradient(center + x0, 0, center + x1, 0);
        frame.setAttribute('aria-hidden', active ? 'false' : 'true');
                if (burn) {
        document.title = active ? otherTitle : baseTitle;
                    dark.addColorStop(0, '#130d0d00');
        updateButton();
                    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();


                // Smoke is below the light-emitting flames.
    async function animateSwap(nextActive) {
                ctx.save();
        if (animating) return;
                for (let i = particles.length - 1; i >= 0; i--) {
        animating = true;
                    const p = particles[i];
        button.classList.add('kkpt-busy');
                    p.remain -= dt;
                    if (p.remain <= 0) { particles.splice(i, 1); continue; }
                    p.x += p.vx * dt;
                    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();


                // Broken filament cracks penetrating the unburnt page.
        // Warm-up should be done already; prepare if the user clicked early.
                // They flicker independently instead of making a tidy wall.
        labelSub.textContent = 'PREPARING THE OTHER SIDE...';
                if (burn) {
        if (!loading) warmFrame();
                    ctx.save();
        const canUseFrame = await Promise.race([loading,sleep(11000).then(() => false)]);
                    for (let y = 38; y < height; y += 103) {
        if (!canUseFrame || !ready) {
                        const a = edge(y), skew = Math.sin(y * .13 + now * .0017);
            // No mock background: fall back to the genuine Wiki page.
                        ctx.lineWidth = 1.6;
            window.location.assign(nextActive ? otherUrl.href : baseUrl);
                        ctx.strokeStyle = 'rgba(38,6,4,.8)';
            return;
                        ctx.beginPath();
        }
                        ctx.moveTo(a - 8, y);
                        ctx.lineTo(a - 35, y - 13 * skew);
                        ctx.lineTo(a - 59, y + 12);
                        ctx.lineTo(a - 93 - 20 * skew, y - 19);
                        ctx.lineTo(a - 127, y - 6);
                        ctx.stroke();
                        ctx.lineWidth = .7;
                        ctx.strokeStyle = 'rgba(255,99,23,.66)';
                        ctx.stroke();
                        ctx.beginPath();
                        ctx.moveTo(a - 61, y + 12);
                        ctx.lineTo(a - 77, y + 31);
                        ctx.lineTo(a - 91, y + 40);
                        ctx.stroke();
                    }
                    ctx.restore();
                }


                // Blooming outer corona, molten core, then white-hot ignition.
        const destinationDark = isDark(nextActive ? otherPage : basePage);
                ctx.save();
        window.__kkptVisualDark = destinationDark;
                ctx.lineJoin = 'round';
        button.classList.remove('kkpt-quiet');
                ctx.lineCap = 'round';
        labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING';
                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.shadowBlur = 39;
                ctx.strokeStyle = burn ? 'rgba(255,110,10,.88)' : 'rgba(130,223,255,.91)';
                ctx.lineWidth = burn ? 29 : 35;
                ctx.stroke();
                ctx.shadowBlur = 18;
                ctx.strokeStyle = burn ? '#ffe564' : '#e5fbff';
                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
        // Freeze frame interaction while its silhouette is animated.
                // old Kuranoka; on the way back, electric shards tear right.
        frame.classList.remove('kkpt-active');
                ctx.save();
        frame.style.pointerEvents = 'none';
                ctx.globalCompositeOperation = 'lighter';
        frame.style.clipPath = active ? 'inset(0)' : hideClip;
                for (let y = -60; y < height + 60; y += burn ? 17 : 26) {
                    const x = edge(y);
                    const flicker = Math.sin(now * .015 + y * .093);
                    const lift = (burn ? 55 : 68) +
                        (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)';
        const canvas = document.createElement('canvas');
                    ctx.beginPath();
        canvas.id = 'kkpt-vfx';
                    ctx.moveTo(x - lean * .05, y + 19);
        document.body.append(canvas);
                    ctx.bezierCurveTo(x + lean * .14, y - 18,
        let draw = createRenderer(canvas);
                        x + lean * .88, y - lift * .47, x + lean, y - lift);
        if (!draw) {
                    ctx.bezierCurveTo(x + lean * .35, y - lift * .53,
            canvas.remove();
                        x - lean * .13, y + 13, x + 16, y + 27);
            const fallbackCanvas = document.createElement('canvas');
                    ctx.closePath();
            fallbackCanvas.id = 'kkpt-vfx';
                    ctx.fill();
            document.body.append(fallbackCanvas);
 
            draw = createFallback(fallbackCanvas);
                    ctx.fillStyle = burn ? 'rgba(255,231,89,.47)' : 'rgba(222,250,255,.52)';
        }
                    ctx.beginPath();
        const fxCanvas = document.getElementById('kkpt-vfx');
                    ctx.moveTo(x + 4, y + 11);
        const duration = destinationDark ? 3650 : 2950;
                    ctx.quadraticCurveTo(x + lean * .32, y - lift * .31,
        const started = performance.now();
                        x + lean * .70, y - lift * .67);
                    ctx.quadraticCurveTo(x - 6, y - lift * .21, x + 7, y + 11);
                    ctx.closePath();
                    ctx.fill();
                }
                ctx.restore();
 
                // Impacts regularly burst along the travelling front.
                burstClock += dt;
                if (burstClock > (burn ? .18 : .26) && progress > .05 && progress < .95) {
                    burstClock = 0;
                    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;
        await new Promise(resolve => {
                for (let i = 0; i < density; i++) {
            function tick(now) {
                    const y = rand(0, height);
                 const seconds = now / 1000;
                    addParticle(edge(y), y);
                const t = Math.min(1,(now-started)/duration);
                }
                clipFrame(seconds,t);
 
                draw(seconds,t,destinationDark);
                ctx.save();
                 if (t < 1) requestAnimationFrame(tick);
                ctx.globalCompositeOperation = 'lighter';
                 else resolve();
                 particles.forEach(p => {
                    if (p.type === 'smoke' || p.type === 'ash') return;
                    const age = 1 - p.remain / p.life;
                    const fade = Math.min(1, p.remain * 3) * (1 - age * .65);
                    ctx.globalAlpha = fade * (p.type === 'spark' ? .97 : .72);
                    ctx.translate(p.x, p.y);
                    ctx.rotate(p.angle);
                    const xsize = p.size * (p.type === 'fire' ? .8 : 1);
                    const ysize = p.size * (p.type === 'fire' ? 1.7 : 1);
                    ctx.drawImage(SPRITES[p.type], -xsize, -ysize, xsize * 2, ysize * 2);
                    ctx.rotate(-p.angle);
                    ctx.translate(-p.x, -p.y);
                });
                ctx.restore();
 
                 if (progress < 1) {
                    requestAnimationFrame(paint);
                 } else {
                    // The iframe is fully visible BEFORE the old document
                    // navigates. No handoff cover, no blue/red solid flash.
                    iframe.style.clipPath = 'inset(0)';
                    canvas.style.opacity = '0';
                    resolve();
                }
             }
             }
             requestAnimationFrame(paint);
             requestAnimationFrame(tick);
         });
         });
    }
    async function begin() {
        if (!warmPromise) createPreviewEarly();
        button.classList.add('kkpt-loading');
        button.querySelector('.kkpt-sub').textContent = 'PREPARING THE OTHER SIDE...';
        // 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;
        }
        button.classList.remove('kkpt-loading');
        button.classList.add('kkpt-running');
        button.querySelector('.kkpt-sub').textContent = burn ? 'THE WORLD IS BURNING' : 'BREAK THROUGH THE DARK';
        await runAnimation();


         // Let the compositor present the REAL destination iframe for a few
         setDisplayed(nextActive);
         // frames. On navigation, native cross-document View Transition takes
         fxCanvas.style.opacity = '0';
         // its old snapshot from this already-revealed destination.
         await sleep(190);
         button.classList.add('kkpt-vanish');
         fxCanvas.remove();
         await frame();
         frame.style.pointerEvents = '';
         await frame();
         animating = false;
         await pause(90);
         button.classList.remove('kkpt-busy','kkpt-quiet');
         window.location.assign(targetUrl);
         // No location.assign here: the rendered target stays alive.
        history.pushState({ kkptV4: true, active }, '', active ? otherUrl.href : baseUrl);
     }
     }


     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);
 
         updateButton();
         // Preload starts right now, not on click.
         warmFrame();
         createPreviewEarly();


         button.addEventListener('click', event => {
         button.addEventListener('click', event => {
             if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
             if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
                 event.metaKey || event.shiftKey || event.altKey) return;
                 event.metaKey || event.altKey || event.shiftKey) return;
            if (button.dataset.kkptFallback === '1') return; // normal navigation
             event.preventDefault();
             event.preventDefault();
             if (busy) return;
             if (animating) return;
            busy = true;
             if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
 
                 setDisplayed(!active);
             if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
                history.pushState({kkptV4:true,active},'',active?otherUrl.href:baseUrl);
                 window.location.assign(targetUrl);
                 return;
                 return;
             }
             }
             begin().catch(err => {
             animateSwap(!active).catch(err => {
                 console.error('[KuranokaPageTransition] V3 error:', err);
                 console.error('[KuranokaPageTransition] V4:',err);
                // Unexpected errors go to the real page, never a blank cover.
                 window.location.assign(active ? baseUrl : otherUrl.href);
                 window.location.assign(targetUrl);
             });
             });
         });
         });
     }
     }
    // 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();
        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日 (五) 12:28的版本

/* ====================================================================
   KURANOKA / KURONATA V4 — EMBER / AURORA
   MediaWiki:KuranokaPageTransition.js
   Same-origin iframe + history.pushState: NO full reload on toggles.
   Smooth SVG-path clipping + WebGL procedural flames / cold lightning.
   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.__kkptV4) 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.__kkptV4 = true;
    window.__kkptVersion = '4.0';

    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');
    }

    // 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;

            // 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 || event.defaultPrevented || event.button !== 0 ||
                    event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
                const el = event.target instanceof doc.defaultView.Element
                    ? event.target.closest('a[href]') : null;
                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) {
                // Fluid, upward-drifting fBm and domain warping.
                vec2 q = vec2(p.y*.012-t*.44, oldSide*.017-t*.68);
                float warp1 = fbm(q+vec2(0.0,t*.4));
                float warp2 = fbm(q*2.3+warp1*2.2);
                float curl = fbm(q*1.35+vec2(warp2*2.8,-warp1*1.7));
                float domain = mix(warp1,warp2,.5);
                float tongues = 140.0*clamp((domain*.76+curl*.53),0.0,1.0);
                float fire = clamp((tongues-oldSide)/68.0,0.0,1.0);
                fire *= smoothstep(-55.0,8.0,oldSide);
                float core = exp(-abs(d)/9.0);
                float outer = exp(-abs(d)/48.0);

                vec2 sq = vec2(p.y*.010+t*.14,oldSide*.013-t*.24);
                float sootNoise = fbm(sq+fbm(sq*2.0));
                float soot = (1.0-smoothstep(45.0,270.0,oldSide))*
                    smoothstep(-28.0,17.0,oldSide)*(sootNoise*.75+.20);
                vec3 sootColor = mix(vec3(.028,.015,.016),vec3(.14,.040,.027),sootNoise);
                float heat = clamp(fire*.85+core*.6,0.0,1.0);
                vec3 fireColor = mix(vec3(.36,.015,.008),vec3(1.0,.20,.025),smoothstep(.15,.58,heat));
                fireColor = mix(fireColor,vec3(1.0,.79,.21),smoothstep(.55,.88,heat));
                fireColor = mix(fireColor,vec3(1.0,.97,.74),smoothstep(.85,1.0,heat));
                col = mix(sootColor,fireColor,clamp(fire*1.10+core,0.0,1.0));
                alpha = clamp(soot*.58+fire*.90+outer*.19+core*.64,0.0,.96);

                // Fine orange embers, not giant elliptical blobs.
                vec2 cell = floor((p+vec2(t*27.0,-t*100.0))/13.0);
                vec2 uv = fract((p+vec2(t*27.0,-t*100.0))/13.0)-.5;
                float rand = random2(cell);
                float sparks = (1.0-smoothstep(0.02,.17,length(uv)))*
                    step(.987,rand)*exp(-abs(d)/210.0);
                col = mix(col,vec3(1.0,.78,.30),sparks);
                alpha = max(alpha,sparks*.93);
            } else {
                // The return is ELECTRIC AURORA / COLD MIST, NOT BLUE 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 mist = exp(-abs(d)/115.0)*(cloud*.7+.13);
                float ion = exp(-abs(d)/26.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(.012,.10,.24),vec3(.20,.64,1.0),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*.70+ion*.36+arc*.95+branches*.61,0.0,.89);
            }

            // 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');
        gl.enable(gl.BLEND);
        gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
        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);
        };
    }

    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);
        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);
                draw(seconds,t,destinationDark);
                if (t < 1) requestAnimationFrame(tick);
                else resolve();
            }
            requestAnimationFrame(tick);
        });

        setDisplayed(nextActive);
        fxCanvas.style.opacity = '0';
        await sleep(190);
        fxCanvas.remove();
        frame.style.pointerEvents = '';
        animating = false;
        button.classList.remove('kkpt-busy','kkpt-quiet');
        // No location.assign here: the rendered target stays alive.
        history.pushState({ kkptV4: true, active }, '', active ? 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);
        updateButton();
        warmFrame();

        button.addEventListener('click', event => {
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
                event.metaKey || event.altKey || event.shiftKey) return;
            event.preventDefault();
            if (animating) return;
            if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
                setDisplayed(!active);
                history.pushState({kkptV4:true,active},'',active?otherUrl.href:baseUrl);
                return;
            }
            animateSwap(!active).catch(err => {
                console.error('[KuranokaPageTransition] V4:',err);
                window.location.assign(active ? baseUrl : otherUrl.href);
            });
        });
    }

    // 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();
        setDisplayed(pathPage() === otherPage);
        button?.classList.remove('kkpt-busy','kkpt-quiet');
    });

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