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

MediaWiki:KuranokaPageTransition.css:修订间差异

MediaWiki界面页面
 
无编辑摘要
第1行: 第1行:
/* ================================================================
/* ===============================================================
   KURANOKA / KURONATA — PAGE TRANSITION 1.0
   Kuranoka Page Transition v2 — REAL CANVAS FIRE
   Paste the ENTIRE file at the end of MediaWiki:Citizen.css.
   REPLACE MediaWiki:KuranokaPageTransition.js with this entire file.
   This does not change the Citizen glass layout or Stormbreak FX.
   No global styles. No external JavaScript dependencies.
   ================================================================ */
   =============================================================== */
(function () {
    'use strict';


/* Allow same-origin document navigation to retain old/new snapshots.
    if (window.__kkptV2 || window !== window.top) return;
  The default transition for unrelated Wiki pages stays very short. */
    window.__kkptV2 = true;
@view-transition {
    if (!window.mw || !mw.config) return;
     navigation: auto;
     if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;
}


::view-transition-old(root),
    const page = mw.config.get('wgPageName');
::view-transition-new(root) {
    if (page !== 'Kuranoka' && page !== 'Kuronata') return;
    animation-duration: 0.16s;
}


/* Button: mounted by MediaWiki:PageTransition.js. */
     const burn = page === 'Kuranoka';
#kkpt-toggle {
     const target = burn ? 'Kuronata' : 'Kuranoka';
     --kkpt-accent: #94dcff;
     const targetUrl = '/wiki/' + encodeURIComponent(target);
     --kkpt-ink: #e8f8ff;
     const DURATION = burn ? 3450 : 2850;
     --kkpt-back: rgba(7, 26, 44, 0.76);
     let busy = false;
    position: fixed;
    right: clamp(12px, 2.3vw, 34px);
    bottom: clamp(14px, 3.4vh, 34px);
    z-index: 2147481000;
    display: flex;
    align-items: center;
    gap: 11px;
    box-sizing: border-box;
    max-width: min(340px, calc(100vw - 24px));
    padding: 11px 16px;
    border: 1px solid color-mix(in srgb, var(--kkpt-accent) 65%, transparent);
    border-radius: 999px;
    background: var(--kkpt-back);
    -webkit-backdrop-filter: blur(16px) saturate(145%);
    backdrop-filter: blur(16px) saturate(145%);
    color: var(--kkpt-ink) !important;
    font: 600 13px/1.35 system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
    letter-spacing: .035em;
    text-decoration: none !important;
    box-shadow: 0 5px 26px rgba(0, 0, 0, .22),
                0 0 22px color-mix(in srgb, var(--kkpt-accent) 15%, transparent);
     transition: transform .24s ease, border-color .24s ease,
                box-shadow .24s ease, background .24s ease;
     cursor: pointer;
    isolation: isolate;
}


#kkpt-toggle:hover,
    function makeButton() {
#kkpt-toggle:focus-visible {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
    transform: translateY(-3px);
        const button = document.createElement('a');
    border-color: var(--kkpt-accent);
        button.id = 'kkpt-toggle';
    box-shadow: 0 8px 30px rgba(0, 0, 0, .34),
        button.href = targetUrl;
                0 0 30px color-mix(in srgb, var(--kkpt-accent) 38%, transparent);
        button.setAttribute('aria-label', burn ? '焚毁当前世界,切换到 Kuronata' : '破晓,返回 Kuranoka');
}


#kkpt-toggle:focus-visible {
        const icon = document.createElement('span');
    outline: 2px solid var(--kkpt-accent);
        icon.className = 'kkpt-icon';
    outline-offset: 4px;
        icon.setAttribute('aria-hidden', 'true');
}
        icon.textContent = burn ? '✦' : '☀';


#kkpt-toggle .kkpt-mark {
        const label = document.createElement('span');
    display: grid;
        label.className = 'kkpt-text';
    place-items: center;
        const title = document.createElement('span');
    width: 25px;
        title.textContent = target.toUpperCase();
    height: 25px;
        const sub = document.createElement('span');
    flex: none;
        sub.className = 'kkpt-sub';
    border-radius: 50%;
        sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
    background: color-mix(in srgb, var(--kkpt-accent) 18%, transparent);
        label.append(title, sub);
    color: var(--kkpt-accent);
        button.append(icon, label);
    font-size: 17px;
        document.body.append(button);
    font-weight: 500;
}


