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

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
 
无编辑摘要
(未显示同一用户的3个中间版本)
第1行: 第1行:
/* ================================================================
/* ====================================================================
   KURANOKA / KURONATA — PAGE TRANSITION 1.0
   KURANOKA / KURONATA V4 — EMBER / AURORA
   Paste this file to MediaWiki:PageTransition.js.
   MediaWiki:KuranokaPageTransition.js
   Loaded by MediaWiki:Citizen.js (see installation guide).
  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 () {
(function () {
     'use strict';
     'use strict';


     if (window.__kkptLoaded) return;
    // The preview is a complete MediaWiki page. It must NOT spawn another.
     window.__kkptLoaded = true;
    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') || '';
    }


     if (!window.mw || !mw.config) return;
     function updateButton() {
     if (mw.config.get('wgNamespaceNumber') !== 0) return;
        if (!button) return;
     if (mw.config.get('wgAction') !== 'view') 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);


    var page = mw.config.get('wgPageName');
                // Fine orange embers, not giant elliptical blobs.
    if (page !== 'Kuranoka' && page !== 'Kuronata') return;
                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);
            }


    var isKuranoka = page === 'Kuranoka';
            // No long-lasting screen tint after the burn has finished.
    var target = isKuranoka ? 'Kuronata' : 'Kuranoka';
            alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) *
     var targetUrl = '/wiki/' + encodeURIComponent(target);
                (1.0-smoothstep(.94,1.0,uProgress));
            gl_FragColor = vec4(col,alpha);
        }
     `;


     function mount() {
     function createRenderer(canvas) {
         if (!document.body || document.getElementById('kkpt-toggle')) return;
        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);
        };
    }


         var link = document.createElement('a');
    function createFallback(canvas) {
         link.id = 'kkpt-toggle';
         const ctx = canvas.getContext('2d');
        link.href = targetUrl;
         return function draw(seconds,progress,dark) {
        link.setAttribute('aria-label', isKuranoka
            const w=innerWidth,h=innerHeight;
            ? '切换至 Kuronata:焚毁世界'
            if (canvas.width!==w || canvas.height!==h) {canvas.width=w;canvas.height=h;}
             : '切换至 Kuranoka:重返天空');
            ctx.clearRect(0,0,w,h);
        link.setAttribute('title', isKuranoka
            const x = edgeAt(h/2,seconds,progress,w);
            ? '让这个世界燃烧 · KURONATA'
            const gradient = ctx.createLinearGradient(x-115,0,x+115,0);
            : '穿越终焉,重返天空 · KURANOKA');
            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);
        };
    }


         var mark = document.createElement('span');
    function setDisplayed(nextActive) {
         mark.className = 'kkpt-mark';
         active = nextActive;
         mark.setAttribute('aria-hidden', 'true');
        frame.classList.toggle('kkpt-active', active);
         mark.textContent = isKuranoka ? '✦' : '☀';
         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();
    }


         var copy = document.createElement('span');
    async function animateSwap(nextActive) {
         copy.className = 'kkpt-copy';
         if (animating) return;
         animating = true;
        button.classList.add('kkpt-busy');


         var title = document.createElement('span');
         // Warm-up should be done already; prepare if the user clicked early.
         title.className = 'kkpt-title';
        labelSub.textContent = 'PREPARING THE OTHER SIDE...';
         title.textContent = isKuranoka ? 'KURONATA' : 'KURANOKA';
        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;
        }


         var subtitle = document.createElement('span');
         const destinationDark = isDark(nextActive ? otherPage : basePage);
         subtitle.className = 'kkpt-subtitle';
         window.__kkptVisualDark = destinationDark;
         subtitle.textContent = isKuranoka
        button.classList.remove('kkpt-quiet');
            ? 'BURN THE WORLD / 世界を燃やせ'
         labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING';
            : 'RETURN TO THE SKY / 空へ帰ろう';


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


         // Browser prefetch is a hint, never required for navigation.
         const canvas = document.createElement('canvas');
         var prefetchStarted = false;
         canvas.id = 'kkpt-vfx';
         function prefetch() {
         document.body.append(canvas);
            if (prefetchStarted || !document.head) return;
        let draw = createRenderer(canvas);
             prefetchStarted = true;
        if (!draw) {
             var hint = document.createElement('link');
             canvas.remove();
             hint.rel = 'prefetch';
             const fallbackCanvas = document.createElement('canvas');
             hint.href = targetUrl;
             fallbackCanvas.id = 'kkpt-vfx';
             document.head.appendChild(hint);
             document.body.append(fallbackCanvas);
             draw = createFallback(fallbackCanvas);
         }
         }
         link.addEventListener('pointerenter', prefetch, { once: true });
         const fxCanvas = document.getElementById('kkpt-vfx');
         link.addEventListener('focus', prefetch, { once: true });
        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();


         // Let the real anchor navigate. This is critical: native cross-
         button.addEventListener('click', event => {
        // document View Transitions require a genuine same-origin navigation.
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
        // Ctrl-click / middle-click will still open a normal new tab.
                event.metaKey || event.altKey || event.shiftKey) return;
        document.body.appendChild(link);
            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') {
     if (document.readyState === 'loading') {
         document.addEventListener('DOMContentLoaded', mount, { once: true });
         document.addEventListener('DOMContentLoaded',createButton,{once:true});
     } else {
     } else {
         mount();
         createButton();
     }
     }
}());
}());

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