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

MediaWiki:KuranokaPageTransition.js

MediaWiki界面页面
KK​(留言 | 贡献)2026年10月23日 (五) 12:07的版本

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
  • Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* =================================================================
   KURANOKA PAGE TRANSITION V3.1 — ASHES / REBIRTH
   Paste into MediaWiki:KuranokaPageTransition.js (replace V2).
   Load ONLY on wiki pages Kuranoka and Kuronata via Citizen.js.
   Needs the matching KuranokaPageTransition.css. No libraries.
   ================================================================= */
(function () {
    'use strict';

    // The destination iframe loads this script too. NEVER nest previews.
    if (window !== window.top || window.__kkptV3) return;
    window.__kkptV3 = true;
    window.__kkptVersion = '3.1';
    if (!window.mw || !mw.config) return;
    if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;

    const page = mw.config.get('wgPageName');
    if (page !== 'Kuranoka' && page !== 'Kuronata') return;

    const burn = page === 'Kuranoka';
    const target = burn ? 'Kuronata' : 'Kuranoka';
    const targetUrl = '/wiki/' + encodeURIComponent(target);
    const DURATION = burn ? 3900 : 3200;
    const startClip = burn ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
    const warmedImages = new Map();
    const rand = (min, max) => min + Math.random() * (max - min);
    const smooth = t => t * t * (3 - 2 * t);
    const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
    const frame = () => new Promise(resolve => requestAnimationFrame(resolve));

    let iframe = null;
    let warmPromise = null;
    let loaded = false;
    let busy = false;
    let button = null;
    let previewIssue = '';

    // Look at actual CSS in BOTH documents. This finds Background template
    // body backgrounds as well as CSS pseudo-element backdrops.
    function findBackgroundUrls(doc) {
        const urls = new Set();
        const nodes = [doc.documentElement, doc.body];
        const selectors = [
            '#mw-content-text', '.mw-parser-output', '.citizen-page-container',
            '.citizen-body-container', '.mw-page-container',
            '.background-theme', '[class*="background"]', '[id*="background"]',
            '[style*="background"]', '[data-background]'
        ];

        selectors.forEach(selector => {
            try {
                doc.querySelectorAll(selector).forEach((node, index) => {
                    if (index < 45 && !nodes.includes(node)) nodes.push(node);
                });
            } catch (e) { /* selector may not exist in an older skin */ }
        });

        function add(value) {
            if (!value || typeof value !== 'string') return;
            const urlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
            let match;
            let hasCssUrl = false;
            while ((match = urlPattern.exec(value))) {
                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)) {
                try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
            }
        }

        nodes.forEach(node => {
            if (!node) return;
            try {
                const win = doc.defaultView;
                [null, '::before', '::after'].forEach(pseudo => {
                    const style = win.getComputedStyle(node, pseudo);
                    if (style) {
                        add(style.backgroundImage);
                        add(style.maskImage);
                        add(style.getPropertyValue('--background-image'));
                        add(style.getPropertyValue('--bg-image'));
                    }
                });
            } catch (e) { /* ignore unsupported pseudo styles */ }

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

    function decodeBackground(url) {
        if (warmedImages.has(url)) return warmedImages.get(url);
        const image = new Image();
        image.decoding = 'async';
        image.fetchPriority = 'high';
        const promise = new Promise(resolve => {
            // An unreachable wallpaper must not freeze the whole transition.
            let finished = false;
            const timeout = setTimeout(() => finish(false), 4500);
            function finish(ok) {
                if (finished) return;
                finished = true;
                clearTimeout(timeout);
                resolve(ok);
            }
            image.onload = () => finish(true);
            image.onerror = () => finish(false);
            image.src = url;
            if (image.complete) finish(image.naturalWidth > 0);
        }).then(async success => {
            if (success && image.decode) {
                try {
                    await Promise.race([image.decode(), pause(900)]);
                } 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;
    }

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

    function createPreviewEarly() {
        if (warmPromise) return warmPromise;

        iframe = document.createElement('iframe');
        iframe.id = 'kkpt-preview';
        iframe.title = target + ' — 预载画面';
        iframe.tabIndex = -1;
        iframe.setAttribute('aria-hidden', 'true');
        iframe.setAttribute('loading', 'eager');
        iframe.style.clipPath = startClip;

        warmPromise = new Promise(resolve => {
            let settled = false;
            let preparing = false;
            let pollHandle = 0;
            function done(ok) {
                if (settled) return;
                settled = true;
                if (pollHandle) clearInterval(pollHandle);
                loaded = ok;
                if (!ok) console.warn('[KuranokaPageTransition] Preview unavailable:', previewIssue || 'unknown');
                resolve(ok);
            }

            // 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;
                let doc;
                try {
                    doc = iframe.contentDocument;
                    if (!doc || !doc.querySelector('#mw-content-text, .mw-parser-output')) return;
                    preparing = true;
                    clearInterval(pollHandle);
                    // Allow background JavaScript / async MediaWiki styles to run.
                    await pause(200);
                    const fonts = doc.fonts && doc.fonts.ready;
                    if (fonts) await Promise.race([fonts.catch(() => {}), pause(1300)]);
                    await Promise.race([warmBackgrounds(doc), pause(2500)]);
                    await pause(170);
                    await Promise.race([warmBackgrounds(doc), pause(1500)]);
                    iframe.contentWindow.scrollTo(0, 0);
                    done(true);
                } catch (err) {
                    previewIssue = String(err);
                    console.warn('[KuranokaPageTransition] Unable to pre-render the next page:', err);
                    done(false);
                }
            }

            iframe.addEventListener('load', () => {
                if (settled || preparing) return;
                checkArticleReady();
                // The load event fired, but it is not a MediaWiki article.
                setTimeout(() => {
                    if (settled || preparing) return;
                    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(() => {});
        setTimeout(() => warmBackgrounds(document).catch(() => {}), 500);
        return warmPromise;
    }

    function makeSprite(stops) {
        const canvas = document.createElement('canvas');
        canvas.width = canvas.height = 96;
        const c = canvas.getContext('2d');
        const g = c.createRadialGradient(48, 48, 0, 48, 48, 48);
        stops.forEach(stop => g.addColorStop(stop[0], stop[1]));
        c.fillStyle = g;
        c.fillRect(0, 0, 96, 96);
        return canvas;
    }

    const SPRITES = {
        fire: makeSprite([[0, '#ffffff'], [.11, '#fff6b3'], [.35, '#ffb71e'], [.68, '#f7330988'], [1, '#a3000000']]),
        spark: makeSprite([[0, '#ffffff'], [.16, '#fff3b8'], [.48, '#ff7900bb'], [1, '#ff230000']]),
        smoke: makeSprite([[0, '#170c0de0'], [.36, '#201b20a1'], [.72, '#302b3260'], [1, '#11111100']]),
        ash: makeSprite([[0, '#d2b2a9'], [.28, '#897471bb'], [1, '#55555500']]),
        sky: makeSprite([[0, '#ffffff'], [.16, '#eeffff'], [.44, '#79d5ffbb'], [1, '#3c92ff00']])
    };

    function runAnimation() {
        return new Promise(resolve => {
            const canvas = document.createElement('canvas');
            canvas.id = 'kkpt-canvas';
            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;
            function resize() {
                width = window.innerWidth;
                height = window.innerHeight;
                const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.6);
                canvas.width = Math.round(width * pixelRatio);
                canvas.height = Math.round(height * pixelRatio);
                ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
            }
            resize();

            const particles = [];
            const shockwaves = [];
            const maxParticles = width < 700 ? 580 : 1100;
            const start = performance.now();
            let previous = start;
            let burstClock = 0;

            function addParticle(x, y) {
                if (particles.length >= maxParticles) return;
                const r = Math.random();
                const type = burn
                    ? (r < .49 ? 'fire' : r < .70 ? 'spark' : r < .90 ? 'smoke' : 'ash')
                    : (r < .68 ? 'sky' : r < .85 ? 'spark' : 'smoke');
                const isSmoke = type === 'smoke';
                const isDust = type === 'ash';
                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)
                });
            }

            function paint(now) {
                const dt = Math.min(.05, Math.max(.001, (now - previous) / 1000));
                previous = now;
                const progress = Math.min(1, (now - start) / DURATION);
                const ease = smooth(progress);
                const wobble = Math.pow(Math.sin(Math.PI * progress), .7);
                const direction = burn ? -1 : 1;
                const center = burn
                    ? width + 120 - (width + 260) * ease
                    : -120 + (width + 260) * ease;

                function edge(y) {
                    return center + wobble * (
                        32 * Math.sin(y * .012 + now * .0037) +
                        21 * Math.sin(y * .035 - now * .0061) +
                        12 * Math.sin(y * .079 + now * .0107) +
                         7 * Math.sin(y * .156 - now * .014)
                    );
                }

                const pts = [];
                const count = Math.max(35, Math.ceil(height / 17));
                for (let i = 0; i <= count; i++) {
                    const y = height * i / count;
                    pts.push([edge(y), y]);
                }
                const pathToEdge = pts.slice().reverse().map(p =>
                    p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'
                ).join(',');
                iframe.style.clipPath = burn
                    ? 'polygon(100% 0,100% 100%,' + pathToEdge + ')'
                    : 'polygon(0 0,0 100%,' + pathToEdge + ')';

                ctx.clearRect(0, 0, width, height);

                // Flare spreading over the whole screen makes this feel like
                // the world's light changes, rather than a moving divider.
                const flash = .025 * (1 + Math.sin(now * .048)) +
                    .07 * Math.pow(Math.max(0, Math.sin(now * .009)), 12);
                ctx.fillStyle = burn
                    ? 'rgba(108,13,5,' + (flash * wobble).toFixed(3) + ')'
                    : 'rgba(48,135,205,' + (flash * wobble).toFixed(3) + ')';
                ctx.fillRect(0, 0, width, height);

                function trace(offset) {
                    ctx.beginPath();
                    pts.forEach((p, i) => {
                        if (i === 0) ctx.moveTo(p[0] + offset, p[1]);
                        else ctx.lineTo(p[0] + offset, p[1]);
                    });
                }

                // A broad charred-paper zone is consumed BEFORE the bright core.
                // Its gradient is on the remaining old page side of the flame.
                ctx.save();
                const x0 = burn ? -133 : 133;
                const x1 = burn ? 18 : -18;
                const dark = ctx.createLinearGradient(center + x0, 0, center + x1, 0);
                if (burn) {
                    dark.addColorStop(0, '#130d0d00');
                    dark.addColorStop(.35, '#150c0eaa');
                    dark.addColorStop(.70, '#10080ce9');
                    dark.addColorStop(.91, '#48170df5');
                    dark.addColorStop(1, '#fa42156a');
                } else {
                    dark.addColorStop(0, '#0b1f3100');
                    dark.addColorStop(.35, '#052744aa');
                    dark.addColorStop(.7, '#041c35e9');
                    dark.addColorStop(.91, '#215b8bf5');
                    dark.addColorStop(1, '#8ce8ff77');
                }
                ctx.beginPath();
                pts.forEach((p, i) => {
                    const jitter = 10 * Math.sin(p[1] * .11 + now * .007);
                    const x = p[0] + x0 + jitter;
                    if (i === 0) ctx.moveTo(x, p[1]);
                    else ctx.lineTo(x, p[1]);
                });
                pts.slice().reverse().forEach(p => ctx.lineTo(p[0] + x1, p[1]));
                ctx.closePath();
                ctx.fillStyle = dark;
                ctx.fill();
                ctx.restore();

                // Smoke is below the light-emitting flames.
                ctx.save();
                for (let i = particles.length - 1; i >= 0; i--) {
                    const p = particles[i];
                    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.
                // They flicker independently instead of making a tidy wall.
                if (burn) {
                    ctx.save();
                    for (let y = 38; y < height; y += 103) {
                        const a = edge(y), skew = Math.sin(y * .13 + now * .0017);
                        ctx.lineWidth = 1.6;
                        ctx.strokeStyle = 'rgba(38,6,4,.8)';
                        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.
                ctx.save();
                ctx.lineJoin = 'round';
                ctx.lineCap = 'round';
                ctx.shadowColor = burn ? '#fb2509' : '#86e6ff';
                ctx.shadowBlur = burn ? 79 : 88;
                trace(0);
                ctx.strokeStyle = burn ? 'rgba(242,35,6,.6)' : 'rgba(74,168,255,.65)';
                ctx.lineWidth = burn ? 65 : 73;
                ctx.stroke();
                ctx.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
                // old Kuranoka; on the way back, electric shards tear right.
                ctx.save();
                ctx.globalCompositeOperation = 'lighter';
                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)';
                    ctx.beginPath();
                    ctx.moveTo(x - lean * .05, y + 19);
                    ctx.bezierCurveTo(x + lean * .14, y - 18,
                        x + lean * .88, y - lift * .47, x + lean, y - lift);
                    ctx.bezierCurveTo(x + lean * .35, y - lift * .53,
                        x - lean * .13, y + 13, x + 16, y + 27);
                    ctx.closePath();
                    ctx.fill();

                    ctx.fillStyle = burn ? 'rgba(255,231,89,.47)' : 'rgba(222,250,255,.52)';
                    ctx.beginPath();
                    ctx.moveTo(x + 4, y + 11);
                    ctx.quadraticCurveTo(x + lean * .32, y - lift * .31,
                        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;
                for (let i = 0; i < density; i++) {
                    const y = rand(0, height);
                    addParticle(edge(y), y);
                }

                ctx.save();
                ctx.globalCompositeOperation = 'lighter';
                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);
        });
    }

    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
        // frames. On navigation, native cross-document View Transition takes
        // its old snapshot from this already-revealed destination.
        button.classList.add('kkpt-vanish');
        await frame();
        await frame();
        await pause(90);
        window.location.assign(targetUrl);
    }

    function makeButton() {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
        button = document.createElement('a');
        button.id = 'kkpt-toggle';
        button.href = targetUrl;
        button.setAttribute('aria-label', burn ? '燃尽当前世界,进入 Kuronata' : '破晓,返回 Kuranoka');

        const icon = document.createElement('span');
        icon.className = 'kkpt-icon';
        icon.setAttribute('aria-hidden', 'true');
        icon.textContent = burn ? '✦' : '☀';
        const label = document.createElement('span');
        label.className = 'kkpt-text';
        const title = document.createElement('span');
        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);

        // Preload starts right now, not on click.
        createPreviewEarly();

        button.addEventListener('click', event => {
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
                event.metaKey || event.shiftKey || event.altKey) return;
            if (button.dataset.kkptFallback === '1') return; // normal navigation
            event.preventDefault();
            if (busy) return;
            busy = true;

            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
                window.location.assign(targetUrl);
                return;
            }
            begin().catch(err => {
                console.error('[KuranokaPageTransition] V3 error:', err);
                // Unexpected errors go to the real page, never a blank cover.
                window.location.assign(targetUrl);
            });
        });
    }

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

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