#kkpt-toggle .kkpt-copy { display: flex; flex-direction: column; gap: 1px; }
        button.addEventListener('click', function (event) {
#kkpt-toggle .kkpt-title { font-size: 13px; font-weight: 700; }
            // Preserve opening in a new tab, modifiers, and browser behavior.
#kkpt-toggle .kkpt-subtitle { font-size: 10px; font-weight: 500; opacity: .72; }
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey ||
                event.shiftKey || event.altKey) return;
            event.preventDefault();
            if (busy) return;
            busy = true;
            button.classList.add('kkpt-loading');


body.page-Kuronata #kkpt-toggle {
            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    --kkpt-accent: #ff8898;
                window.location.assign(targetUrl);
    --kkpt-ink: #fff2f3;
                return;
    --kkpt-back: rgba(60, 4, 19, .79);
            }
}
            begin().catch(function (err) {
                console.error('[KuranokaPageTransition] Transition failed:', err);
                window.location.assign(targetUrl);
            });
        });
    }


/* --------------------------------------------------------------
    // Use an actual same-origin rendering of the destination as the layer
  ENTER KURONATA — consume the old page from the right edge.
    // that shows through the burnt holes. If framing is blocked, fall back
  The new page is already painted beneath the old page snapshot.
    // to a solid atmospheric color while retaining the fire animation.
  -------------------------------------------------------------- */
    function preparePreview() {
html:has(body.page-Kuronata)::view-transition-group(root),
        return new Promise(function (resolve) {
html:has(body.page-Kuranoka)::view-transition-group(root) {
            const iframe = document.createElement('iframe');
    animation-duration: 2.7s;
            iframe.id = 'kkpt-preview';
}
            iframe.className = burn ? 'kkpt-blood' : 'kkpt-sky';
            iframe.title = 'Transition preview';
            iframe.tabIndex = -1;
            iframe.setAttribute('aria-hidden', 'true');
            iframe.style.clipPath = burn
                ? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
                : 'polygon(0 0,0 100%,0 100%,0 0)';


html:has(body.page-Kuronata)::view-transition-old(root) {
            let completed = false;
    z-index: 2;
            const finish = function (success) {
    animation: kkpt-burn 2.7s linear both;
                if (completed) return;
    transform-origin: center;
                completed = true;
}
                clearTimeout(timeout);
                if (success) {
                    try { iframe.contentWindow.scrollTo(0, 0); } catch (e) { /* ignore */ }
                    resolve(iframe);
                } else {
                    iframe.remove();
                    const fallback = document.createElement('div');
                    fallback.id = 'kkpt-preview';
                    fallback.className = burn ? 'kkpt-blood' : 'kkpt-sky';
                    fallback.style.clipPath = burn
                        ? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
                        : 'polygon(0 0,0 100%,0 100%,0 0)';
                    document.body.append(fallback);
                    resolve(fallback);
                }
            };


html:has(body.page-Kuronata)::view-transition-new(root) {
            const timeout = setTimeout(function () { finish(false); }, 3500);
    z-index: 1;
            iframe.addEventListener('load', function () {
    animation: kkpt-blood-arrive 2.7s linear both;
                let valid = false;
}
                try {
                    valid = Boolean(iframe.contentDocument &&
                        iframe.contentDocument.querySelector('#mw-content-text'));
                } catch (e) { valid = false; }
                finish(valid);
            }, { once: true });
            iframe.addEventListener('error', function () { finish(false); }, { once: true });
            document.body.append(iframe);
            iframe.src = targetUrl;
        });
    }


