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

MediaWiki:KuranokaPageTransition.js

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

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

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
  • Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* ====================================================================
   KURANOKA / KURONATA V12 — CHAR / CRIMSON NIGHT
   MediaWiki:KuranokaPageTransition.js
   Same-origin iframe + history.pushState: NO full reload on toggles.
   Hard chiaroscuro: preserved sky / incandescent tearing edge /
   persistent burning wake / residual soot / torn paper / blood-red world.
   Wiki easter eggs: [[Kuronata|...]] / [[Kuranoka|...]].
   Backgrounds are prewarmed on page load. Zero external dependencies.
   ==================================================================== */
(function () {
    'use strict';

    // Kuronata alone uses Citizen's dark palette, even if the reader's
    // site-wide preference is Light. This changes this DOCUMENT only;
    // no cookies, localStorage or global site settings are touched.
    // Runs in the preview iframe too, before the iframe recursion guard.
    const pageName=window.mw&&mw.config&&mw.config.get('wgPageName');
    const ownPath=/\/wiki\/Kuronata\/?$/.test(location.pathname);
    if(pageName==='Kuronata'||ownPath){
        const root=document.documentElement;
        function enforceKuronataNight(){
            const wrong=['skin-theme-clientpref-day',
                'skin-theme-clientpref-os','skin-theme-clientpref-black',
                'skin-citizen-light'];
            if(!wrong.some(c=>root.classList.contains(c))&&
                root.classList.contains('skin-theme-clientpref-night')&&
                root.classList.contains('kkpt-kuronata-dark'))return;
            root.classList.remove('skin-theme-clientpref-day',
                'skin-theme-clientpref-os','skin-theme-clientpref-black',
                'skin-citizen-light');
            root.classList.add('skin-theme-clientpref-night','kkpt-kuronata-dark');
        }
        enforceKuronataNight();
        if(!window.__kkptNightGuard){
            window.__kkptNightGuard=true;
            new MutationObserver(enforceKuronataNight).observe(root,
                {attributes:true,attributeFilter:['class']});
        }
    }

    // The preview is a complete MediaWiki page. It must NOT spawn another.
    if (window !== window.top || window.__kkptV12) 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.__kkptV12 = true;
    window.__kkptVersion = '12.0';

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

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

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

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

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

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

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

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

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

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

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

    // The Infobox gear begins to turn before the actual world changes.
    function beginFateSwap(anchor,nextActive) {
        if(animating||gearPending||nextActive===active)return;
        gearPending=true;
        anchor.classList.add('kkpt-fate-turning');
        setTimeout(()=>{
            gearPending=false;
            requestSwap(nextActive);
        },570);
        setTimeout(()=>anchor.classList.remove('kkpt-fate-turning'),4800);
    }

    function fateGearShape() {
        const parts=[];
        for(let i=0;i<48;i++) {
            const a=i/48*Math.PI*2-Math.PI/2;
            const r=(i%4===0||i%4===3)?21:16.8;
            parts.push((i?'L':'M')+(24+Math.cos(a)*r).toFixed(2)+' '+
                (24+Math.sin(a)*r).toFixed(2));
        }
        return parts.join(' ')+' Z';
    }

    function mountFateGear(doc,targetPage) {
        if(doc.querySelector('.kkpt-fate-gear'))return;
        const content=doc.querySelector('#mw-content-text,.mw-parser-output');
        if(!content)return;
        const selectors='table.infobox,.infobox-character,.portable-infobox,'+
            '.infobox,[class*="infobox"]';
        const box=[...content.querySelectorAll(selectors)].find(el=>{
            const r=el.getBoundingClientRect();
            return r.width>175&&r.width<720&&r.height>210;
        });
        if(!box)return;
        const header=box.querySelector('.infobox-title,.infobox-header,'+
            '.infobox-above,.infobox-heading,.infobox-head,'+
            '.pi-title,caption,th[colspan]');
        // Deliberately NEVER append to or restyle a native Infobox node.
        // A fixed-position overlay follows its visual rectangle instead.
        const host=header&&header.tagName!=='TR'?header:box;
        const anchor=doc.createElement('a');
        anchor.className='kkpt-fate-gear';
        anchor.hidden=true;
        anchor.href=new URL('/wiki/'+targetPage,doc.baseURI).href;
        anchor.title=targetPage==='Kuronata'
            ?'转动命运齿轮 · 唤醒 Kuronata':'转动命运齿轮 · 回到 Kuranoka';
        anchor.setAttribute('aria-label',anchor.title);
        anchor.innerHTML='<svg class="kkpt-fate-gear-svg" viewBox="0 0 48 48" '+
            'aria-hidden="true" focusable="false"><path d="'+fateGearShape()+
            '"/><circle cx="24" cy="24" r="8.5"/>'+
            '<circle cx="24" cy="24" r="3"/></svg>';
        doc.body.appendChild(anchor);
        let pendingPosition=false;
        function positionGear(){
            pendingPosition=false;
            if(!doc.body.contains(anchor)||!doc.body.contains(host)){
                anchor.hidden=true;return;
            }
            const rect=host.getBoundingClientRect();
            const view=doc.defaultView;
            const y=host===box?rect.top+23:rect.top+rect.height*.5;
            anchor.hidden=rect.width<120||rect.bottom<0||rect.top>view.innerHeight||
                y<0||y>view.innerHeight;
            if(anchor.hidden)return;
            anchor.style.left=Math.round(rect.right-49)+'px';
            anchor.style.top=Math.round(y-21)+'px';
        }
        function updateGear(){
            if(pendingPosition)return;
            pendingPosition=true;
            doc.defaultView.requestAnimationFrame(positionGear);
        }
        doc.defaultView.addEventListener('scroll',updateGear,{passive:true});
        doc.defaultView.addEventListener('resize',updateGear,{passive:true});
        updateGear();
    }

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

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

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

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

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

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

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

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

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

    // One irregular, multi-speed burn front. Large lobes advance at
    // genuinely different rates; close-up turbulence erodes the boundary.
    // This EXACT expression is mirrored in the fragment shader below.
    function edgeAt(y, seconds, progress, width) {
        const toDark = window.__kkptVisualDark;
        const wave = Math.sin(Math.PI * progress);
        const h=innerHeight;
        const yn=y/h;
        const lobes=.63*Math.sin(yn*9.3+seconds*.38+.4)+
            .27*Math.sin(yn*22.7-seconds*.28+1.4)+
            .10*Math.sin(yn*51.1+seconds*.58);
        const localProgress=toDark
            ? Math.max(0,Math.min(1,progress+.285*wave*lobes))
            : progress;
        const eased = smooth(localProgress);
        const center = toDark
            ? width + 110 - (width + 220) * eased
            : -110 + (width + 220) * eased;
        const turbulence=toDark
            ? 22*Math.sin(y*.019+seconds*1.8)+
              13*Math.sin(y*.061-seconds*3.6)+
               6*Math.sin(y*.135+seconds*7.9)+
               17*(Math.abs(Math.sin(y*.133+seconds*3.7+
                   .8*Math.sin(y*.043-seconds*1.1)))-.5)+
               5*Math.sin(y*.325-seconds*10.8)+
               2.5*Math.sin(y*.74+seconds*16.2)
            : 28*Math.sin(y*.012+seconds*3.1)+
              17*Math.sin(y*.041-seconds*5.7)+
               7*Math.sin(y*.10+seconds*10);
        return center+wave*turbulence;
    }

    function clipFrame(seconds, progress) {
        const width = innerWidth, height = innerHeight;
        const pts = [];
        const count = Math.min(520,Math.max(110,Math.ceil(height / 2.7)));
        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}`;
            // Follow the actual contour: smoothing it into quadratic arcs
            // was making the page look like a neon-piped vector shape.
            for (let i = count - 1; i >= 0; i--) {
                const point = pts[i];
                d += ` L ${point[0].toFixed(1)} ${point[1].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 wave=sin(3.14159265*uProgress);
            float yn=y/uSize.y;
            float lobes=.63*sin(yn*9.3+uTime*.38+.4)+
                .27*sin(yn*22.7-uTime*.28+1.4)+
                .10*sin(yn*51.1+uTime*.58);
            float localProgress=uDark>.5
                ? clamp(uProgress+.285*wave*lobes,0.0,1.0)
                : uProgress;
            float t = smoothProgress(localProgress);
            float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t;
            float turbulent=uDark>.5
                ? 22.0*sin(y*.019+uTime*1.8)+
                  13.0*sin(y*.061-uTime*3.6)+
                   6.0*sin(y*.135+uTime*7.9)+
                   17.0*(abs(sin(y*.133+uTime*3.7+
                       .8*sin(y*.043-uTime*1.1)))-.5)+
                   5.0*sin(y*.325-uTime*10.8)+
                   2.5*sin(y*.74+uTime*16.2)
                : 28.0*sin(y*.012+uTime*3.1)+
                  17.0*sin(y*.041-uTime*5.7)+
                   7.0*sin(y*.10+uTime*10.0);
            return c+wave*turbulent;
        }
        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) {
                // V7: intact sky remains clean on the OLD side.
                // Incandescent wake + char exist ONLY on the newly
                // revealed Kuronata side, trailing the traveling tear.
                float wakeSide = -oldSide;
                vec2 sq = vec2(p.y*.007-t*.29,wakeSide*.006+t*.12);
                vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)),
                                 fbm(sq*1.9+vec2(0.0,-t*.21)));
                float smokeCloud = fbm(sq*2.0+warp*2.2);
                float smokeDetail = fbm(sq*5.1-warp*.8);
                float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34);
                float soot = smoothstep(-42.0,24.0,wakeSide)*
                    (1.0-smoothstep(390.0,850.0,wakeSide))*
                    (.47+.53*billow);
                vec3 sootColor = mix(vec3(.010,.009,.012),vec3(.066,.019,.018),smokeCloud);

                // Flame remains close to the paper boundary; the far wake
                // retains soot and charcoal, not wandering bright blobs.
                vec2 q = vec2(p.y*.015-t*.66,wakeSide*.012-t*.49);
                float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25));
                float shredded = fbm(q*3.9+vec2(t*.25,-t*.32));
                float lengthOfTongue = 57.0+65.0*turbulence+33.0*shredded;
                float raggedEdge = 1.0-smoothstep(lengthOfTongue-35.0,lengthOfTongue+18.0,wakeSide);
                float flame = raggedEdge*smoothstep(-65.0,22.0,wakeSide)*
                    smoothstep(.24,.73,turbulence+.35*shredded);
                float ignitionNoise=fbm(vec2(p.y*.057+t*.19,
                    t*.84+sin(p.y*.029)*.44));
                float ignite=.22+.78*smoothstep(.26,.63,
                    ignitionNoise+.13*sin(p.y*.092-t*7.8));
                float core = exp(-abs(d)/5.2)*ignite;
                float outer = exp(-abs(d)/32.0)*(.30+.70*ignite);
                float heat = clamp(flame*.87+core*.93,0.0,1.0);
                float hotPockets = smoothstep(.49,.78,turbulence*.75+shredded*.55)*
                    exp(-max(0.0,wakeSide)/62.0);
                vec3 fireColor = mix(vec3(.43,.013,.004),vec3(1.0,.16,.009),smoothstep(.07,.56,heat));
                fireColor = mix(fireColor,vec3(1.0,.43,.025),smoothstep(.35,.78,heat));
                fireColor = mix(fireColor,vec3(1.0,.98,.75),
                    clamp(hotPockets*.57+core*.78,0.0,.92));
                col = mix(sootColor,fireColor,clamp(flame*.81+core*.95,0.0,1.0));
                alpha = clamp(soot*.81+flame*.79+outer*.18+core*.56,0.0,.96);

                // Sharp micron-scale cinders; the real ember trails live
                // on the separate high-resolution atmosphere canvas.
                vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0);
                vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5;
                float sparks = (1.0-smoothstep(.015,.12,length(uv)))*
                    step(.991,random2(cell))*exp(-max(0.0,wakeSide)/380.0);
                col = mix(col,vec3(1.0,.78,.23),sparks);
                alpha = max(alpha,sparks*.97);
            } else {
                // Dawn is a living, spreading atmosphere: diffuse cloud,
                // breathing alabaster light and soft shafts. NO electric
                // arcs, ignition lines, flame-shaped sparks or embers.
                float newlyBlue = -oldSide;
                vec2 q = vec2(p.y*.007-t*.16,newlyBlue*.008+t*.10);
                float cloud = fbm(q+vec2(fbm(q*1.6)*1.7,-t*.13));
                float billow = fbm(q*2.9+vec2(t*.08,-t*.11));
                float erosion = smoothstep(.22,.72,cloud*.76+billow*.37);
                float settling = smoothstep(-68.0,35.0,newlyBlue)*
                    exp(-max(0.0,newlyBlue)/440.0);
                float pearlyMist = settling*(.33+.64*erosion);
                float halo = exp(-pow(d/78.0,2.0));
                float innerLight = exp(-pow(d/21.0,2.0));
                float shaftDrift=20.0*sin(p.y*.013-t*1.0)+
                    9.0*sin(p.y*.039+t*.61);
                float softRay=exp(-pow((d+shaftDrift+88.0)/81.0,2.0))*
                    (.32+.68*smoothstep(.28,.81,cloud));
                col=mix(vec3(.075,.30,.57),vec3(.72,.94,1.0),clamp(cloud*.76+.38,0.0,1.0));
                col=mix(col,vec3(.94,.995,1.0),
                    clamp(innerLight*.49+softRay*.20,0.0,.6));
                alpha=clamp(pearlyMist*.42+halo*.30+innerLight*.22+
                    softRay*.14,0.0,.59);
            }

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

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

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

    // A second canvas adds real, textured soot / rolling vapor and violent
    // short-lived pressure waves. It is deliberately independent of WebGL,
    // so even browsers with GPU disabled still get the smoke and impact.
    function createAtmosphere(canvas) {
        const ctx = canvas.getContext('2d', {alpha:true});
        const rand = n => {
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
            return x-Math.floor(x);
        };
        const clamp01 = n => Math.max(0,Math.min(1,n));
        function valueNoise(x,y,seed) {
            const ix=Math.floor(x), iy=Math.floor(y);
            let fx=x-ix,fy=y-iy;
            fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
            const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
            const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
            const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
            return a*(1-fy)+b*fy;
        }
        function smokeTexture(seed,dark) {
            const tex=document.createElement('canvas');
            tex.width=tex.height=112;
            const g=tex.getContext('2d');
            const data=g.createImageData(112,112);
            for(let y=0;y<112;y++) for(let x=0;x<112;x++){
                const u=(x-56)/56,v=(y-56)/56;
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
                const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
                const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
                const envelope=clamp01((1.05-radius)/.56);
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
                    envelope*envelope;
                const i=(y*112+x)*4;
                const variation=low*28+high*15;
                if(dark){
                    data.data[i]=5+variation*.37;
                    data.data[i+1]=5+variation*.33;
                    data.data[i+2]=8+variation*.38;
                }else{
                    data.data[i]=44+variation;
                    data.data[i+1]=91+variation;
                    data.data[i+2]=125+variation;
                }
                data.data[i+3]=Math.round(240*density);
            }
            g.putImageData(data,0,0);
            return tex;
        }
        const sootSprites=[0,1,2,3].map(n=>smokeTexture(n+17,true));
        const mistSprites=[0,1,2,3].map(n=>smokeTexture(n+51,false));
        // V2-inspired white-hot additive sprites, but with noise-eroded
        // silhouettes instead of repetitive smooth, elliptical balls.
        function glowTexture(seed,mode) {
            const tex=document.createElement('canvas');
            tex.width=96;tex.height=128;
            const g=tex.getContext('2d');
            const gradient=g.createRadialGradient(48,68,0,48,68,64);
            const colors=mode==='fire'
                ? [[0,'#ffde82'],[.16,'#ffc03d'],[.36,'#ff8019'],
                   [.58,'#f3400bbb'],[.81,'#9312063a'],[1,'#6e030000']]
                : mode==='blue'
                ? [[0,'#ffffff'],[.19,'#f1ffff'],[.40,'#a2efff'],
                   [.68,'#5298ff99'],[1,'#345bf000']]
                : [[0,'#ffffff'],[.19,'#fff8cd'],[.49,'#ff9b28cc'],
                   [1,'#ff380000']];
            colors.forEach(([p,c])=>gradient.addColorStop(p,c));
            g.fillStyle=gradient;g.fillRect(0,0,96,128);
            const img=g.getImageData(0,0,96,128);
            for(let y=0;y<128;y++)for(let x=0;x<96;x++){
                const u=(x-48)/48,v=(y-64)/64;
                const cloud=valueNoise(u*4+seed*1.9,v*4+seed*.7,seed+18);
                const fiber=valueNoise(u*11+3,v*8+17,seed+41);
                const shifted=u+.18*(cloud-.5);
                const irregular=clamp01((1.15-Math.abs(shifted))/.52)*
                    clamp01((1.05-Math.abs(v+.12*(fiber-.5)))/.42);
                const n=.59+.77*cloud+.28*fiber;
                const alphaIndex=(y*96+x)*4+3;
                img.data[alphaIndex]=Math.round(img.data[alphaIndex]*
                    irregular*clamp01(n));
            }
            g.putImageData(img,0,0);
            return tex;
        }
        const FIRE_GLOW=[1,2,3,4].map(seed=>glowTexture(seed,'fire'));
        const BLUE_GLOW=[1,2,3,4].map(seed=>glowTexture(seed+7,'blue'));
        const EMBER_GLOW=glowTexture(21,'ember');
        const combustion=[];
        const residualSoot=[];
        const tornPaper=[];
        let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0;
        function advanceCombustion(seconds,progress,dark,elapsed,w,h) {
            const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed));
            lastElapsed=elapsed;
            const live=progress>.01&&progress<.985;
            if(live){
                const flow=(dark?310:115)*Math.min(1.5,h/720)*
                    (.75+.25*Math.sin(Math.PI*progress));
                spawnRemainder+=dt*flow;
                const births=Math.min(65,Math.floor(spawnRemainder));
                spawnRemainder-=births;
                for(let j=0;j<births&&combustion.length<1320;j++){
                    const y=Math.random()*h;
                    const edge=edgeAt(y,seconds,progress,w);
                    const r=Math.random();
                    const kind=dark
                        ? r<.44?'flame':r<.88?'ember':'ash'
                        : r<.37?'glow':r<.81?'leaf':'pollen';
                    const small=kind==='ember'||kind==='pollen';
                    const soot=kind==='ash';
                    const life=dark
                        ? (soot?.7+Math.random()*1.1:
                            small?.65+Math.random()*1.1:.42+Math.random()*.76)
                        : 1.15+Math.random()*1.55;
                    combustion.push({
                        kind,x:edge+(dark?1:-1)*(Math.random()*47-8),
                        y:y+(Math.random()-.5)*25,
                        vx:(dark?1:-1)*(dark?14+Math.random()*154:
                            16+Math.random()*69),
                        vy:-(dark?32+Math.random()*171:12+Math.random()*57),
                        life,remaining:life,
                        radius:dark
                            ? (soot?1.2+Math.random()*2.7:
                                small?2+Math.random()*5:9+Math.random()*19)
                            : kind==='glow'?26+Math.random()*49:
                                kind==='leaf'?4+Math.random()*6:
                                2+Math.random()*3,
                        angle:(Math.random()-.5)*1.1,
                        sprite:(Math.random()*4)|0,
                        seed:Math.random()*6.28
                    });
                }
                if(dark) {
                    // Unlike the traveling front, these black smoke plumes
                    // are born in world-space and remain over burnt paper.
                    sootRemainder+=dt*(h<680?48:94);
                    const sootBirths=Math.min(11,Math.floor(sootRemainder));
                    sootRemainder-=sootBirths;
                    for(let i=0;i<sootBirths&&residualSoot.length<(w<700?175:360);i++){
                        const y=Math.random()*h;
                        const life=2.05+Math.random()*2.0;
                        residualSoot.push({
                            x:edgeAt(y,seconds,progress,w)+5+Math.random()*83,
                            y:y+(Math.random()-.5)*48,
                            vx:8+Math.random()*39,vy:-18-Math.random()*83,
                            size:110+Math.random()*175,life,remaining:life,
                            sprite:(Math.random()*4)|0,
                            spin:(Math.random()-.5)*.8,
                            seed:Math.random()*6.28
                        });
                    }
                    // Charcoal sheets tear off in irregular polygons, not
                    // squares or narrow glitch streaks.
                    shardRemainder+=dt*(h<680?9:15);
                    const shardBirths=Math.min(5,Math.floor(shardRemainder));
                    shardRemainder-=shardBirths;
                    for(let i=0;i<shardBirths&&tornPaper.length<135;i++){
                        const y=Math.random()*h;
                        const life=.82+Math.random()*1.55;
                        const points=6+Math.floor(Math.random()*4);
                        const outline=Array.from({length:points},(_,j)=>({
                            a:Math.PI*2*j/points+(Math.random()-.5)*.22,
                            r:.50+Math.random()*.61
                        }));
                        tornPaper.push({
                            x:edgeAt(y,seconds,progress,w)+(Math.random()-.5)*47,
                            y, vx:34+Math.random()*210,
                            vy:-170+Math.random()*260,
                            gravity:90+Math.random()*150,
                            radius:3+Math.pow(Math.random(),1.6)*15,
                            angle:Math.random()*6.28,
                            spin:(Math.random()-.5)*7,
                            outline,life,remaining:life,
                            seed:Math.random()*6.28
                        });
                    }
                }
            }
            for(let i=combustion.length-1;i>=0;i--){
                const p=combustion[i];
                p.remaining-=dt;
                if(p.remaining<=0){combustion.splice(i,1);continue;}
                p.x+=(p.vx+Math.sin(elapsed*(dark?5:1.6)+p.seed)*
                    (dark?22:24))*dt;
                p.y+=p.vy*dt;
                p.vy-=(dark?17:7)*dt;
            }
            for(let i=residualSoot.length-1;i>=0;i--){
                const p=residualSoot[i];
                p.remaining-=dt;
                if(p.remaining<=0){residualSoot.splice(i,1);continue;}
                p.x+=(p.vx+Math.sin(elapsed*2+p.seed)*27)*dt;
                p.y+=p.vy*dt;
                p.size+=32*dt;
            }
            for(let i=tornPaper.length-1;i>=0;i--){
                const p=tornPaper[i];
                p.remaining-=dt;
                if(p.remaining<=0){tornPaper.splice(i,1);continue;}
                p.x+=(p.vx+Math.sin(elapsed*4+p.seed)*42)*dt;
                p.y+=p.vy*dt;
                p.vy+=p.gravity*dt;
                p.angle+=p.spin*dt;
            }
        }
        const particles=Array.from({length:innerWidth<700?72:124},(_,i)=>({
            seed:rand(i*4+71),height:rand(i*5+13),
            lifetime:.5+rand(i*8+37)*.7,
            phase:rand(i*9+21),sprite:i%4,
            scale:.72+rand(i*7+9)*1.25,
            bend:rand(i*15+23)*6.28
        }));
        const motes=Array.from({length:innerWidth<700?47:100},(_,i)=>({
            y:rand(i*3+17),phase:rand(i*9+43),
            speed:85+rand(i*12+5)*260,
            drift:75+rand(i*15+2)*170,
            length:3+rand(i*17+7)*11
        }));
        const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({
            angle:rand(i*17+131)*Math.PI*2,
            velocity:190+rand(i*11+51)*900,
            seed:rand(i*5+79),
            length:6+rand(i*7+17)*26
        }));
        function pressureWave(x,y,radius,power,dark,time) {
            if(power<=.005)return;
            const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
            if(dark){
                core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
                core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
                core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')');
                core.addColorStop(1,'rgba(30,0,0,0)');
            }else{
                core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')');
                core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')');
                core.addColorStop(1,'rgba(0,29,100,0)');
            }
            ctx.globalCompositeOperation='screen';
            ctx.fillStyle=core;
            ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
            ctx.globalCompositeOperation='source-over';
        }
        return function drawAtmosphere(seconds,progress,dark,elapsed) {
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.67:.79;
            const bw=Math.max(1,Math.round(w*scale));
            const bh=Math.max(1,Math.round(h*scale));
            if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;}
            ctx.setTransform(scale,0,0,scale,0,0);
            ctx.clearRect(0,0,w,h);
            const envelope=Math.sin(Math.PI*progress);
            const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
            advanceCombustion(seconds,progress,dark,elapsed,w,h);
            const sprites=dark?sootSprites:mistSprites;
            // New-world side: red fire trails to the right when burning,
            // blue dawn trails to the left when returning.
            const direction=dark?1:-1;
            // Broken smoke banks with different depths and sizes. The old
            // aligned rows created an artificial horizontal fog ribbon.
            ctx.globalCompositeOperation='source-over';
            const bands=Math.max(8,Math.ceil(h/(dark?116:91)));
            for(let i=0;i<bands;i++){
                const baseY=(i+.15+rand(i*11+39)*.70)*h/bands;
                const y=baseY+31*Math.sin(seconds*1.6+i*1.47);
                const x=edgeAt(y,seconds,progress,w)+direction*
                    (dark?76+rand(i*19+6)*260:120)+
                    Math.sin(seconds*.93+i*1.3)*37;
                const width=dark?170+rand(i*29+12)*220:250;
                const height=dark?165+rand(i*7+5)*170:245;
                ctx.save();
                ctx.globalAlpha=(dark?.34:.21)*fade*(.55+.45*envelope);
                ctx.translate(x,y);
                ctx.rotate(.27*Math.sin(i*2.1+seconds*.9));
                ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height);
                ctx.restore();
            }
            // Independent turbulent billows peel away and rise.
            for(const item of particles){
                const age=((elapsed/item.lifetime+item.phase)%1);
                const baseY=item.height*h;
                const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
                if(y< -230||y>h+230)continue;
                const frontier=edgeAt(baseY,seconds,progress,w);
                const x=frontier+direction*(age*(dark?225:135)+
                    30*Math.sin(age*6.3+item.bend));
                const size=(dark?145:145)*item.scale*(.68+age*1.9);
                const opacity=(dark?.38:.26)*Math.sin(Math.PI*age)*fade*
                    (.55+.45*envelope);
                if(opacity<=.005)continue;
                ctx.save();
                ctx.globalAlpha=opacity;
                ctx.translate(x,y);
                ctx.rotate((item.bend-3.14)*.14+age*.85*direction);
                ctx.scale(1.18,.84);
                ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size);
                ctx.restore();
            }
            // Residual devastation: coal-black plumes stay pinned to their
            // original burn positions, expanding long after the tear passes.
            // Composited *before* fire, so the hot core cuts through smoke.
            if(dark){
                ctx.save();
                ctx.globalCompositeOperation='source-over';
                for(const p of residualSoot){
                    const age=1-p.remaining/p.life;
                    const opacity=clamp01(age/.14)*
                        Math.pow(1-age,.49)*(.56+.26*Math.sin(p.seed*3)**2)*fade;
                    if(opacity<.009)continue;
                    ctx.save();
                    ctx.globalAlpha=opacity;
                    ctx.translate(p.x,p.y);
                    ctx.rotate(p.spin+age*.33*Math.sin(p.seed));
                    ctx.scale(1.20,.92);
                    const s=p.size*(1+age*.68);
                    ctx.drawImage(sootSprites[p.sprite],-s/2,-s/2,s,s);
                    ctx.restore();
                }
                ctx.restore();
            }else{
                // The inverse is a slow, organic unfurling: broad milky
                // radiance behind the rebuilt sky, not a cold explosion.
                ctx.save();
                ctx.globalCompositeOperation='screen';
                for(let n=0;n<3;n++){
                    const yy=(n+.43)*h/3+Math.sin(seconds*.8+n*2)*30;
                    const xx=edgeAt(yy,seconds,progress,w)-
                        (88+55*Math.sin(n*2.1+seconds*.7));
                    const radius=145+65*Math.sin(seconds*.7+n*1.7)**2;
                    const bloom=ctx.createRadialGradient(xx,yy,8,xx,yy,radius);
                    bloom.addColorStop(0,'rgba(250,255,255,.10)');
                    bloom.addColorStop(.33,'rgba(164,237,255,.07)');
                    bloom.addColorStop(.74,'rgba(117,198,255,.04)');
                    bloom.addColorStop(1,'rgba(117,198,255,0)');
                    ctx.globalAlpha=fade;
                    ctx.fillStyle=bloom;
                    ctx.fillRect(xx-radius,yy-radius,radius*2,radius*2);
                }
                ctx.restore();
            }
            // The impact happens at ignition and again as the rupture reaches
            // the middle of the screen; pulses are intentionally fleeting.
            for(const moment of (dark?[.10,.48]:[])){
                const age=(progress-moment)/.125;
                if(age<0||age>1)continue;
                const intensity=(1-age)*(1-age)*fade;
                // A single-frame-ish exposure bloom punches through
                // the entire page; the tone is red or icy blue by direction.
                ctx.globalCompositeOperation='screen';
                ctx.fillStyle=dark
                    ? 'rgba(255,81,26,'+(.095*intensity)+')'
                    : 'rgba(150,219,255,'+(.080*intensity)+')';
                ctx.fillRect(0,0,w,h);
                ctx.globalCompositeOperation='source-over';
                const x=edgeAt(h*.51,seconds,progress,w);
                const y=h*.51;
                const radius=95+age*Math.max(w,h)*.9;
                pressureWave(x,y,radius,intensity,dark,seconds);
                ctx.globalAlpha=(dark?.20:.28)*intensity;
                ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
                ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
                ctx.beginPath();
                for(let i=0;i<=110;i++){
                    const a=i/110*Math.PI*2;
                    const jag=1+.03*Math.sin(a*29+seconds*12)+
                        .05*Math.sin(a*13-seconds*5);
                    const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag;
                    if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry);
                }
                ctx.stroke();
                ctx.globalAlpha=1;
                // Only combustion throws straight, fiery projectile trails.
                // Return-to-blue has no streaks or sparks whatsoever.
                if(dark){
                  ctx.globalCompositeOperation='screen';
                  for(const bit of shockDebris){
                    const f=age*(.5+bit.seed*.5);
                    const travel=bit.velocity*f;
                    const px=x+Math.cos(bit.angle)*travel;
                    const py=y+Math.sin(bit.angle)*travel;
                    const vx=Math.cos(bit.angle)*bit.length*(1-age);
                    const vy=Math.sin(bit.angle)*bit.length*(1-age);
                    ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7);
                    ctx.strokeStyle=dark?'#ffdd9b':'#e0faff';
                    ctx.lineWidth=.6+bit.seed*1.3;
                    ctx.beginPath();
                    ctx.moveTo(px,py);
                    ctx.lineTo(px-vx,py-vy);
                    ctx.stroke();
                  }
                }
                ctx.globalCompositeOperation='source-over';
                ctx.globalAlpha=1;
            }
            // Real flame tongues break OUT of the burn front into the sky.
            // They are thin, tapered Bezier ribbons with an unstable pulse,
            // rather than round glowing particles or a uniform bright line.
            if(dark){
                ctx.save();
                ctx.globalCompositeOperation='screen';
                const count=Math.ceil(h/14);
                for(let j=0;j<count;j++){
                    const seed=rand(j*13+167);
                    const y=(j+seed*.78)*14-5;
                    const x=edgeAt(y,seconds,progress,w);
                    if(x < -90 || x > w+90)continue;
                    const ripple=Math.sin(seconds*(3.4+rand(j*11+4)*2.4)+
                        j*2.09+seed*3.1);
                    const heat=clamp01(.48+ripple*.40+
                        .16*Math.sin(j*1.17-seconds*7.1));
                    if(heat<.38)continue;
                    const length=(22+rand(j*23+19)*75)*(.5+heat*.94);
                    const bend=.18+rand(j*17+42)*.66;
                    const tipX=x-length*(.76+.19*seed);
                    const tipY=y-length*bend;
                    const thick=(2.8+rand(j*31+11)*8.5)*(.5+heat);
                    const gradient=ctx.createLinearGradient(x,y,tipX,tipY);
                    gradient.addColorStop(0,'rgba(255,251,208,.93)');
                    gradient.addColorStop(.24,'rgba(255,197,45,.88)');
                    gradient.addColorStop(.59,'rgba(255,76,10,.64)');
                    gradient.addColorStop(1,'rgba(182,17,6,0)');
                    ctx.beginPath();
                    ctx.moveTo(x+2,y+thick*.5);
                    ctx.bezierCurveTo(
                        x-length*.22,y-thick*.25,
                        tipX+length*.36,tipY+thick*.68,tipX,tipY);
                    ctx.bezierCurveTo(
                        tipX+length*.32,tipY-thick*.22,
                        x-length*.17,y-thick*.74,
                        x+2,y-thick*.65);
                    ctx.closePath();
                    ctx.globalAlpha=heat*.91*fade;
                    ctx.fillStyle=gradient;
                    ctx.shadowColor='#ff5412';
                    ctx.shadowBlur=2+heat*7;
                    ctx.fill();
                    if(heat>.73){
                        ctx.globalAlpha=(heat-.63)*.88*fade;
                        ctx.shadowColor='#ffe4aa';ctx.shadowBlur=7;
                        ctx.strokeStyle='#fff5bc';ctx.lineWidth=.65+heat*.8;
                        ctx.beginPath();ctx.moveTo(x,y);
                        ctx.quadraticCurveTo(x-length*.3,y-length*.11,
                            tipX+length*.19,tipY+thick*.18);
                        ctx.stroke();
                    }
                }
                ctx.restore();
            }

            // Carbonized paper has a fine, ragged edge. The charcoal layer
            // persists continuously, but the white-hot core does NOT.
            ctx.save();
            ctx.beginPath();
            for(let y=-12,i=0;y<=h+12;y+=2.7,i++){
                const x=edgeAt(y,seconds,progress,w);
                if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
            }
            ctx.lineCap='round';ctx.lineJoin='round';
            if(dark){
                ctx.strokeStyle='rgba(9,5,7,.88)';
                ctx.lineWidth=22;ctx.shadowColor='#130508';ctx.shadowBlur=19;
                ctx.stroke();
                ctx.strokeStyle='rgba(192,39,8,.45)';
                ctx.lineWidth=8;ctx.shadowColor='#ff4c08';ctx.shadowBlur=15;
                ctx.stroke();
            }else{
                ctx.strokeStyle='rgba(27,95,190,.22)';
                ctx.lineWidth=27;ctx.shadowColor='#56bdff';ctx.shadowBlur=40;
                ctx.stroke();
                ctx.strokeStyle='rgba(132,225,255,.51)';
                ctx.lineWidth=11;ctx.shadowColor='#8aeaff';ctx.shadowBlur=30;
                ctx.stroke();
                ctx.strokeStyle='rgba(249,255,255,.65)';
                ctx.lineWidth=2.7;ctx.shadowBlur=11;
                ctx.stroke();
            }
            ctx.restore();
            if(dark){
                // Intermittent ignition cells: varying width/color/intensity
                // make the bright edge resemble burning fibres, not neon.
                ctx.save();
                ctx.globalCompositeOperation='screen';
                ctx.lineCap='round';
                for(let y=-8;y<h+8;y+=5){
                    const intensity=clamp01(.47+
                        .29*Math.sin(y*.054+seconds*3.7)+
                        .21*Math.sin(y*.147-seconds*8.9)+
                        .13*Math.sin(y*.337+seconds*16.4));
                    if(intensity<.53)continue;
                    const x=edgeAt(y,seconds,progress,w);
                    const nextX=edgeAt(y+5,seconds,progress,w);
                    ctx.globalAlpha=intensity*.93*fade;
                    ctx.lineWidth=.9+intensity*3.2;
                    ctx.shadowColor=intensity>.76?'#ffe4a0':'#ff5b0a';
                    ctx.shadowBlur=4+intensity*10;
                    ctx.strokeStyle=intensity>.76?'#fff0b5':'#ff951e';
                    ctx.beginPath();ctx.moveTo(x,y);
                    ctx.lineTo(nextX,y+5);ctx.stroke();
                }
                ctx.restore();
                // Small carbon flecks stick to the still-blue paper just
                // ahead of the bite; no large rectangular glitch debris.
                ctx.save();
                for(let j=0;j<Math.ceil(h/10);j++){
                    const y=(j+rand(j*5+61)*.8)*10;
                    const x=edgeAt(y,seconds,progress,w)-
                        (5+rand(j*17+38)*24);
                    const size=.5+rand(j*29+11)*2.5;
                    const visible=.25+.59*rand(j*13+7);
                    ctx.globalAlpha=visible*fade;
                    ctx.fillStyle='#150b0a';
                    ctx.beginPath();
                    ctx.ellipse(x,y,size*.71,size,rand(j*7+31)*3.14,
                        0,Math.PI*2);
                    ctx.fill();
                }
                ctx.restore();
            }
            // The key property V3-V6 lost: particles remain at their
            // birth position while the front advances. A wide, incandescent
            // WAKE builds behind the tearing page instead of following
            // it as one thin, plastic-looking stripe.
            ctx.save();
            ctx.globalCompositeOperation='lighter';
            for(const p of combustion){
                if(p.kind==='ash'||p.kind==='leaf'||p.kind==='pollen')continue;
                const age=1-p.remaining/p.life;
                const opacity=clamp01(p.remaining/.22)*
                    clamp01(age/.08)*(1-age*.30)*
                    (p.kind==='ember'?.61:p.kind==='glow'?.13:.40)*fade*
                    (dark?Math.exp(-Math.max(0,p.x-
                        edgeAt(p.y,seconds,progress,w))/70):1);
                if(opacity<.012)continue;
                ctx.save();
                ctx.globalAlpha=opacity;
                ctx.translate(p.x,p.y);
                ctx.rotate(p.angle+Math.sin(elapsed*4+p.seed)*.13);
                const large=p.kind==='flame';
                const sx=p.radius*(p.kind==='glow'?1.7:large?.96:1.32)*(1+age*.32);
                const sy=p.radius*(p.kind==='glow'?1.4:large?1.78:1.35)*(1+age*.57);
                const sprite=p.kind==='flame'?FIRE_GLOW[p.sprite]:
                    p.kind==='glow'?BLUE_GLOW[p.sprite]:EMBER_GLOW;
                ctx.drawImage(sprite,-sx,-sy,sx*2,sy*2);
                ctx.restore();
            }
            ctx.restore();
            // Rebirth uses soft, rotating, organic leaf/petal silhouettes,
            // minute circular pollen and spacious white-blue bokeh. No
            // rectangular ions, no warm textures and NO glowing streaks.
            if(!dark){
                ctx.save();
                ctx.globalCompositeOperation='screen';
                for(const p of combustion){
                    if(p.kind!=='leaf'&&p.kind!=='pollen')continue;
                    const age=1-p.remaining/p.life;
                    const opacity=clamp01(age/.17)*clamp01(p.remaining/.55)*
                        (p.kind==='leaf'?.47:.28)*fade;
                    if(opacity<.012)continue;
                    ctx.save();
                    ctx.globalAlpha=opacity;
                    ctx.translate(p.x+11*Math.sin(elapsed*1.6+p.seed),p.y);
                    ctx.rotate(p.angle+age*1.65*Math.sin(p.seed*2.5));
                    if(p.kind==='leaf'){
                        const r=p.radius;
                        ctx.shadowBlur=13;
                        ctx.shadowColor='#bbf4ff';
                        ctx.fillStyle=p.sprite%2?'#e6ffff':'#ffffff';
                        ctx.beginPath();
                        ctx.moveTo(0,-r*1.65);
                        ctx.bezierCurveTo(r*.97,-r*.48,r*.85,r*.60,0,r*1.35);
                        ctx.bezierCurveTo(-r*.92,r*.28,-r*.94,-r*.57,0,-r*1.65);
                        ctx.fill();
                        ctx.globalAlpha=opacity*.38;
                        ctx.strokeStyle='#70c9ea';
                        ctx.lineWidth=.65;
                        ctx.beginPath();ctx.moveTo(0,-r*1.15);
                        ctx.quadraticCurveTo(-r*.17,0,0,r*.95);ctx.stroke();
                    }else{
                        const r=p.radius;
                        const halo=ctx.createRadialGradient(0,0,0,0,0,r*3);
                        halo.addColorStop(0,'rgba(255,255,255,.78)');
                        halo.addColorStop(.25,'rgba(225,250,255,.40)');
                        halo.addColorStop(1,'rgba(150,215,255,0)');
                        ctx.fillStyle=halo;
                        ctx.beginPath();ctx.arc(0,0,r*3,0,Math.PI*2);ctx.fill();
                    }
                    ctx.restore();
                }
                ctx.restore();
            }
            // Minute black flecks, never rectangular shards on the blue side.
            ctx.save();
            ctx.globalCompositeOperation='source-over';
            for(const p of combustion){
                if(p.kind!=='ash')continue;
                ctx.globalAlpha=clamp01(p.remaining/.25)*.7*fade;
                ctx.fillStyle='#190d10';
                ctx.save();ctx.translate(p.x,p.y);ctx.rotate(elapsed*2+p.angle);
                ctx.fillRect(-p.radius*.5,-p.radius*.5,p.radius,p.radius*1.8);
                ctx.restore();
            }
            ctx.restore();
            // Irregular burnt page sections tumble away and reveal their
            // blackened underside, with an occasional incandescent rim.
            if(dark){
                ctx.save();
                ctx.globalCompositeOperation='source-over';
                for(const p of tornPaper){
                    const age=1-p.remaining/p.life;
                    const opacity=clamp01(age/.08)*clamp01(p.remaining/.4)*fade;
                    if(opacity<.01)continue;
                    ctx.save();
                    ctx.globalAlpha=opacity;
                    ctx.translate(p.x,p.y);
                    ctx.rotate(p.angle);
                    ctx.beginPath();
                    p.outline.forEach((v,i)=>{
                        const px=Math.cos(v.a)*v.r*p.radius;
                        const py=Math.sin(v.a)*v.r*p.radius*.74;
                        if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
                    });
                    ctx.closePath();
                    ctx.shadowBlur=7;
                    ctx.shadowColor='#090506';
                    ctx.fillStyle=p.seed>3?'#190d11':'#302025';
                    ctx.fill();
                    ctx.shadowBlur=0;
                    ctx.lineWidth=.8+Math.sin(p.seed)**2*.8;
                    ctx.strokeStyle=age<.48?'rgba(255,98,34,.29)':
                        'rgba(49,20,19,.47)';
                    ctx.stroke();
                    // Thin blue-white unburnt fibers still visible on a
                    // few ripped faces, before the back chars completely.
                    if(p.seed>4.5&&age<.52){
                        ctx.globalAlpha*=.45*(1-age);
                        ctx.strokeStyle='#c5e7f7';
                        ctx.beginPath();
                        ctx.moveTo(-p.radius*.42,-p.radius*.16);
                        ctx.quadraticCurveTo(0,p.radius*.25,p.radius*.28,0);
                        ctx.stroke();
                    }
                    ctx.restore();
                }
                ctx.restore();
            }
            // Sharp, directional embers / crystalline fragments, not blobs.
            if(dark){ctx.save();
            ctx.globalCompositeOperation='screen';
            for(const mote of motes){
                const age=(elapsed*.74+mote.phase)%1;
                const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
                const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
                if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
                const opacity=(1-age)*fade*.89;
                ctx.globalAlpha=opacity;
                ctx.strokeStyle='#ffb45d';
                ctx.lineWidth=.8;
                ctx.beginPath();
                ctx.moveTo(x,yy);
                ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
                ctx.stroke();
            }
            ctx.restore();}
        };
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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