@keyframes kkpt-burn {
    function createSprite(stops) {
    0% {
        const canvas = document.createElement('canvas');
         clip-path: polygon(0 0, 100% 0, 100% 10%, 100% 20%, 100% 30%, 100% 40%, 100% 50%, 100% 60%, 100% 70%, 100% 80%, 100% 90%, 100% 100%, 0 100%);
        canvas.width = canvas.height = 96;
         filter: brightness(1) saturate(1) drop-shadow(0 0 0 transparent);
        const c = canvas.getContext('2d');
         const gradient = c.createRadialGradient(48, 48, 0, 48, 48, 48);
         stops.forEach(function (stop) { gradient.addColorStop(stop[0], stop[1]); });
        c.fillStyle = gradient;
        c.fillRect(0, 0, 96, 96);
        return canvas;
     }
     }
    10% {
        clip-path: polygon(0 0, 99% 0, 97% 10%, 93% 20%, 98% 30%, 95% 40%, 91% 50%, 97% 60%, 93% 70%, 98% 80%, 94% 90%, 97% 100%, 0 100%);
        filter: brightness(.89) sepia(.12) drop-shadow(10px 0 16px #ff772c);
    }
    29% {
        clip-path: polygon(0 0, 77% 0, 71% 10%, 78% 20%, 69% 30%, 73% 40%, 65% 50%, 75% 60%, 69% 70%, 74% 80%, 68% 90%, 72% 100%, 0 100%);
        filter: brightness(.78) sepia(.4) drop-shadow(12px 0 23px #ff360c);
    }
    49% {
        clip-path: polygon(0 0, 52% 0, 45% 10%, 53% 20%, 43% 30%, 50% 40%, 39% 50%, 47% 60%, 42% 70%, 50% 80%, 40% 90%, 46% 100%, 0 100%);
        filter: brightness(.64) sepia(.65) drop-shadow(11px 0 28px #ff3a10);
    }
    71% {
        clip-path: polygon(0 0, 27% 0, 20% 10%, 25% 20%, 15% 30%, 23% 40%, 11% 50%, 22% 60%, 17% 70%, 25% 80%, 14% 90%, 20% 100%, 0 100%);
        filter: brightness(.41) sepia(.85) drop-shadow(10px 0 28px #ff260b);
    }
    91% {
        clip-path: polygon(0 0, 5% 0, -1% 10%, 6% 20%, -4% 30%, 3% 40%, -7% 50%, 4% 60%, -2% 70%, 6% 80%, -5% 90%, 2% 100%, 0 100%);
        filter: brightness(.3) sepia(1) drop-shadow(9px 0 17px #ff3b15);
    }
    100% {
        clip-path: polygon(-14% 0, -14% 0, -14% 10%, -14% 20%, -14% 30%, -14% 40%, -14% 50%, -14% 60%, -14% 70%, -14% 80%, -14% 90%, -14% 100%, -14% 100%);
        filter: brightness(.2) sepia(1) drop-shadow(0 0 0 transparent);
    }
}


@keyframes kkpt-blood-arrive {
    const SPRITES = {
    0%, 12% { filter: brightness(.23) saturate(1.7) sepia(.33); }
        flame: createSprite([[0, '#fffadc'], [.14, '#ffeb75'], [.43, '#ff7a17'], [.72, '#eb230b88'], [1, '#9a000000']]),
    35% { filter: brightness(.52) saturate(1.4) sepia(.15); }
        ember: createSprite([[0, '#ffffff'], [.18, '#fff5af'], [.52, '#ff8500aa'], [1, '#ff330000']]),
    72% { filter: brightness(.9) saturate(1.1); }
        smoke: createSprite([[0, '#160b0acc'], [.4, '#24171977'], [1, '#11111100']]),
    100% { filter: brightness(1) saturate(1) sepia(0); }
        blue: createSprite([[0, '#ffffff'], [.18, '#e6ffff'], [.47, '#75ccffad'], [1, '#55b2ff00']])
}
    };
 
    function runAnimation(preview) {
        return new Promise(function (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;
            let height = 0;
            let ratio = 1;
            function size() {
                width = window.innerWidth;
                height = window.innerHeight;
                ratio = Math.min(window.devicePixelRatio || 1, 1.7);
                canvas.width = Math.round(width * ratio);
                canvas.height = Math.round(height * ratio);
                ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
            }
            size();
 
            const particles = [];
            const rand = function (a, b) { return a + Math.random() * (b - a); };
            const smooth = function (x) { return x * x * (3 - 2 * x); };
            let previous = performance.now();
            let start = previous;
            let done = false;
 
            function spawn(x, y) {
                if (particles.length > 1300) return;
                const r = Math.random();
                const kind = burn ? (r < .64 ? 'flame' : r < .84 ? 'ember' : 'smoke') : 'blue';
                const life = kind === 'smoke' ? rand(.6, 1.4) : kind === 'ember' ? rand(.42, 1.1) : rand(.28, .75);
                particles.push({
                    kind: kind,
                    x: x + rand(-13, 13),
                    y: y + rand(-14, 14),
                    vx: rand(burn ? -125 : -55, burn ? 55 : 85),
                    vy: rand(-185, -35),
                    life: life,
                    remaining: life,
                    radius: kind === 'smoke' ? rand(32, 85) : kind === 'ember' ? rand(3, 12) : rand(16, 47),
                    angle: rand(-.4, .4)
                });
            }
 
            function render(now) {
                if (done) return;
                const delta = Math.min(.05, Math.max(0, (now - previous) / 1000));
                previous = now;
                const raw = Math.min(1, (now - start) / DURATION);
                const eased = smooth(raw);
                const center = burn
                    ? (width + 100) - (width + 200) * eased
                    : -100 + (width + 200) * eased;
                const waviness = Math.sin(Math.PI * raw);
 
                function edgeX(y) {
                    return center + waviness * (
                        16 * Math.sin(y * .023 + now * .0055) +
                        9 * Math.sin(y * .063 - now * .009) +
                        5 * Math.sin(y * .13 + now * .012)
                    );
                }
 
                const pts = [];
                const count = Math.max(28, Math.ceil(height / 25));
                for (let i = 0; i <= count; i++) {
                    const y = height * i / count;
                    pts.push([edgeX(y), y]);
                }
                const reverse = pts.slice().reverse().map(function (p) {
                    return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px';
                }).join(',');
                preview.style.clipPath = burn
                    ? 'polygon(100% 0,100% 100%,' + reverse + ')'
                    : 'polygon(0 0,0 100%,' + reverse + ')';
 
                ctx.clearRect(0, 0, width, height);
 
                // Smoke first, so bright fire and sparks appear in front.
                ctx.globalCompositeOperation = 'source-over';
                for (let i = particles.length - 1; i >= 0; i--) {
                    const p = particles[i];
                    p.remaining -= delta;
                    if (p.remaining <= 0) { particles.splice(i, 1); continue; }
                    p.x += p.vx * delta;
                    p.y += p.vy * delta;
                    p.vy -= 18 * delta;
                    if (p.kind !== 'smoke') continue;
                    const opacity = Math.min(1, p.remaining / .35) * .42;
                    const size = p.radius * (1 + (1 - p.remaining / p.life) * .8);
                    ctx.globalAlpha = opacity;
                    ctx.drawImage(SPRITES.smoke, p.x - size, p.y - size, size * 2, size * 2);
                }
                ctx.globalAlpha = 1;
 
                // The irregular, continuously moving combustion front.
                ctx.beginPath();
                pts.forEach(function (p, i) {
                    if (i === 0) ctx.moveTo(p[0], p[1]);
                    else ctx.lineTo(p[0], p[1]);
                });
                ctx.lineJoin = 'round';
                ctx.lineCap = 'round';
                ctx.shadowColor = burn ? '#ff3905' : '#b1eaff';
                ctx.shadowBlur = burn ? 48 : 55;
                ctx.strokeStyle = burn ? 'rgba(255,41,5,.82)' : 'rgba(160,227,255,.72)';
                ctx.lineWidth = burn ? 29 : 35;
                ctx.stroke();
                ctx.shadowBlur = 21;
                ctx.strokeStyle = burn ? '#ff9d1c' : '#e5fbff';
                ctx.lineWidth = burn ? 9 : 11;
                ctx.stroke();
                ctx.shadowBlur = 11;
                ctx.strokeStyle = '#fff9e2';
                ctx.lineWidth = 2.5;
                ctx.stroke();
                ctx.shadowBlur = 0;
 
                // Actual flame tongues curling into the page that is burning.
                // Layered Bezier shapes prevent this from looking like a flat wipe.
                if (burn) {
                    ctx.save();
                    ctx.globalCompositeOperation = 'lighter';
                    for (let y = -20; y < height + 45; y += 31) {
                        const x = edgeX(y);
                        const flicker = Math.sin(now * .014 + y * .19);
                        const lift = 24 + 29 * (1 + flicker) + 22 * Math.abs(Math.sin(now * .006 - y * .1));
                        const lean = 15 + 12 * Math.sin(now * .009 + y * .075);
                        ctx.fillStyle = 'rgba(255,77,5,.39)';
                        ctx.beginPath();
                        ctx.moveTo(x + 13, y + 15);
                        ctx.bezierCurveTo(x - 17, y - 2, x - lean - 25, y - lift * .62, x - lean, y - lift);
                        ctx.bezierCurveTo(x - 2, y - lift * .56, x + 11, y - 13, x + 17, y + 15);
                        ctx.closePath();
                        ctx.fill();
 
                        ctx.fillStyle = 'rgba(255,220,91,.43)';
                        ctx.beginPath();
                        ctx.moveTo(x + 3, y + 7);
                        ctx.quadraticCurveTo(x - 10, y - lift * .28, x - lean * .6, y - lift * .68);
                        ctx.quadraticCurveTo(x + 3, y - lift * .3, x + 7, y + 7);
                        ctx.closePath();
                        ctx.fill();
                    }
                    ctx.restore();
                }


/* --------------------------------------------------------------
                // Live flame tongues / sparks, not an animated screenshot.
  ENTER KURANOKA — a white-blue break of dawn from the left.
                const amount = Math.max(14, Math.min(34, Math.round(height / 41)));
  -------------------------------------------------------------- */
                for (let i = 0; i < amount; i++) {
html:has(body.page-Kuranoka)::view-transition-old(root) {
                    const y = rand(0, height);
    z-index: 2;
                    spawn(edgeX(y), y);
    animation: kkpt-dawn 2.45s linear both;
                }
}


html:has(body.page-Kuranoka)::view-transition-new(root) {
                ctx.globalCompositeOperation = 'lighter';
    z-index: 1;
                for (let i = 0; i < particles.length; i++) {
    animation: kkpt-sky-arrive 2.45s ease-out both;
                    const p = particles[i];
}
                    if (p.kind === 'smoke') continue;
                    const stage = 1 - p.remaining / p.life;
                    const fade = Math.min(1, p.remaining * 3) * (1 - stage * .5);
                    ctx.save();
                    ctx.globalAlpha = fade * (p.kind === 'ember' ? .85 : .8);
                    ctx.translate(p.x, p.y);
                    ctx.rotate(p.angle);
                    const sprite = p.kind === 'blue' ? SPRITES.blue :
                        p.kind === 'ember' ? SPRITES.ember : SPRITES.flame;
                    const sx = p.radius * (p.kind === 'flame' ? .9 : 1);
                    const sy = p.radius * (p.kind === 'flame' ? 1.7 : 1);
                    ctx.drawImage(sprite, -sx, -sy, sx * 2, sy * 2);
                    ctx.restore();
                }
                ctx.globalAlpha = 1;
                ctx.globalCompositeOperation = 'source-over';


@keyframes kkpt-dawn {
                if (raw < 1) {
    0% {
                    requestAnimationFrame(render);
        clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 90%, 0 80%, 0 70%, 0 60%, 0 50%, 0 40%, 0 30%, 0 20%, 0 10%);
                } else {
        filter: brightness(1) drop-shadow(0 0 0 transparent);
                    done = true;
                    preview.style.clipPath = 'none';
                    setTimeout(resolve, 110);
                }
            }
            requestAnimationFrame(render);
        });
     }
     }
     25% {
 
         clip-path: polygon(18% 0, 100% 0, 100% 100%, 21% 100%, 25% 90%, 20% 80%, 28% 70%, 23% 60%, 31% 50%, 22% 40%, 29% 30%, 19% 20%, 24% 10%);
     function handoff() {
         filter: brightness(1.25) drop-shadow(-9px 0 23px #b0eaff);
         // A very short opaque frame hides the last real document navigation.
        // The preview is already the actual target URL, not mock Wiki text.
        const cover = document.createElement('div');
        cover.id = 'kkpt-handoff';
        if (!burn) cover.className = 'kkpt-sky';
        document.body.append(cover);
         requestAnimationFrame(function () {
            cover.style.opacity = '1';
            setTimeout(function () { window.location.assign(targetUrl); }, 200);
        });
     }
     }
     55% {
 
        clip-path: polygon(56% 0, 100% 0, 100% 100%, 60% 100%, 65% 90%, 58% 80%, 69% 70%, 61% 60%, 72% 50%, 62% 40%, 68% 30%, 57% 20%, 64% 10%);
     async function begin() {
        filter: brightness(1.65) drop-shadow(-12px 0 30px #f1fbff);
         const preview = await preparePreview();
    }
         await runAnimation(preview);
    84% {
         handoff();
         clip-path: polygon(89% 0, 100% 0, 100% 100%, 94% 100%, 101% 90%, 93% 80%, 103% 70%, 95% 60%, 107% 50%, 96% 40%, 104% 30%, 91% 20%, 99% 10%);
         filter: brightness(1.85) drop-shadow(-9px 0 19px #d0f6ff);
    }
    100% {
         clip-path: polygon(115% 0, 115% 0, 115% 100%, 115% 100%, 115% 90%, 115% 80%, 115% 70%, 115% 60%, 115% 50%, 115% 40%, 115% 30%, 115% 20%, 115% 10%);
        filter: brightness(1) drop-shadow(0 0 0 transparent);
     }
     }
}
@keyframes kkpt-sky-arrive {
    0%, 17% { filter: brightness(1.65) saturate(.7); }
    51% { filter: brightness(1.22) saturate(.95); }
    100% { filter: brightness(1) saturate(1); }
}


@media (max-width: 600px) {
    if (document.readyState === 'loading') {
    #kkpt-toggle { padding: 10px 13px; gap: 8px; }
        document.addEventListener('DOMContentLoaded', makeButton, { once: true });
    #kkpt-toggle .kkpt-subtitle { display: none; }
     } else {
}
        makeButton();
 
@media (prefers-reduced-motion: reduce) {
    html:has(body.page-Kuronata)::view-transition-group(root),
    html:has(body.page-Kuranoka)::view-transition-group(root),
    html:has(body.page-Kuronata)::view-transition-old(root),
     html:has(body.page-Kuranoka)::view-transition-old(root),
    html:has(body.page-Kuronata)::view-transition-new(root),
    html:has(body.page-Kuranoka)::view-transition-new(root) {
        animation-duration: .01ms !important;
     }
     }
    #kkpt-toggle { transition: none; }
}());
}
 
@media print { #kkpt-toggle { display: none !important; } }

2026年10月23日 (五) 11:39的版本

/* ===============================================================
   Kuranoka Page Transition v2 — REAL CANVAS FIRE
   REPLACE MediaWiki:KuranokaPageTransition.js with this entire file.
   No global styles. No external JavaScript dependencies.
   =============================================================== */
(function () {
    'use strict';

    if (window.__kkptV2 || window !== window.top) return;
    window.__kkptV2 = true;
    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 ? 3450 : 2850;
    let busy = false;

    function makeButton() {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
        const 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);

        button.addEventListener('click', function (event) {
            // Preserve opening in a new tab, modifiers, and browser behavior.
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey ||
                event.shiftKey || event.altKey) return;
            event.preventDefault();
            if (busy) return;
            busy = true;
            button.classList.add('kkpt-loading');

            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
                window.location.assign(targetUrl);
                return;
            }
            begin().catch(function (err) {
                console.error('[KuranokaPageTransition] Transition failed:', err);
                window.location.assign(targetUrl);
            });
        });
    }

    // Use an actual same-origin rendering of the destination as the layer
    // that shows through the burnt holes. If framing is blocked, fall back
    // to a solid atmospheric color while retaining the fire animation.
    function preparePreview() {
        return new Promise(function (resolve) {
            const iframe = document.createElement('iframe');
            iframe.id = 'kkpt-preview';
            iframe.className = burn ? 'kkpt-blood' : 'kkpt-sky';
            iframe.title = 'Transition preview';
            iframe.tabIndex = -1;
            iframe.setAttribute('aria-hidden', 'true');
            iframe.style.clipPath = burn
                ? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
                : 'polygon(0 0,0 100%,0 100%,0 0)';

            let completed = false;
            const finish = function (success) {
                if (completed) return;
                completed = true;
                clearTimeout(timeout);
                if (success) {
                    try { iframe.contentWindow.scrollTo(0, 0); } catch (e) { /* ignore */ }
                    resolve(iframe);
                } else {
                    iframe.remove();
                    const fallback = document.createElement('div');
                    fallback.id = 'kkpt-preview';
                    fallback.className = burn ? 'kkpt-blood' : 'kkpt-sky';
                    fallback.style.clipPath = burn
                        ? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
                        : 'polygon(0 0,0 100%,0 100%,0 0)';
                    document.body.append(fallback);
                    resolve(fallback);
                }
            };

            const timeout = setTimeout(function () { finish(false); }, 3500);
            iframe.addEventListener('load', function () {
                let valid = false;
                try {
                    valid = Boolean(iframe.contentDocument &&
                        iframe.contentDocument.querySelector('#mw-content-text'));
                } catch (e) { valid = false; }
                finish(valid);
            }, { once: true });
            iframe.addEventListener('error', function () { finish(false); }, { once: true });
            document.body.append(iframe);
            iframe.src = targetUrl;
        });
    }

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

    const SPRITES = {
        flame: createSprite([[0, '#fffadc'], [.14, '#ffeb75'], [.43, '#ff7a17'], [.72, '#eb230b88'], [1, '#9a000000']]),
        ember: createSprite([[0, '#ffffff'], [.18, '#fff5af'], [.52, '#ff8500aa'], [1, '#ff330000']]),
        smoke: createSprite([[0, '#160b0acc'], [.4, '#24171977'], [1, '#11111100']]),
        blue: createSprite([[0, '#ffffff'], [.18, '#e6ffff'], [.47, '#75ccffad'], [1, '#55b2ff00']])
    };

    function runAnimation(preview) {
        return new Promise(function (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;
            let height = 0;
            let ratio = 1;
            function size() {
                width = window.innerWidth;
                height = window.innerHeight;
                ratio = Math.min(window.devicePixelRatio || 1, 1.7);
                canvas.width = Math.round(width * ratio);
                canvas.height = Math.round(height * ratio);
                ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
            }
            size();

            const particles = [];
            const rand = function (a, b) { return a + Math.random() * (b - a); };
            const smooth = function (x) { return x * x * (3 - 2 * x); };
            let previous = performance.now();
            let start = previous;
            let done = false;

            function spawn(x, y) {
                if (particles.length > 1300) return;
                const r = Math.random();
                const kind = burn ? (r < .64 ? 'flame' : r < .84 ? 'ember' : 'smoke') : 'blue';
                const life = kind === 'smoke' ? rand(.6, 1.4) : kind === 'ember' ? rand(.42, 1.1) : rand(.28, .75);
                particles.push({
                    kind: kind,
                    x: x + rand(-13, 13),
                    y: y + rand(-14, 14),
                    vx: rand(burn ? -125 : -55, burn ? 55 : 85),
                    vy: rand(-185, -35),
                    life: life,
                    remaining: life,
                    radius: kind === 'smoke' ? rand(32, 85) : kind === 'ember' ? rand(3, 12) : rand(16, 47),
                    angle: rand(-.4, .4)
                });
            }

            function render(now) {
                if (done) return;
                const delta = Math.min(.05, Math.max(0, (now - previous) / 1000));
                previous = now;
                const raw = Math.min(1, (now - start) / DURATION);
                const eased = smooth(raw);
                const center = burn
                    ? (width + 100) - (width + 200) * eased
                    : -100 + (width + 200) * eased;
                const waviness = Math.sin(Math.PI * raw);

                function edgeX(y) {
                    return center + waviness * (
                        16 * Math.sin(y * .023 + now * .0055) +
                         9 * Math.sin(y * .063 - now * .009) +
                         5 * Math.sin(y * .13 + now * .012)
                    );
                }

                const pts = [];
                const count = Math.max(28, Math.ceil(height / 25));
                for (let i = 0; i <= count; i++) {
                    const y = height * i / count;
                    pts.push([edgeX(y), y]);
                }
                const reverse = pts.slice().reverse().map(function (p) {
                    return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px';
                }).join(',');
                preview.style.clipPath = burn
                    ? 'polygon(100% 0,100% 100%,' + reverse + ')'
                    : 'polygon(0 0,0 100%,' + reverse + ')';

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

                // Smoke first, so bright fire and sparks appear in front.
                ctx.globalCompositeOperation = 'source-over';
                for (let i = particles.length - 1; i >= 0; i--) {
                    const p = particles[i];
                    p.remaining -= delta;
                    if (p.remaining <= 0) { particles.splice(i, 1); continue; }
                    p.x += p.vx * delta;
                    p.y += p.vy * delta;
                    p.vy -= 18 * delta;
                    if (p.kind !== 'smoke') continue;
                    const opacity = Math.min(1, p.remaining / .35) * .42;
                    const size = p.radius * (1 + (1 - p.remaining / p.life) * .8);
                    ctx.globalAlpha = opacity;
                    ctx.drawImage(SPRITES.smoke, p.x - size, p.y - size, size * 2, size * 2);
                }
                ctx.globalAlpha = 1;

                // The irregular, continuously moving combustion front.
                ctx.beginPath();
                pts.forEach(function (p, i) {
                    if (i === 0) ctx.moveTo(p[0], p[1]);
                    else ctx.lineTo(p[0], p[1]);
                });
                ctx.lineJoin = 'round';
                ctx.lineCap = 'round';
                ctx.shadowColor = burn ? '#ff3905' : '#b1eaff';
                ctx.shadowBlur = burn ? 48 : 55;
                ctx.strokeStyle = burn ? 'rgba(255,41,5,.82)' : 'rgba(160,227,255,.72)';
                ctx.lineWidth = burn ? 29 : 35;
                ctx.stroke();
                ctx.shadowBlur = 21;
                ctx.strokeStyle = burn ? '#ff9d1c' : '#e5fbff';
                ctx.lineWidth = burn ? 9 : 11;
                ctx.stroke();
                ctx.shadowBlur = 11;
                ctx.strokeStyle = '#fff9e2';
                ctx.lineWidth = 2.5;
                ctx.stroke();
                ctx.shadowBlur = 0;

                // Actual flame tongues curling into the page that is burning.
                // Layered Bezier shapes prevent this from looking like a flat wipe.
                if (burn) {
                    ctx.save();
                    ctx.globalCompositeOperation = 'lighter';
                    for (let y = -20; y < height + 45; y += 31) {
                        const x = edgeX(y);
                        const flicker = Math.sin(now * .014 + y * .19);
                        const lift = 24 + 29 * (1 + flicker) + 22 * Math.abs(Math.sin(now * .006 - y * .1));
                        const lean = 15 + 12 * Math.sin(now * .009 + y * .075);
                        ctx.fillStyle = 'rgba(255,77,5,.39)';
                        ctx.beginPath();
                        ctx.moveTo(x + 13, y + 15);
                        ctx.bezierCurveTo(x - 17, y - 2, x - lean - 25, y - lift * .62, x - lean, y - lift);
                        ctx.bezierCurveTo(x - 2, y - lift * .56, x + 11, y - 13, x + 17, y + 15);
                        ctx.closePath();
                        ctx.fill();

                        ctx.fillStyle = 'rgba(255,220,91,.43)';
                        ctx.beginPath();
                        ctx.moveTo(x + 3, y + 7);
                        ctx.quadraticCurveTo(x - 10, y - lift * .28, x - lean * .6, y - lift * .68);
                        ctx.quadraticCurveTo(x + 3, y - lift * .3, x + 7, y + 7);
                        ctx.closePath();
                        ctx.fill();
                    }
                    ctx.restore();
                }

                // Live flame tongues / sparks, not an animated screenshot.
                const amount = Math.max(14, Math.min(34, Math.round(height / 41)));
                for (let i = 0; i < amount; i++) {
                    const y = rand(0, height);
                    spawn(edgeX(y), y);
                }

                ctx.globalCompositeOperation = 'lighter';
                for (let i = 0; i < particles.length; i++) {
                    const p = particles[i];
                    if (p.kind === 'smoke') continue;
                    const stage = 1 - p.remaining / p.life;
                    const fade = Math.min(1, p.remaining * 3) * (1 - stage * .5);
                    ctx.save();
                    ctx.globalAlpha = fade * (p.kind === 'ember' ? .85 : .8);
                    ctx.translate(p.x, p.y);
                    ctx.rotate(p.angle);
                    const sprite = p.kind === 'blue' ? SPRITES.blue :
                        p.kind === 'ember' ? SPRITES.ember : SPRITES.flame;
                    const sx = p.radius * (p.kind === 'flame' ? .9 : 1);
                    const sy = p.radius * (p.kind === 'flame' ? 1.7 : 1);
                    ctx.drawImage(sprite, -sx, -sy, sx * 2, sy * 2);
                    ctx.restore();
                }
                ctx.globalAlpha = 1;
                ctx.globalCompositeOperation = 'source-over';

                if (raw < 1) {
                    requestAnimationFrame(render);
                } else {
                    done = true;
                    preview.style.clipPath = 'none';
                    setTimeout(resolve, 110);
                }
            }
            requestAnimationFrame(render);
        });
    }

    function handoff() {
        // A very short opaque frame hides the last real document navigation.
        // The preview is already the actual target URL, not mock Wiki text.
        const cover = document.createElement('div');
        cover.id = 'kkpt-handoff';
        if (!burn) cover.className = 'kkpt-sky';
        document.body.append(cover);
        requestAnimationFrame(function () {
            cover.style.opacity = '1';
            setTimeout(function () { window.location.assign(targetUrl); }, 200);
        });
    }

    async function begin() {
        const preview = await preparePreview();
        await runAnimation(preview);
        handoff();
    }

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