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

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
无编辑摘要
无编辑摘要
 
(未显示同一用户的2个中间版本)
第1行: 第1行:
/* ============================================================
/* ============================================================
   Kuranoka / Kuronata — Page Transition V31 / WebGL2
   Kuranoka / Kuronata — Page Transition V34 / V25 faithful restore
   V25 motion and V13/P3's oppressive, wide white-hot fire wall.
   V25 ORIGINAL fire edge, translucent fire pockets, smoke and dawn.
   The BODY of the fire, not just the glowing edge, is rendered by GPU.
   Lightweight WebGL2 is restricted to subtle ember points, NEVER the
  Detailed embers, soot and storm debris preserve V13's atmosphere.
  fire curtain nor the reverse transition. Carbon sheets are 2D.
   Canvas 2D engine retained as the no-WebGL fallback.
   Two-way scores: MARENOIA peaks at 00:33; AnotherMe ends at 02:39.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Real page navigation after the transition; iframe is visual-only.
   Real page navigation after the transition; iframe is visual-only.
第11行: 第11行:
     'use strict';
     'use strict';
     retry=retry||0;
     retry=retry||0;
    // A separately imported page script may arrive before mw.config.
    // Retry rather than silently abandoning the entire world.
     if (!window.mw || !mw.config || typeof mw.config.get!=='function') {
     if (!window.mw || !mw.config || typeof mw.config.get!=='function') {
         if(retry<40)setTimeout(()=>initKkpt(retry+1),100);
         if(retry<40)setTimeout(()=>initKkpt(retry+1),100);
第56行: 第54行:
     function showTheme(dark){wantsNight=dark;syncTheme();}
     function showTheme(dark){wantsNight=dark;syncTheme();}
     syncTheme();
     syncTheme();
     // CSS and JS are sometimes loaded independently on this wiki.
     // Citizen may reapply OS-preference classes after its own scripts load.
     // A sentinel distinguishes a missing stylesheet from slow image
     // Keep each world's class correct without changing the user's setting.
     // decoding. A single same-origin retry prevents unstyled effects.
    new MutationObserver(syncTheme).observe(root,{
        attributes:true,attributeFilter:['class']
    });
     // V31's page-style retry solves occasional initial unstyled portals.
     function ensureWorldStyles(){
     function ensureWorldStyles(){
         if(!document.head || document.getElementById('kkpt-css-retry'))return;
         if(!document.head || document.getElementById('kkpt-css-retry'))return;
         try{
         try{
             if(getComputedStyle(root).getPropertyValue('--kkpt-css-v31').trim()==='ready')
             if(getComputedStyle(root).getPropertyValue('--kkpt-css-v34').trim()==='ready')return;
                return;
         }catch(_){}
         }catch(_){}
         const link=document.createElement('link');
         const link=document.createElement('link');
第72行: 第72行:
         target.searchParams.set('action','raw');
         target.searchParams.set('action','raw');
         target.searchParams.set('ctype','text/css');
         target.searchParams.set('ctype','text/css');
         target.searchParams.set('kkptv','31');
         target.searchParams.set('kkptv','34');
         link.href=target.href;
         link.href=target.href;
        link.addEventListener('error',()=>console.warn(
            '[KuranokaPageTransition] Stylesheet retry failed; '+
            'check ResourceLoader / CSS permissions.'));
         document.head.appendChild(link);
         document.head.appendChild(link);
     }
     }
     setTimeout(ensureWorldStyles,1200);
     setTimeout(ensureWorldStyles,1200);
    // Citizen's later initialization may reinstate the OS-preference class.
    // Keep the intended world palette without touching the user's global
    // account preference or other MediaWiki pages.
    new MutationObserver(syncTheme).observe(root,{
        attributes:true,attributeFilter:['class']
    });
     function navigateRealPage(href){
     function navigateRealPage(href){
         window.location.assign(href);
         window.location.assign(href);
第207行: 第198行:
             });
             });
         };
         };
         // All visible references to the old identity are unstable in the
         // V29's useful identity faults, transplanted without its transition
         // Kuronata world, including plain text, table cells and link labels.
         // shaders. Only text nodes are wrapped; Wiki links stay clickable.
        // Wrap only matching TEXT nodes so links keep their original href,
        // MediaWiki tables/collapsibles keep their layout, and wiki editing
        // controls remain untouched.
         const decorateIdentityMentions=()=>{
         const decorateIdentityMentions=()=>{
             const article=document.querySelector('#mw-content-text,.mw-parser-output');
             const article=document.querySelector('#mw-content-text,.mw-parser-output');
第233行: 第221行:
             const matches=[];
             const matches=[];
             let node;
             let node;
             while((node=walker.nextNode())&&matches.length<1800)
             while((node=walker.nextNode())&&matches.length<1800)matches.push(node);
                matches.push(node);
             let index=0;
             let index=0;
             for(const textNode of matches){
             for(const textNode of matches){
第243行: 第230行:
                 while((match=exactIdentity.exec(raw))){
                 while((match=exactIdentity.exec(raw))){
                     if(match.index>previous)
                     if(match.index>previous)
                         fragment.append(document.createTextNode(
                         fragment.append(document.createTextNode(raw.slice(previous,match.index)));
                            raw.slice(previous,match.index)));
                     const el=document.createElement('span');
                     const el=document.createElement('span');
                     el.className='kkpt-identity-glitch';
                     el.className='kkpt-identity-glitch';
第323行: 第309行:
     if (window!==window.top || window.__kkptV12) return;
     if (window!==window.top || window.__kkptV12) return;
     window.__kkptV12=true;
     window.__kkptV12=true;
     window.__kkptVersion='31.0';
     window.__kkptVersion='34.0';


     // Easter eggs + Infobox gear are the default. No floating pill.
     // Easter eggs + Infobox gear are the default. No floating pill.
第500行: 第486行:
     }
     }


     let active = false; // true only during the visual handoff; destination navigates natively
     // Audio is a CLOCK, not just background music. V25's actual fire pixels
     let animating = false;
     // and particle layers are intentionally unchanged. Only their progress
     let gearPending = false;
     // timebase changes: the burning curtain crosses 50% at Marenoia 00:33;
     let ready = false;
     // the returning dawn completes at AnotherMe 02:39.
     let loading = null;
     const SCORE_KEY='kkpt:score-handoff:34';
     let button, labelTitle, labelSub, labelIcon, frame;
     const SCORE_MUTE_KEY='kkpt:score-muted';
    let otherTitle = otherPage + ' - Blockland Wiki';
     const SCORES={
 
        Kuronata:{
     function whichPage() { return active ? otherPage : basePage; }
            id:'marenoia',target:'Kuronata',
    function destination() { return active ? basePage : otherPage; }
            url:'https://blkl.fun/wp-content/uploads/2026/10/Marenoia.mp3?kkpt_audio=v34',
    function isDark(page) { return page === 'Kuronata'; }
            startAt:0,fireStart:31.275,fireEnd:34.725,
    function pathPage() {
            stopAt:93,fadeSeconds:1.45
        const url = new URL(location.href);
        },
         if (url.pathname.startsWith('/wiki/')) {
         Kuranoka:{
             try { return decodeURIComponent(url.pathname.slice(6)).replace(/_/g, ' '); }
            id:'anotherme',target:'Kuranoka',
             catch (e) { return ''; }
            url:'https://blkl.fun/wp-content/uploads/2026/10/AnotherMe.mp3?kkpt_audio=20261024_043627',
             startAt:155.9,fireStart:155.9,fireEnd:159,
             stopAt:224.914286,fadeSeconds:0
         }
         }
         return url.searchParams.get('title') || '';
    };
    let returnScore=null;
    let scoreOmen=null;
    function userWantsMute(){
         try{return sessionStorage.getItem(SCORE_MUTE_KEY)==='1';}
        catch(_){return false;}
    }
    const AUDIO_ICONS={
        speaker:'<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15 9a5 5 0 0 1 0 6"/><path d="M18 6a9 9 0 0 1 0 12"/>',
        mute:'<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>',
        play:'<path d="m8 5 11 7-11 7V5Z"/>'
    };
    function updateAudioToggle(score){
        if(!score?.hud)return;
        const paused=score.blocked||score.audio.paused;
        const icon=paused?'play':score.audio.muted?'mute':'speaker';
        score.hud.innerHTML='<svg viewBox="0 0 24 24" fill="none" '+
            'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '+
            'stroke-linejoin="round" aria-hidden="true">'+AUDIO_ICONS[icon]+'</svg>';
        score.hud.setAttribute('aria-label',paused?'恢复播放':
            score.audio.muted?'取消静音':'静音');
        score.hud.setAttribute('aria-pressed',String(score.audio.muted));
        score.hud.classList.toggle('kkpt-audio-needs-resume',paused);
     }
     }
 
     function mountAudioToggle(score){
     function updateButton() {
         const old=document.getElementById('kkpt-audio-toggle');
         if (!button) return;
        old?.remove();
         const to = destination();
         const hud=document.createElement('button');
         button.href = to === basePage ? baseUrl : otherUrl.href;
         hud.type='button';hud.id='kkpt-audio-toggle';
        labelTitle.textContent = to.toUpperCase();
        hud.setAttribute('aria-label','静音');
        labelSub.textContent = isDark(to) ? 'BURN THE SKY' : 'RECLAIM THE SKY';
        score.hud=hud;
         labelIcon.textContent = isDark(to) ? '✦' : '❄';
        document.body.append(hud);
         button.classList.toggle('kkpt-dark', isDark(whichPage()));
        const audio=score.audio;
         button.setAttribute('aria-label', isDark(to) ? '燃尽天空,进入 Kuronata' : '驱散黑暗,回到 Kuranoka');
        hud.addEventListener('click',()=>{
            if(audio.paused||score.blocked){
                score.blocked=false;
                const resume=typeof score.resume==='function'?
                    score.resume():audio.play();
                Promise.resolve(resume).then(()=>{
                    updateAudioToggle(score);
                }).catch(err=>{
                    console.info('[KKPT] Audio resume blocked:',err);
                    score.blocked=true;updateAudioToggle(score);
                });
                updateAudioToggle(score);
                return;
            }
            audio.muted=!audio.muted;
            try{sessionStorage.setItem(SCORE_MUTE_KEY,audio.muted?'1':'0');}
            catch(_){}
            updateAudioToggle(score);
        });
         audio.addEventListener('play',()=>updateAudioToggle(score));
        audio.addEventListener('pause',()=>updateAudioToggle(score));
        updateAudioToggle(score);
    }
    function removeScoreOmen(){
        scoreOmen?.remove();scoreOmen=null;
         document.body.classList.remove('kkpt-omen-running');
    }
    function startScoreOmen(score){
        if(score?.config?.id!=='marenoia'||scoreOmen)return;
        const omen=document.createElement('div');
         omen.id='kkpt-omen';omen.setAttribute('aria-hidden','true');
        omen.innerHTML='<i class="kkpt-omen-glow"></i>'+
            '<i class="kkpt-omen-line"></i>';
        document.body.append(omen);
        scoreOmen=omen;
        document.body.classList.add('kkpt-omen-running');
    }
    function clearReturnScore(score){
        if(!score)return;
        clearInterval(score.displayTimer);
        score.hud?.remove();
        try{
            score.audio.pause();
            score.audio.removeAttribute('src');score.audio.load();
        }catch(_){}
        if(returnScore===score)returnScore=null;
        if(score.config?.id==='marenoia')removeScoreOmen();
     }
     }
 
     function watchScore(score){
    // Accept the two standard MediaWiki link forms, while rejecting
         const audio=score.audio,config=score.config;
    // edit/action links, external sites and similarly named articles.
        score.displayTimer=setInterval(()=>{
     function wikiLinkPage(anchor, doc) {
             if(audio.ended||audio.error||audio.currentTime>=config.stopAt-.018){
         try {
                 clearReturnScore(score);return;
            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, ' ');
             const remaining=config.stopAt-audio.currentTime;
             return title === 'Kuranoka' || title === 'Kuronata' ? title : null;
            if(config.fadeSeconds&&remaining<config.fadeSeconds){
         } catch (_) {
                audio.volume=.78*Math.max(0,remaining/config.fadeSeconds);
             return null;
            }
        },95);
        audio.addEventListener('ended',()=>clearReturnScore(score),{once:true});
        audio.addEventListener('error',()=>{
             score.failed=true;clearReturnScore(score);
        },{once:true});
    }
    function createScore(config){
        const audio=new Audio(config.url);
        audio.preload='auto';audio.playsInline=true;
        audio.volume=.78;audio.muted=userWantsMute();
        const score={audio,config,failed:false,blocked:false,
            displayTimer:null,hud:null,playPromise:null};
        returnScore=score;
        mountAudioToggle(score);
        watchScore(score);
        return score;
    }
    function armReturnScore(targetPage){
        const config=SCORES[targetPage];
        if(!config||targetPage===basePage)return null;
        if(returnScore&&!returnScore.failed&&
            returnScore.config.id===config.id)return returnScore;
        if(returnScore)clearReturnScore(returnScore);
        const score=createScore(config);
        // Set the offset in the original, trusted CLICK. No 2:35 pre-roll.
        try{score.audio.currentTime=config.startAt;}catch(_){}
        score.audio.addEventListener('loadedmetadata',()=>{
            if(score.audio.currentTime<config.startAt-.2){
                try{score.audio.currentTime=config.startAt;}catch(_){}
            }
        },{once:true});
        try{
            score.playPromise=Promise.resolve(score.audio.play()).then(()=>{
                startScoreOmen(score);return true;
            },err=>{
                console.warn('[KKPT] Soundtrack playback blocked:',err);
                score.failed=true;clearReturnScore(score);return false;
            });
         }catch(err){
             console.warn('[KKPT] Soundtrack playback failed:',err);
            score.failed=true;clearReturnScore(score);
            score.playPromise=Promise.resolve(false);
         }
         }
        return score;
     }
     }
 
     function awaitScoreTime(score,seconds){
     function decoratePortalLinks(doc, targetPage) {
         if(!score||score.failed)return Promise.resolve(false);
         // Use only the article: sidebar, edit tools and site-wide links
         return new Promise(resolve=>{
         // must retain their usual navigation behavior.
            const audio=score.audio;
        const content = doc.querySelector('#mw-content-text,.mw-parser-output');
            let timer=null,lastTime=audio.currentTime;
        if (!content) return;
            let lastMovement=performance.now();
        content.querySelectorAll('a[href]').forEach(anchor => {
            let settled=false;
            if(anchor.classList.contains('kkpt-fate-gear'))return;
            const done=ok=>{
            if (wikiLinkPage(anchor, doc) !== targetPage) return;
                if(settled)return;
            anchor.classList.add('kkpt-portal-link');
                settled=true;clearInterval(timer);
             anchor.classList.toggle('kkpt-portal-red', targetPage === 'Kuronata');
                audio.removeEventListener('timeupdate',check);
             anchor.classList.toggle('kkpt-portal-blue', targetPage === 'Kuranoka');
                audio.removeEventListener('ended',check);
             const caption = (anchor.textContent || '').trim();
                audio.removeEventListener('error',check);
             if (caption) anchor.dataset.kkptText = caption.slice(0, 120);
                resolve(ok);
             anchor.setAttribute('aria-label', caption + ',切换至 ' + targetPage);
            };
            const check=()=>{
                if(audio.currentTime>=seconds-.018){done(true);return;}
                if(score.failed||audio.error||audio.ended){done(false);return;}
                if(audio.currentTime>lastTime+.015){
                    lastTime=audio.currentTime;lastMovement=performance.now();
                }
                // A stalled soundtrack cannot trap navigation indefinitely.
                if(performance.now()-lastMovement>20000)done(false);
             };
            audio.addEventListener('timeupdate',check);
             audio.addEventListener('ended',check);
             audio.addEventListener('error',check);
             timer=setInterval(check,75);
             check();
         });
         });
     }
     }
 
     function saveReturnScoreHandoff(score){
     function watchPortalLinks(doc, targetPage) {
         if(!score||score.failed||score.audio.ended||
        decoratePortalLinks(doc, targetPage);
            score.audio.currentTime>=score.config.stopAt-.15)return;
         if (doc.__kkptPortalObserver) return;
         try{
        const root = doc.querySelector('#mw-content-text,.mw-parser-output');
            sessionStorage.setItem(SCORE_KEY,JSON.stringify({
        if (!root) return;
                stamp:Date.now(),time:score.audio.currentTime,
         let pending=false;
                 target:score.config.target,
        const observer = new MutationObserver(() => {
                 expires:Date.now()+115000,revision:34
            if(pending)return;
             }));
            pending=true;
         }catch(err){
            setTimeout(()=>{
            console.warn('[KKPT] Cannot preserve soundtrack position:',err);
                 pending=false;
         }
                 decoratePortalLinks(doc,targetPage);
             },120);
         });
        observer.observe(root, {childList:true,subtree:true});
         doc.__kkptPortalObserver = observer;
     }
     }
 
     function restoreReturnScoreHandoff(){
     function isPlainClick(event) {
         if(window!==window.top)return false;
         return !event.defaultPrevented && event.button === 0 &&
        let saved=null;
             !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
        try{
            saved=JSON.parse(sessionStorage.getItem(SCORE_KEY));
            sessionStorage.removeItem(SCORE_KEY);
        }catch(_){}
        if(!saved||saved.revision!==34||saved.target!==basePage||
            Date.now()>saved.expires)return false;
        const config=SCORES[saved.target];
        if(!config)return false;
        const currentTarget=()=>saved.time+
            (Date.now()-saved.stamp)/1000;
        if(currentTarget()>=config.stopAt-.15)return false;
        const score=createScore(config);
        const audio=score.audio;
        let attempted=false,blockedAt=null;
        function playFromPosition(){
            const requested=blockedAt===null?currentTarget():blockedAt;
            const position=Math.min(config.stopAt-.12,requested);
            if(position>=config.stopAt-.15){clearReturnScore(score);return;}
            try{audio.currentTime=Math.max(config.startAt,position);}catch(_){}
            Promise.resolve(audio.play()).then(()=>{
                blockedAt=null;
                score.blocked=false;updateAudioToggle(score);
             }).catch(err=>{
                // The only fallback is the SAME small icon, no audio popup.
                console.info('[KKPT] Browser requires an audio click:',err);
                blockedAt=position;score.blocked=true;
                updateAudioToggle(score);
            });
        }
        score.resume=()=>{
            if(blockedAt!==null){
                try{audio.currentTime=blockedAt;}catch(_){}
            }
            return Promise.resolve(audio.play()).then(value=>{
                blockedAt=null;return value;
            });
        };
        const autoplay=()=>{
            if(attempted)return;attempted=true;
            playFromPosition();
        };
        audio.addEventListener('loadedmetadata',autoplay,{once:true});
        audio.load();
        setTimeout(()=>{if(audio.readyState>=1)autoplay();},1100);
        return true;
     }
     }


     function findAnchor(event, doc) {
    let active = false; // true only during the visual handoff; destination navigates natively
         const target = event.target;
    let animating = false;
         return target && target.nodeType === 1 &&
    let gearPending = false;
             typeof target.closest === 'function'
    let ready = false;
             ? target.closest('a[href]') : null;
    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 portalClick(event, doc, targetPage, nextActive) {
     function updateButton() {
         if (!isPlainClick(event)) return false;
         if (!button) return;
         const anchor = findAnchor(event, doc);
         const to = destination();
         if (!anchor || (anchor.target && anchor.target !== '_self') ||
         button.href = to === basePage ? baseUrl : otherUrl.href;
            anchor.hasAttribute('download') ||
        labelTitle.textContent = to.toUpperCase();
            wikiLinkPage(anchor, doc) !== targetPage) return false;
         labelSub.textContent = isDark(to) ? 'BURN THE SKY' : 'RECLAIM THE SKY';
         event.preventDefault();
         labelIcon.textContent = isDark(to) ? '✦' : '❄';
         primeSound(); // Synchronous click activation unlocks Web Audio.
         button.classList.toggle('kkpt-dark', isDark(whichPage()));
         if(anchor.classList.contains('kkpt-fate-gear')) {
         button.setAttribute('aria-label', isDark(to) ? '燃尽天空,进入 Kuronata' : '驱散黑暗,回到 Kuranoka');
            beginFateSwap(anchor,nextActive);
         } else {
            requestSwap(nextActive);
        }
        return true;
     }
     }


     // The Infobox gear begins to turn before the actual world changes.
     // Accept the two standard MediaWiki link forms, while rejecting
     function beginFateSwap(anchor,nextActive) {
    // edit/action links, external sites and similarly named articles.
         if(animating||gearPending||nextActive===active)return;
     function wikiLinkPage(anchor, doc) {
        gearPending=true;
         try {
        anchor.classList.add('kkpt-fate-gear-turning');
            const url = new URL(anchor.href, doc.baseURI);
        setTimeout(()=>{
            if (url.origin !== location.origin) return null;
            gearPending=false;
            const action = url.searchParams.get('action');
             requestSwap(nextActive);
            if (action && action !== 'view') return null;
        },570);
             let title = url.searchParams.get('title');
        setTimeout(()=>anchor.classList.remove('kkpt-fate-gear-turning'),4800);
            if (!title) {
    }
                const matched = url.pathname.match(/^\/wiki\/([^/?#]+)\/?$/);
 
                if (matched) title = decodeURIComponent(matched[1]);
    function fateGearShape() {
             }
        const parts=[];
            title = (title || '').replace(/_/g, ' ');
        for(let i=0;i<48;i++) {
             return title === 'Kuranoka' || title === 'Kuronata' ? title : null;
             const a=i/48*Math.PI*2-Math.PI/2;
        } catch (_) {
             const r=(i%4===0||i%4===3)?21:16.8;
            return null;
            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) {
     function decoratePortalLinks(doc, targetPage) {
         if(doc.querySelector('.kkpt-fate-gear'))return;
         // Use only the article: sidebar, edit tools and site-wide links
         const content=doc.querySelector('#mw-content-text,.mw-parser-output');
        // must retain their usual navigation behavior.
         if(!content)return;
         const content = doc.querySelector('#mw-content-text,.mw-parser-output');
         // The fate wheel stays inside the native Infobox caption, but is
         if (!content) return;
        // positioned independently: it must NEVER push the title off-center.
         content.querySelectorAll('a[href]').forEach(anchor => {
        const box=content.querySelector('table.infobox.vcard,table.infobox');
             if(anchor.classList.contains('kkpt-fate-gear'))return;
        const host=box?.caption ||
            if (wikiLinkPage(anchor, doc) !== targetPage) return;
             box?.querySelector('th.infobox-above,th.infobox-title,th[colspan]')||
            anchor.classList.add('kkpt-portal-link');
            doc.querySelector('#firstHeading .mw-page-title-main,'+
            anchor.classList.toggle('kkpt-portal-red', targetPage === 'Kuronata');
                '.mw-page-title-main,#firstHeading');
             anchor.classList.toggle('kkpt-portal-blue', targetPage === 'Kuranoka');
        if(!host)return;
             const caption = (anchor.textContent || '').trim();
        host.classList.add('kkpt-fate-host');
             if (caption) anchor.dataset.kkptText = caption.slice(0, 120);
        const anchor=doc.createElement('a');
            anchor.setAttribute('aria-label', caption + ',切换至 ' + targetPage);
        anchor.className='kkpt-fate-gear';
         });
        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>';
        // Critical inline dimensions: if page-specific CSS arrives late,
        // a raw SVG must NEVER expand into the giant black gear again.
        host.style.position='relative';
        anchor.style.cssText='position:absolute;right:7px;top:7px;'+
            'width:26px;height:26px;padding:4px;box-sizing:border-box;'+
            'display:grid;place-items:center;float:none;z-index:3;'+
            'color:#e5eff5;background:rgba(30,45,60,.35);'+
             'border:1px solid rgba(255,255,255,.30);border-radius:50%';
        anchor.firstElementChild?.setAttribute('width','100%');
        anchor.firstElementChild?.setAttribute('height','100%');
         host.appendChild(anchor);
 
        // Fixed corner placement: no title measurements, ResizeObserver
        // or reflow during the animation.
     }
     }


    // Read backgrounds after background-template code populates --bg-image.
     function watchPortalLinks(doc, targetPage) {
     function backgroundUrls(doc) {
         decoratePortalLinks(doc, targetPage);
         const urls = new Set();
         if (doc.__kkptPortalObserver) return;
         const selector = [doc.documentElement, doc.body];
         const root = doc.querySelector('#mw-content-text,.mw-parser-output');
         try {
        if (!root) return;
            doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container')
        let pending=false;
                .forEach((el, i) => { if (i < 18) selector.push(el); });
         const observer = new MutationObserver(() => {
         } catch (e) { /* use html/body */ }
             if(pending)return;
        function add(value) {
             pending=true;
             if (!value) return;
             setTimeout(()=>{
             const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
                 pending=false;
            let match;
                decoratePortalLinks(doc,targetPage);
             while ((match = pattern.exec(value))) {
             },120);
                 try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
         });
             }
         observer.observe(root, {childList:true,subtree:true});
            if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) {
        doc.__kkptPortalObserver = observer;
                try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
    }
            }
 
         }
    function isPlainClick(event) {
         selector.forEach(el => {
         return !event.defaultPrevented && event.button === 0 &&
            if (!el) return;
            !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
            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) {
     function findAnchor(event, doc) {
        if (imagePool.has(url)) return imagePool.get(url).promise;
         const target = event.target;
         const image = new Image();
         return target && target.nodeType === 1 &&
         const promise = new Promise(resolve => {
             typeof target.closest === 'function'
            let finished = false;
             ? target.closest('a[href],button.kkpt-fate-gear') : null;
            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) {
     function portalClick(event, doc, targetPage, nextActive) {
         const urls = backgroundUrls(doc);
        if (!isPlainClick(event)) return false;
         await Promise.allSettled(urls.map(warmImage));
         const anchor = findAnchor(event, doc);
    }
         const gear=anchor?.classList.contains('kkpt-fate-gear')&&
 
            anchor.dataset.kkptTargetPage===targetPage;
    function wireFrame() {
        if (!anchor || (anchor.target && anchor.target !== '_self') ||
        try {
             anchor.hasAttribute('download') ||
             const doc = frame.contentDocument;
             (!gear&&wikiLinkPage(anchor, doc)!==targetPage)) return false;
             if (!doc) return;
        event.preventDefault();
            otherTitle = doc.title || otherTitle;
        // Both directions start playback during the trusted user gesture.
            // The preview exists only for the animated reveal. Pausing
        armReturnScore(targetPage);
            // its hidden ambience avoids doubling ongoing render work.
        primeSound(); // Synchronous click activation unlocks Web Audio.
            doc.documentElement.classList.add('kkpt-frame-inactive');
        if(gear) {
             watchPortalLinks(doc, basePage);
             beginFateSwap(anchor,nextActive);
             mountFateGear(doc, basePage);
        } else {
            if (doc.__kkptFrameBridge) return;
             requestSwap(nextActive);
            doc.__kkptFrameBridge = true;
        }
        return true;
    }


            // Non-anchor navigation should leave the iframe and open normally
    // The Infobox gear begins to turn before the actual world changes.
            // in the top window, so the browser's URL doesn't become stale.
    function beginFateSwap(anchor,nextActive) {
            doc.addEventListener('click', event => {
        if(animating||gearPending||nextActive===active)return;
                if (!active || !isPlainClick(event)) return;
        gearPending=true;
                if (portalClick(event, doc, basePage, false)) return;
        anchor.classList.add('kkpt-fate-gear-turning');
                const el = findAnchor(event, doc);
        setTimeout(()=>{
                if (!el || (el.target && el.target !== '_self')) return;
             gearPending=false;
                const href = el.href;
             requestSwap(nextActive);
                if (!href || href.startsWith('javascript:')) return;
        },570);
                const next = new URL(href, doc.baseURI);
         setTimeout(()=>anchor.classList.remove('kkpt-fate-gear-turning'),4800);
                // Same-document fragments stay inside the temporary preview.
                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();
                navigateRealPage(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() {
     function fateGearShape() {
         if (loading) return loading;
         const parts=[];
         frame = document.createElement('iframe');
         for(let i=0;i<48;i++) {
        frame.id = 'kkpt-preview';
            const a=i/48*Math.PI*2-Math.PI/2;
        frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop
            const r=(i%4===0||i%4===3)?21:16.8;
        frame.title = otherPage + ' 预载页面';
            parts.push((i?'L':'M')+(24+Math.cos(a)*r).toFixed(2)+' '+
        frame.tabIndex = -1;
                (24+Math.sin(a)*r).toFixed(2));
        frame.setAttribute('aria-hidden', 'true');
         }
        frame.setAttribute('loading', 'eager');
         return parts.join(' ')+' Z';
        frame.style.clipPath = hideClip;
    }
         // Load all resources in advance, but don't composite the hidden
         // full-screen Wiki until its first transition frame.
        frame.style.visibility = 'hidden';


        loading = new Promise(resolve => {
    function mountFateGear(doc,targetPage) {
            let settled = false;
        if(doc.querySelector('.kkpt-fate-gear'))return;
            let preparing = false;
        const content=doc.querySelector('#mw-content-text,.mw-parser-output');
            let poll = null;
        if(!content)return;
            function finish(ok, reason) {
        // The fate wheel stays inside the native Infobox caption, but is
                if (settled) return;
        // positioned independently: it must NEVER push the title off-center.
                settled = true;
        const box=content.querySelector('table.infobox.vcard,table.infobox');
                clearInterval(poll);
        const host=box?.caption ||
                ready = ok;
            box?.querySelector('th.infobox-above,th.infobox-title,th[colspan]')||
                if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason);
            doc.querySelector('#firstHeading .mw-page-title-main,'+
                resolve(ok);
                '.mw-page-title-main,#firstHeading');
            }
        if(!host)return;
            async function inspect() {
        host.classList.add('kkpt-fate-host');
                if (settled || preparing) return;
        const anchor=doc.createElement('button');
                try {
        anchor.type='button';
                    const doc = frame.contentDocument;
        anchor.className='kkpt-fate-gear';
                    if (!doc || !doc.querySelector('#mw-content-text,.mw-parser-output')) return;
        // A native button cannot be mistaken for an article link by
                    preparing = true;
        // MediaWiki Popups, including in previews loaded into iframes.
                    clearInterval(poll);
        anchor.dataset.kkptTargetPage=targetPage;
                    await sleep(320); // Background.js / wiki style scripts
        const gearLabel=targetPage==='Kuronata'
                    await Promise.race([warmBackgrounds(doc), sleep(3600)]);
             ?'命运的齿轮 · 唤醒 Kuronata':'命运的齿轮 · 回到 Kuranoka';
                    await sleep(120);
        anchor.setAttribute('aria-label',gearLabel);
                    await Promise.race([warmBackgrounds(doc), sleep(1300)]);
        anchor.innerHTML='<svg class="kkpt-fate-gear-svg" viewBox="0 0 48 48" '+
                    frame.contentWindow.scrollTo(0, 0);
             'aria-hidden="true" focusable="false"><path d="'+fateGearShape()+
                    wireFrame();
             '"/><circle cx="24" cy="24" r="8.5"/>'+
                    finish(true);
             '<circle cx="24" cy="24" r="3"/></svg>';
                } catch (err) { finish(false, String(err)); }
         host.appendChild(anchor);
            }
            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(() => {});
         // Fixed corner placement: no title measurements, ResizeObserver
         setTimeout(() => warmBackgrounds(document).catch(() => {}), 700);
         // or reflow during the animation.
        return loading;
     }
     }


 
     // Read backgrounds after background-template code populates --bg-image.
     // Shared, low-resolution atmospherics: return of V14's dark smoke,
     function backgroundUrls(doc) {
    // long-lived torn paper, shockwaves, blue-white petals and pollen.
         const urls = new Set();
    // Only the combustion CURTAIN is replaced by the bright V2 engine.
         const selector = [doc.documentElement, doc.body];
    let atmosphereAssetCache=null;
         try {
    const atmosphereNoiseCache=new Map();
             doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container')
     function createAtmosphere(canvas,buildOneOnly=false,foreground=null) {
                .forEach((el, i) => { if (i < 18) selector.push(el); });
        // V30: background soot never touches the canvas above the GPU flame.
         } catch (e) { /* use html/body */ }
         const backCtx=canvas.getContext('2d',{alpha:true});
         function add(value) {
         const fireCtx=foreground?.getContext('2d',{alpha:true})||null;
            if (!value) return;
         let ctx=backCtx;
             const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
        const rand = n => {
             let match;
             const cached=atmosphereNoiseCache.get(n);
             while ((match = pattern.exec(value))) {
            if(cached!==undefined)return cached;
                try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
            }
            const value=x-Math.floor(x);
             if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) {
            atmosphereNoiseCache.set(n,value);
                try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
            return value;
            }
         };
         }
        const clamp01 = n => Math.max(0,Math.min(1,n));
         selector.forEach(el => {
         function valueNoise(x,y,seed) {
             if (!el) return;
             const ix=Math.floor(x), iy=Math.floor(y);
             try {
             let fx=x-ix,fy=y-iy;
                [null, '::before', '::after'].forEach(pseudo => {
             fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
                    const style = doc.defaultView.getComputedStyle(el, pseudo);
            const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
                    if (!style) return;
            const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
                    add(style.backgroundImage);
             const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
                    add(style.getPropertyValue('--bg-image'));
            return a*(1-fy)+b*fy;
                    add(style.getPropertyValue('--background-image'));
         }
                 });
         function smokeTexture(seed,dark) {
                 if (el.dataset) add(el.dataset.bgImage);
             const tex=document.createElement('canvas');
            } catch (e) { /* style not yet ready */ }
             tex.width=tex.height=112;
        });
            const g=tex.getContext('2d');
        return [...urls];
            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;
    function warmImage(url) {
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
        if (imagePool.has(url)) return imagePool.get(url).promise;
                 const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
        const image = new Image();
                 const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
        const promise = new Promise(resolve => {
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
            let finished = false;
                const envelope=clamp01((1.05-radius)/.56);
            const timeout = setTimeout(() => done(false), 5000);
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
            function done(ok) {
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
                 if (finished) return;
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
                finished = true;
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
                 clearTimeout(timeout);
                    envelope*envelope;
                 resolve(ok);
                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);
             image.onload = () => done(true);
             return tex;
             image.onerror = () => done(false);
        }
             image.src = url;
        // V2-inspired white-hot additive sprites, but with noise-eroded
             if (image.complete) done(image.naturalWidth > 0);
        // silhouettes instead of repetitive smooth, elliptical balls.
        }).then(ok => ok && image.decode
        function glowTexture(seed,mode) {
             ? Promise.race([image.decode().then(() => true).catch(() => true),sleep(900).then(() => true)])
            const tex=document.createElement('canvas');
             : ok);
             tex.width=96;tex.height=128;
        imagePool.set(url, { image, promise });
             const g=tex.getContext('2d');
        return promise;
            const gradient=g.createRadialGradient(48,68,0,48,68,64);
    }
             const colors=mode==='fire'
 
                ? [[0,'#fffef1'],[.14,'#fff4bb'],[.34,'#ffd04d'],
    async function warmBackgrounds(doc) {
                  [.55,'#ff841bcc'],[.80,'#bc290749'],[1,'#6e030000']]
        const urls = backgroundUrls(doc);
                : mode==='blue'
        await Promise.allSettled(urls.map(warmImage));
                ? [[0,'#ffffff'],[.19,'#f1ffff'],[.40,'#a2efff'],
    }
                  [.68,'#5298ff99'],[1,'#345bf000']]
 
                : [[0,'#ffffff'],[.19,'#fff8cd'],[.49,'#ff9b28cc'],
    function wireFrame() {
                  [1,'#ff380000']];
        try {
            colors.forEach(([p,c])=>gradient.addColorStop(p,c));
            const doc = frame.contentDocument;
             g.fillStyle=gradient;g.fillRect(0,0,96,128);
            if (!doc) return;
            const img=g.getImageData(0,0,96,128);
            otherTitle = doc.title || otherTitle;
            for(let y=0;y<128;y++)for(let x=0;x<96;x++){
            // The preview exists only for the animated reveal. Pausing
                const u=(x-48)/48,v=(y-64)/64;
            // its hidden ambience avoids doubling ongoing render work.
                const cloud=valueNoise(u*4+seed*1.9,v*4+seed*.7,seed+18);
            doc.documentElement.classList.add('kkpt-frame-inactive');
                const fiber=valueNoise(u*11+3,v*8+17,seed+41);
            watchPortalLinks(doc, basePage);
                const shifted=u+.18*(cloud-.5);
            mountFateGear(doc, basePage);
                const irregular=clamp01((1.15-Math.abs(shifted))/.52)*
             if (doc.__kkptFrameBridge) return;
                    clamp01((1.05-Math.abs(v+.12*(fiber-.5)))/.42);
             doc.__kkptFrameBridge = true;
                const n=.59+.77*cloud+.28*fiber;
 
                const alphaIndex=(y*96+x)*4+3;
            // Non-anchor navigation should leave the iframe and open normally
                img.data[alphaIndex]=Math.round(img.data[alphaIndex]*
            // in the top window, so the browser's URL doesn't become stale.
                    irregular*clamp01(n));
            doc.addEventListener('click', event => {
             }
                if (!active || !isPlainClick(event)) return;
            g.putImageData(img,0,0);
                if (portalClick(event, doc, basePage, false)) return;
             return tex;
                const el = findAnchor(event, doc);
        }
                if (!el || (el.target && el.target !== '_self')) return;
        // V25: the profiler found one 270ms long task here. Build the
                const href = el.href;
        // EXACT SAME 17 textures in separate tasks, never all at once.
                if (!href || href.startsWith('javascript:')) return;
        // The stable noise cache avoids repeating the costly sin hash.
                const next = new URL(href, doc.baseURI);
        const cache=atmosphereAssetCache ||= {
                // Same-document fragments stay inside the temporary preview.
            sootSprites:[],mistSprites:[],FIRE_GLOW:[],BLUE_GLOW:[],
                const currentFrame = new URL(frame.contentWindow.location.href);
            EMBER_GLOW:null
                if (next.origin === currentFrame.origin &&
        };
                    next.pathname === currentFrame.pathname &&
        function buildNextAsset(){
                    next.search === currentFrame.search && next.hash) {
            let n=cache.sootSprites.length;
                    history.replaceState(history.state, '', next.href);
            if(n<4){cache.sootSprites.push(smokeTexture(n+17,true));return true;}
                    return;
            n=cache.mistSprites.length;
                }
            if(n<4){cache.mistSprites.push(smokeTexture(n+51,false));return true;}
                event.preventDefault();
            n=cache.FIRE_GLOW.length;
                navigateRealPage(next.href);
            if(n<4){cache.FIRE_GLOW.push(glowTexture(n+1,'fire'));return true;}
             }, true);
            n=cache.BLUE_GLOW.length;
 
             if(n<4){cache.BLUE_GLOW.push(glowTexture(n+8,'blue'));return true;}
            doc.addEventListener('submit', event => {
            if(!cache.EMBER_GLOW){
                if (active && event.target instanceof doc.defaultView.HTMLFormElement) {
                cache.EMBER_GLOW=glowTexture(21,'ember');return true;
                    event.target.target = '_top';
            }
                }
             return false;
            }, true);
        } catch (err) {
             console.warn('[KuranokaPageTransition] Frame link bridge unavailable:', err);
         }
         }
         if(buildOneOnly)return buildNextAsset();
    }
         // Normally prewarming is already complete before a user clicks.
 
         // The fallback also works if an animation is started immediately.
    function warmFrame() {
         while(buildNextAsset()){}
         if (loading) return loading;
         const {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW}=cache;
        frame = document.createElement('iframe');
         const combustion=[];
         frame.id = 'kkpt-preview';
         const residualSoot=[];
        frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop
         const tornPaper=[];
        frame.title = otherPage + ' 预载页面';
         let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0;
         frame.tabIndex = -1;
         function advanceCombustion(seconds,progress,dark,elapsed,w,h) {
         frame.setAttribute('aria-hidden', 'true');
             const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed));
         frame.setAttribute('loading', 'eager');
             lastElapsed=elapsed;
         frame.style.clipPath = hideClip;
             const live=progress>.01&&progress<.985;
         // Load all resources in advance, but don't composite the hidden
             if(live){
         // full-screen Wiki until its first transition frame.
                 // Restore the actual V13 flame density: the V27 reduction
         frame.style.visibility = 'hidden';
                 // from 370 to 70 removed most of P3's oppressive fire wall.
 
                 const flow=(dark?370:115)*Math.min(1.5,h/720)*
         loading = new Promise(resolve => {
                    (.75+.25*Math.sin(Math.PI*progress));
             let settled = false;
                 spawnRemainder+=dt*flow;
             let preparing = false;
                 const births=Math.min(65,Math.floor(spawnRemainder));
             let poll = null;
                 spawnRemainder-=births;
             function finish(ok, reason) {
                for(let j=0;j<births&&combustion.length<1320;j++){
                 if (settled) return;
                    const y=Math.random()*h;
                 settled = true;
                    const edge=edgeAt(y,seconds,progress,w);
                 clearInterval(poll);
                     const r=Math.random();
                 ready = ok;
                     const kind=dark
                 if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason);
                        ? r<.36?'flame':r<.89?'ember':'ash'
                 resolve(ok);
                        : r<.37?'glow':r<.81?'leaf':'pollen';
            }
                     const small=kind==='ember'||kind==='pollen';
            async function inspect() {
                     const soot=kind==='ash';
                if (settled || preparing) return;
                     const life=dark
                try {
                        ? (soot?.7+Math.random()*1.4:
                     const doc = frame.contentDocument;
                            small?1.05+Math.random()*1.75:.42+Math.random()*.76)
                     if (!doc || !doc.querySelector('#mw-content-text,.mw-parser-output')) return;
                        : 1.15+Math.random()*1.55;
                     preparing = true;
                     combustion.push({
                     clearInterval(poll);
                        kind,x:edge+(dark?1:-1)*(Math.random()*47-8),
                     await sleep(320); // Background.js / wiki style scripts
                        y:y+(Math.random()-.5)*25,
                    await Promise.race([warmBackgrounds(doc), sleep(3600)]);
                        vx:(dark?1:-1)*(dark?(small?85+Math.random()*320:
                    await sleep(120);
                            14+Math.random()*154):
                     await Promise.race([warmBackgrounds(doc), sleep(1300)]);
                            16+Math.random()*69),
                    frame.contentWindow.scrollTo(0, 0);
                        vy:-(dark?(small?12+Math.random()*205:
                    wireFrame();
                            32+Math.random()*171):12+Math.random()*57),
                    finish(true);
                        life,remaining:life,
                } catch (err) { finish(false, String(err)); }
                        radius:dark
            }
                            ? (soot?1.2+Math.random()*2.7:
            frame.addEventListener('load', () => {
                                small?2+Math.random()*4.8:9+Math.random()*19)
                if (!settled && !preparing) inspect();
                            : kind==='glow'?26+Math.random()*49:
            });
                                kind==='leaf'?4+Math.random()*6:
            frame.addEventListener('error', () => finish(false,'iframe 网络加载错误'));
                                2+Math.random()*3,
            document.body.append(frame);
                        angle:(Math.random()-.5)*1.1,
            poll = setInterval(inspect, 130);
                        sprite:(Math.random()*4)|0,
            setTimeout(() => { if (!settled && !preparing) finish(false,'18 秒仍未找到目标正文'); }, 18000);
                        seed:Math.random()*6.28
        });
                    });
 
                }
        warmBackgrounds(document).catch(() => {});
                if(dark) {
        setTimeout(() => warmBackgrounds(document).catch(() => {}), 700);
                    // Unlike the traveling front, these black smoke plumes
        return loading;
                    // are born in world-space and remain over burnt paper.
    }
                    sootRemainder+=dt*(h<680?41:62);
 
                    const sootBirths=Math.min(11,Math.floor(sootRemainder));
 
                    sootRemainder-=sootBirths;
    // Shared, low-resolution atmospherics: return of V14's dark smoke,
                    for(let i=0;i<sootBirths&&residualSoot.length<(w<700?125:225);i++){
    // long-lived torn paper, shockwaves, blue-white petals and pollen.
                        const y=Math.random()*h;
    // Only the combustion CURTAIN is replaced by the bright V2 engine.
                        const life=2.15+Math.random()*1.60;
    let atmosphereAssetCache=null;
                        residualSoot.push({
    const atmosphereNoiseCache=new Map();
                            x:edgeAt(y,seconds,progress,w)+5+Math.random()*83,
    function createAtmosphere(canvas,buildOneOnly=false) {
                            y:y+(Math.random()-.5)*48,
        const ctx = canvas.getContext('2d', {alpha:true});
                            vx:8+Math.random()*39,vy:-18-Math.random()*83,
        const rand = n => {
                            size:155+Math.random()*195,life,remaining:life,
            const cached=atmosphereNoiseCache.get(n);
                            sprite:(Math.random()*4)|0,
            if(cached!==undefined)return cached;
                            spin:(Math.random()-.5)*.8,
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
                            seed:Math.random()*6.28
            const value=x-Math.floor(x);
                        });
            atmosphereNoiseCache.set(n,value);
                    }
            return value;
                    // Charcoal sheets tear off in irregular polygons, not
        };
                    // squares or narrow glitch streaks.
        const clamp01 = n => Math.max(0,Math.min(1,n));
                    shardRemainder+=dt*(h<680?41:69);
        function valueNoise(x,y,seed) {
                    const shardBirths=Math.min(13,Math.floor(shardRemainder));
            const ix=Math.floor(x), iy=Math.floor(y);
                    shardRemainder-=shardBirths;
            let fx=x-ix,fy=y-iy;
                    for(let i=0;i<shardBirths&&tornPaper.length<330;i++){
            fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
                        const y=Math.random()*h;
             const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
                        const life=1.00+Math.random()*2.05;
             const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
                        const points=8+Math.floor(Math.random()*8);
             const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
                        const outline=Array.from({length:points},(_,j)=>({
            return a*(1-fy)+b*fy;
                            a:Math.PI*2*j/points+(Math.random()-.5)*.52,
                            r:.20+Math.random()*1.16
                        }));
                        tornPaper.push({
                            x:edgeAt(y,seconds,progress,w)+(Math.random()-.2)*66,
                            y, vx:58+Math.random()*330,
                            vy:-210+Math.random()*295,
                            gravity:90+Math.random()*150,
                            radius:2+Math.pow(Math.random(),2.7)*23,
                            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?54:85},(_,i)=>({
         function smokeTexture(seed,dark) {
             seed:rand(i*4+71),height:rand(i*5+13),
             const tex=document.createElement('canvas');
             lifetime:.5+rand(i*8+37)*.7,
            tex.width=tex.height=112;
             phase:rand(i*9+21),sprite:i%4,
             const g=tex.getContext('2d');
             scale:.72+rand(i*7+9)*1.25,
             const data=g.createImageData(112,112);
            bend:rand(i*15+23)*6.28
             for(let y=0;y<112;y++) for(let x=0;x<112;x++){
        }));
                const u=(x-56)/56,v=(y-56)/56;
        const motes=Array.from({length:innerWidth<700?92:175},(_,i)=>({
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
            y:rand(i*3+17),phase:rand(i*9+43),
                const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
            speed:105+rand(i*12+5)*410,
                const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
            drift:90+rand(i*15+2)*460,
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
            length:5+rand(i*17+7)*26
                const envelope=clamp01((1.05-radius)/.56);
        }));
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
        const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
            angle:rand(i*17+131)*Math.PI*2,
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
            velocity:190+rand(i*11+51)*900,
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
            seed:rand(i*5+79),
                    envelope*envelope;
            length:6+rand(i*7+17)*26
                const i=(y*112+x)*4;
        }));
                const variation=low*28+high*15;
        function pressureWave(x,y,radius,power,dark,time) {
                if(dark){
            if(power<=.005)return;
                    data.data[i]=5+variation*.37;
            const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
                    data.data[i+1]=5+variation*.33;
            if(dark){
                    data.data[i+2]=8+variation*.38;
                core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
                 }else{
                core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
                    data.data[i]=44+variation;
                core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')');
                    data.data[i+1]=91+variation;
                 core.addColorStop(1,'rgba(30,0,0,0)');
                    data.data[i+2]=125+variation;
            }else{
                 }
                core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')');
                data.data[i+3]=Math.round(240*density);
                core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')');
                 core.addColorStop(1,'rgba(0,29,100,0)');
             }
             }
             ctx.globalCompositeOperation='screen';
             g.putImageData(data,0,0);
            ctx.fillStyle=core;
             return tex;
            ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
             ctx.globalCompositeOperation='source-over';
         }
         }
         // Rebuilt from V25's layered incandescent contour, but drawn ONLY
         // V2-inspired white-hot additive sprites, but with noise-eroded
        // on the separate post-WebGL highlights canvas. Background soot
         // silhouettes instead of repetitive smooth, elliptical balls.
         // can therefore never flatten its brightness.
         function glowTexture(seed,mode) {
         function drawV25HotFibers(seconds,progress,fade,w,h){
             const tex=document.createElement('canvas');
             const edge=y=>edgeAt(y,seconds,progress,w);
             tex.width=96;tex.height=128;
             ctx.save();
             const g=tex.getContext('2d');
             ctx.globalCompositeOperation='lighter';
             const gradient=g.createRadialGradient(48,68,0,48,68,64);
             ctx.beginPath();
             const colors=mode==='fire'
             for(let y=-12,i=0;y<=h+12;y+=4.0,i++){
                 ? [[0,'#fffef1'],[.14,'#fff4bb'],[.34,'#ffd04d'],
                 if(i===0)ctx.moveTo(edge(y),y);
                  [.55,'#ff841bcc'],[.80,'#bc290749'],[1,'#6e030000']]
                else ctx.lineTo(edge(y),y);
                : mode==='blue'
            }
                ? [[0,'#ffffff'],[.19,'#f1ffff'],[.40,'#a2efff'],
            ctx.lineJoin='round';
                  [.68,'#5298ff99'],[1,'#345bf000']]
            ctx.lineCap='round';
                : [[0,'#ffffff'],[.19,'#fff8cd'],[.49,'#ff9b28cc'],
            ctx.globalAlpha=.72*fade;
                  [1,'#ff380000']];
            ctx.shadowColor='#ff2903';
             colors.forEach(([p,c])=>gradient.addColorStop(p,c));
            ctx.shadowBlur=18;
             g.fillStyle=gradient;g.fillRect(0,0,96,128);
            ctx.strokeStyle='#ff3008';
             const img=g.getImageData(0,0,96,128);
            ctx.lineWidth=19;
             for(let y=0;y<128;y++)for(let x=0;x<96;x++){
            ctx.stroke();
                 const u=(x-48)/48,v=(y-64)/64;
            ctx.globalAlpha=.83*fade;
                 const cloud=valueNoise(u*4+seed*1.9,v*4+seed*.7,seed+18);
            ctx.shadowColor='#ffa620';
                 const fiber=valueNoise(u*11+3,v*8+17,seed+41);
            ctx.shadowBlur=12;
                 const shifted=u+.18*(cloud-.5);
            ctx.strokeStyle='#ff9c12';
                const irregular=clamp01((1.15-Math.abs(shifted))/.52)*
            ctx.lineWidth=9;
                    clamp01((1.05-Math.abs(v+.12*(fiber-.5)))/.42);
            ctx.stroke();
                const n=.59+.77*cloud+.28*fiber;
            ctx.globalAlpha=.89*fade;
                const alphaIndex=(y*96+x)*4+3;
            ctx.shadowColor='#ffef83';
                 img.data[alphaIndex]=Math.round(img.data[alphaIndex]*
            ctx.shadowBlur=8;
                     irregular*clamp01(n));
            ctx.strokeStyle='#ffdf54';
             ctx.lineWidth=5;
            ctx.stroke();
             ctx.globalAlpha=.93*fade;
            ctx.shadowColor='#fff9dd';
             ctx.shadowBlur=4;
            ctx.strokeStyle='#fffce8';
            ctx.lineWidth=1.9;
            ctx.stroke();
             ctx.shadowBlur=0;
 
            // V25's incandescent pockets: stretched, flickering vertical
            // tongues without a regular grid or a horizontal glowing band.
            for(let j=0,y=-66;y<h+90;j++,y+=37){
                 const oscillation=Math.sin(seconds*7.3+j*2.71);
                 const heat=.55+.45*Math.sin(seconds*4.6+j*1.77)**2;
                const xx=edge(y)+12+22*Math.sin(j*1.91+seconds*2.2);
                 const sprite=FIRE_GLOW[j%4];
                ctx.globalAlpha=(.21+.28*heat)*fade;
                 const rw=62+28*heat,rh=72+24*oscillation;
                ctx.drawImage(sprite,xx-rw*.51,y-rh*.58,
                    rw*1.45,rh*1.62);
            }
            ctx.restore();
        }
        return function drawAtmosphere(seconds,progress,dark,elapsed,cells) {
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.56:.69;
            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=backCtx;
            ctx.setTransform(scale,0,0,scale,0,0);
            ctx.clearRect(0,0,w,h);
            if(fireCtx){
                 if(foreground.width!==bw||foreground.height!==bh){
                     foreground.width=bw;foreground.height=bh;
                }
                fireCtx.setTransform(scale,0,0,scale,0,0);
                fireCtx.clearRect(0,0,w,h);
             }
             }
             const envelope=Math.sin(Math.PI*progress);
             g.putImageData(img,0,0);
            const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
             return tex;
            advanceCombustion(seconds,progress,dark,elapsed,w,h);
        }
             const sprites=dark?sootSprites:mistSprites;
        // V25: the profiler found one 270ms long task here. Build the
            // New-world side: red fire trails to the right when burning,
        // EXACT SAME 17 textures in separate tasks, never all at once.
            // blue dawn trails to the left when returning.
        // The stable noise cache avoids repeating the costly sin hash.
            const direction=dark?1:-1;
        const cache=atmosphereAssetCache ||= {
             // Broken smoke banks with different depths and sizes. The old
             sootSprites:[],mistSprites:[],FIRE_GLOW:[],BLUE_GLOW:[],
             // aligned rows created an artificial horizontal fog ribbon.
             EMBER_GLOW:null
            ctx.globalCompositeOperation='source-over';
        };
             const bands=Math.max(8,Math.ceil(h/(dark?116:91)));
        function buildNextAsset(){
             for(let i=0;i<bands;i++){
             let n=cache.sootSprites.length;
                const baseY=(i+.15+rand(i*11+39)*.70)*h/bands;
             if(n<4){cache.sootSprites.push(smokeTexture(n+17,true));return true;}
                const y=baseY+31*Math.sin(seconds*1.6+i*1.47);
            n=cache.mistSprites.length;
                const x=edgeAt(y,seconds,progress,w)+direction*
            if(n<4){cache.mistSprites.push(smokeTexture(n+51,false));return true;}
                    (dark?76+rand(i*19+6)*260:120)+
            n=cache.FIRE_GLOW.length;
                    Math.sin(seconds*.93+i*1.3)*37;
            if(n<4){cache.FIRE_GLOW.push(glowTexture(n+1,'fire'));return true;}
                const width=dark?170+rand(i*29+12)*220:250;
            n=cache.BLUE_GLOW.length;
                const height=dark?165+rand(i*7+5)*170:245;
            if(n<4){cache.BLUE_GLOW.push(glowTexture(n+8,'blue'));return true;}
                ctx.save();
            if(!cache.EMBER_GLOW){
                ctx.globalAlpha=(dark?.34:.21)*fade*(.55+.45*envelope);
                 cache.EMBER_GLOW=glowTexture(21,'ember');return true;
                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.
             return false;
            for(const item of particles){
        }
                const age=((elapsed/item.lifetime+item.phase)%1);
        if(buildOneOnly)return buildNextAsset();
                const baseY=item.height*h;
        // Normally prewarming is already complete before a user clicks.
                const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
        // The fallback also works if an animation is started immediately.
                if(y< -230||y>h+230)continue;
        while(buildNextAsset()){}
                const frontier=edgeAt(baseY,seconds,progress,w);
        const {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW}=cache;
                const x=frontier+direction*(age*(dark?225:135)+
        const combustion=[];
                    30*Math.sin(age*6.3+item.bend));
        const residualSoot=[];
                 const size=(dark?174:145)*item.scale*(.68+age*1.9);
        const tornPaper=[];
                const opacity=(dark?.43:.26)*Math.sin(Math.PI*age)*fade*
        let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0;
                    (.55+.45*envelope);
        function advanceCombustion(seconds,progress,dark,elapsed,w,h) {
                 if(opacity<=.005)continue;
            const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed));
                ctx.save();
            lastElapsed=elapsed;
                 ctx.globalAlpha=opacity;
            const live=progress>.01&&progress<.985;
                 ctx.translate(x,y);
            if(live){
                ctx.rotate((item.bend-3.14)*.14+age*.85*direction);
                // More long-lived sparks than V25, far below V31's 370/s.
                ctx.scale(1.18,.84);
                 const flow=(dark?132:96)*Math.min(1.5,h/720)*
                ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size);
                    (.75+.25*Math.sin(Math.PI*progress));
                ctx.restore();
                spawnRemainder+=dt*flow;
            }
                 const births=Math.min(65,Math.floor(spawnRemainder));
            // Residual devastation: coal-black plumes stay pinned to their
                 spawnRemainder-=births;
            // original burn positions, expanding long after the tear passes.
                 for(let j=0;j<births&&combustion.length<1320;j++){
            // Composited *before* fire, so the hot core cuts through smoke.
                    const y=Math.random()*h;
            if(dark){
                    const edge=edgeAt(y,seconds,progress,w);
                ctx.save();
                    const r=Math.random();
                ctx.globalCompositeOperation='source-over';
                    const kind=dark
                for(const p of residualSoot){
                        ? r<.38?'flame':r<.91?'ember':'ash'
                     const age=1-p.remaining/p.life;
                        : r<.37?'glow':r<.81?'leaf':'pollen';
                     const opacity=clamp01(age/.14)*
                    const small=kind==='ember'||kind==='pollen';
                        Math.pow(1-age,.49)*(.66+.24*Math.sin(p.seed*3)**2)*fade;
                     const soot=kind==='ash';
                    if(opacity<.009)continue;
                     const life=dark
                    ctx.save();
                        ? (soot?.7+Math.random()*1.1:
                    ctx.globalAlpha=opacity;
                            small?.9+Math.random()*1.55:.42+Math.random()*.76)
                    ctx.translate(p.x,p.y);
                        : 1.15+Math.random()*1.55;
                    ctx.rotate(p.spin+age*.33*Math.sin(p.seed));
                    combustion.push({
                    ctx.scale(1.20,.92);
                        kind,x:edge+(dark?1:-1)*(Math.random()*47-8),
                    const s=p.size*(1+age*.68);
                        y:y+(Math.random()-.5)*25,
                    ctx.drawImage(sootSprites[p.sprite],-s/2,-s/2,s,s);
                        vx:(dark?1:-1)*(dark?(small?65+Math.random()*245:
                     ctx.restore();
                            14+Math.random()*154):
                            16+Math.random()*69),
                        vy:-(dark?(small?12+Math.random()*190:
                            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
                     });
                 }
                 }
                 ctx.restore();
                 if(dark) {
            }else{
                    // Unlike the traveling front, these black smoke plumes
                // The inverse is a slow, organic unfurling: broad milky
                    // are born in world-space and remain over burnt paper.
                // radiance behind the rebuilt sky, not a cold explosion.
                    sootRemainder+=dt*(h<680?32:46);
                ctx.save();
                    const sootBirths=Math.min(11,Math.floor(sootRemainder));
                ctx.globalCompositeOperation='screen';
                    sootRemainder-=sootBirths;
                for(let n=0;n<3;n++){
                    for(let i=0;i<sootBirths&&residualSoot.length<(w<700?92:155);i++){
                    const yy=(n+.43)*h/3+Math.sin(seconds*.8+n*2)*30;
                        const y=Math.random()*h;
                    const xx=edgeAt(yy,seconds,progress,w)-
                        const life=2.15+Math.random()*1.60;
                        (88+55*Math.sin(n*2.1+seconds*.7));
                        residualSoot.push({
                     const radius=145+65*Math.sin(seconds*.7+n*1.7)**2;
                            x:edgeAt(y,seconds,progress,w)+5+Math.random()*83,
                    const bloom=ctx.createRadialGradient(xx,yy,8,xx,yy,radius);
                            y:y+(Math.random()-.5)*48,
                    bloom.addColorStop(0,'rgba(250,255,255,.10)');
                            vx:8+Math.random()*39,vy:-18-Math.random()*83,
                    bloom.addColorStop(.33,'rgba(164,237,255,.07)');
                            size:155+Math.random()*195,life,remaining:life,
                    bloom.addColorStop(.74,'rgba(117,198,255,.04)');
                            sprite:(Math.random()*4)|0,
                    bloom.addColorStop(1,'rgba(117,198,255,0)');
                            spin:(Math.random()-.5)*.8,
                    ctx.globalAlpha=fade;
                            seed:Math.random()*6.28
                    ctx.fillStyle=bloom;
                        });
                    ctx.fillRect(xx-radius,yy-radius,radius*2,radius*2);
                    }
                    // Charcoal sheets tear off in irregular polygons, not
                    // squares or narrow glitch streaks.
                    shardRemainder+=dt*(h<680?18:30);
                    const shardBirths=Math.min(7,Math.floor(shardRemainder));
                     shardRemainder-=shardBirths;
                    for(let i=0;i<shardBirths&&tornPaper.length<175;i++){
                        const y=Math.random()*h;
                        const life=.90+Math.random()*1.85;
                        const points=8+Math.floor(Math.random()*7);
                        const outline=Array.from({length:points},(_,j)=>({
                            a:Math.PI*2*j/points+(Math.random()-.5)*.50,
                            r:.25+Math.random()*1.06
                        }));
                        tornPaper.push({
                            x:edgeAt(y,seconds,progress,w)+(Math.random()-.20)*58,
                            y, vx:55+Math.random()*295,
                            vy:-190+Math.random()*270,
                            gravity:90+Math.random()*150,
                            radius:2+Math.pow(Math.random(),2.5)*22,
                            angle:Math.random()*6.28,
                            spin:(Math.random()-.5)*7,
                            outline,life,remaining:life,
                            seed:Math.random()*6.28
                        });
                    }
                 }
                 }
                ctx.restore();
             }
             }
            // The impact happens at ignition and again as the rupture reaches
             for(let i=combustion.length-1;i>=0;i--){
            // the middle of the screen; pulses are intentionally fleeting.
                 const p=combustion[i];
             for(const moment of (dark?[.10,.48]:[])){
                p.remaining-=dt;
                 const age=(progress-moment)/.125;
                 if(p.remaining<=0){combustion.splice(i,1);continue;}
                 if(age<0||age>1)continue;
                 p.x+=(p.vx+Math.sin(elapsed*(dark?5:1.6)+p.seed)*
                 const intensity=(1-age)*(1-age)*fade;
                    (dark?22:24))*dt;
                 // A single-frame-ish exposure bloom punches through
                 p.y+=p.vy*dt;
                // the entire page; the tone is red or icy blue by direction.
                 p.vy-=(dark?17:7)*dt;
                ctx.globalCompositeOperation='screen';
            }
                 ctx.fillStyle=dark
            for(let i=residualSoot.length-1;i>=0;i--){
                    ? 'rgba(255,81,26,'+(.095*intensity)+')'
                 const p=residualSoot[i];
                    : 'rgba(150,219,255,'+(.080*intensity)+')';
                 p.remaining-=dt;
                ctx.fillRect(0,0,w,h);
                 if(p.remaining<=0){residualSoot.splice(i,1);continue;}
                ctx.globalCompositeOperation='source-over';
                 p.x+=(p.vx+Math.sin(elapsed*2+p.seed)*27)*dt;
                 const x=edgeAt(h*.51,seconds,progress,w);
                 p.y+=p.vy*dt;
                 const y=h*.51;
                 p.size+=32*dt;
                 const radius=95+age*Math.max(w,h)*.9;
            }
                pressureWave(x,y,radius,intensity,dark,seconds);
            for(let i=tornPaper.length-1;i>=0;i--){
                 ctx.globalAlpha=(dark?.20:.28)*intensity;
                const p=tornPaper[i];
                 ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
                p.remaining-=dt;
                 ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
                if(p.remaining<=0){tornPaper.splice(i,1);continue;}
                ctx.beginPath();
                p.x+=(p.vx+Math.sin(elapsed*4+p.seed)*42)*dt;
                for(let i=0;i<=110;i++){
                p.y+=p.vy*dt;
                    const a=i/110*Math.PI*2;
                p.vy+=p.gravity*dt;
                    const jag=1+.03*Math.sin(a*29+seconds*12)+
                p.angle+=p.spin*dt;
                        .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);
        const particles=Array.from({length:innerWidth<700?40:62},(_,i)=>({
                }
            seed:rand(i*4+71),height:rand(i*5+13),
                ctx.stroke();
            lifetime:.5+rand(i*8+37)*.7,
                ctx.globalAlpha=1;
            phase:rand(i*9+21),sprite:i%4,
                // Only combustion throws straight, fiery projectile trails.
            scale:.72+rand(i*7+9)*1.25,
                // Return-to-blue has no streaks or sparks whatsoever.
            bend:rand(i*15+23)*6.28
                if(dark){
        }));
                  ctx.globalCompositeOperation='screen';
        const motes=Array.from({length:innerWidth<700?56:115},(_,i)=>({
                  for(const bit of shockDebris){
            y:rand(i*3+17),phase:rand(i*9+43),
                    const f=age*(.5+bit.seed*.5);
            speed:100+rand(i*12+5)*340,
                    const travel=bit.velocity*f;
            drift:82+rand(i*15+2)*330,
                    const px=x+Math.cos(bit.angle)*travel;
            length:3+rand(i*17+7)*16
                    const py=y+Math.sin(bit.angle)*travel;
        }));
                    const vx=Math.cos(bit.angle)*bit.length*(1-age);
        const shockDebris=Array.from({length:innerWidth<700?32:66},(_,i)=>({
                    const vy=Math.sin(bit.angle)*bit.length*(1-age);
            angle:rand(i*17+131)*Math.PI*2,
                    ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7);
            velocity:190+rand(i*11+51)*900,
                    ctx.strokeStyle=dark?'#ffdd9b':'#e0faff';
            seed:rand(i*5+79),
                    ctx.lineWidth=.6+bit.seed*1.3;
            length:6+rand(i*7+17)*26
                    ctx.beginPath();
        }));
                    ctx.moveTo(px,py);
        function pressureWave(x,y,radius,power,dark,time) {
                    ctx.lineTo(px-vx,py-vy);
            if(power<=.005)return;
                    ctx.stroke();
            const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
                  }
            if(dark){
                 }
                core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
                ctx.globalCompositeOperation='source-over';
                core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
                 ctx.globalAlpha=1;
                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)');
             }
             }
             // Real flame tongues break OUT of the burn front into the sky.
             ctx.globalCompositeOperation='screen';
             // They are thin, tapered Bezier ribbons with an unstable pulse,
             ctx.fillStyle=core;
            // rather than round glowing particles or a uniform bright line.
             ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
             if(dark){
            ctx.globalCompositeOperation='source-over';
                ctx.save();
        }
                ctx.globalCompositeOperation='screen';
        return function drawAtmosphere(seconds,progress,dark,elapsed,cells) {
                const count=Math.ceil(h/11.5);
            const w=innerWidth,h=innerHeight;
                for(let j=0;j<count;j++){
            const scale=w<700?.43:.55;
                    const seed=rand(j*13+167);
            const bw=Math.max(1,Math.round(w*scale));
                    const y=(j+seed*.78)*11.5-5;
            const bh=Math.max(1,Math.round(h*scale));
                    const x=edgeAt(y,seconds,progress,w);
            if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;}
                    if(x < -90 || x > w+90)continue;
            ctx.setTransform(scale,0,0,scale,0,0);
                    const ripple=Math.sin(seconds*(3.4+rand(j*11+4)*2.4)+
            ctx.clearRect(0,0,w,h);
                        j*2.09+seed*3.1);
            const envelope=Math.sin(Math.PI*progress);
                    const heat=clamp01(.58+ripple*.34+
            const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
                        .16*Math.sin(j*1.17-seconds*7.1));
            advanceCombustion(seconds,progress,dark,elapsed,w,h);
                    if(heat<.34)continue;
            const sprites=dark?sootSprites:mistSprites;
                    const length=(22+rand(j*23+19)*75)*(.5+heat*.94);
            // New-world side: red fire trails to the right when burning,
                    const bend=.18+rand(j*17+42)*.66;
            // blue dawn trails to the left when returning.
                     const tipX=x-length*(.76+.19*seed);
            const direction=dark?1:-1;
                    const tipY=y-length*bend;
            // Broken smoke banks with different depths and sizes. The old
                    const thick=(3.2+rand(j*31+11)*10.3)*(.5+heat);
            // aligned rows created an artificial horizontal fog ribbon.
                    const gradient=ctx.createLinearGradient(x,y,tipX,tipY);
            ctx.globalCompositeOperation='source-over';
                    gradient.addColorStop(0,'rgba(255,255,242,.99)');
            const bands=Math.max(8,Math.ceil(h/(dark?116:91)));
                    gradient.addColorStop(.28,'rgba(255,224,84,.96)');
            for(let i=0;i<bands;i++){
                    gradient.addColorStop(.59,'rgba(255,76,10,.64)');
                const baseY=(i+.15+rand(i*11+39)*.70)*h/bands;
                    gradient.addColorStop(1,'rgba(182,17,6,0)');
                const y=baseY+31*Math.sin(seconds*1.6+i*1.47);
                    ctx.beginPath();
                const x=edgeAt(y,seconds,progress,w)+direction*
                    ctx.moveTo(x+2,y+thick*.5);
                    (dark?76+rand(i*19+6)*260:120)+
                    ctx.bezierCurveTo(
                     Math.sin(seconds*.93+i*1.3)*37;
                        x-length*.22,y-thick*.25,
                const width=dark?170+rand(i*29+12)*220:250;
                        tipX+length*.36,tipY+thick*.68,tipX,tipY);
                const height=dark?165+rand(i*7+5)*170:245;
                     ctx.bezierCurveTo(
                ctx.save();
                        tipX+length*.32,tipY-thick*.22,
                ctx.globalAlpha=(dark?.34:.21)*fade*(.55+.45*envelope);
                        x-length*.17,y-thick*.74,
                ctx.translate(x,y);
                        x+2,y-thick*.65);
                ctx.rotate(.27*Math.sin(i*2.1+seconds*.9));
                    ctx.closePath();
                ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height);
                    ctx.globalAlpha=Math.min(1,heat*1.08)*fade;
                ctx.restore();
                     ctx.fillStyle=gradient;
            }
                    ctx.shadowBlur=0;
            // Independent turbulent billows peel away and rise.
                    ctx.fill();
            for(const item of particles){
                    if(heat>.66){
                const age=((elapsed/item.lifetime+item.phase)%1);
                        ctx.globalAlpha=(heat-.54)*1.40*fade;
                const baseY=item.height*h;
                        ctx.strokeStyle='#fffce0';ctx.lineWidth=.9+heat*1.1;
                const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
                        ctx.beginPath();ctx.moveTo(x,y);
                if(y< -230||y>h+230)continue;
                        ctx.quadraticCurveTo(x-length*.3,y-length*.11,
                const frontier=edgeAt(baseY,seconds,progress,w);
                            tipX+length*.19,tipY+thick*.18);
                const x=frontier+direction*(age*(dark?225:135)+
                        ctx.stroke();
                     30*Math.sin(age*6.3+item.bend));
                    }
                const size=(dark?174:145)*item.scale*(.68+age*1.9);
                }
                const opacity=(dark?.43:.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();
                 ctx.restore();
             }
             }
 
            // Residual devastation: coal-black plumes stay pinned to their
             // Carbonized paper has a fine, ragged edge. The charcoal layer
             // original burn positions, expanding long after the tear passes.
             // persists continuously, but the white-hot core does NOT.
             // Composited *before* fire, so the hot core cuts through smoke.
            ctx.save();
             if(dark){
            ctx.beginPath();
            for(let y=-12,i=0;y<=h+12;y+=3.6,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,.81)';
                ctx.lineWidth=18;ctx.shadowBlur=0;
                ctx.stroke();
                ctx.strokeStyle='rgba(210,45,9,.53)';
                ctx.lineWidth=6;
                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.save();
                 ctx.globalCompositeOperation='screen';
                 ctx.globalCompositeOperation='source-over';
                ctx.lineCap='round';
                 for(const p of residualSoot){
                 for(let y=-8;y<h+8;y+=5.8){
                    const age=1-p.remaining/p.life;
                     const intensity=clamp01(.58+
                     const opacity=clamp01(age/.14)*
                         .29*Math.sin(y*.054+seconds*3.7)+
                         Math.pow(1-age,.49)*(.66+.24*Math.sin(p.seed*3)**2)*fade;
                        .21*Math.sin(y*.147-seconds*8.9)+
                     if(opacity<.009)continue;
                        .13*Math.sin(y*.337+seconds*16.4));
                     ctx.save();
                     if(intensity<.47)continue;
                     ctx.globalAlpha=opacity;
                     const x=edgeAt(y,seconds,progress,w);
                     ctx.translate(p.x,p.y);
                     const nextX=edgeAt(y+5.8,seconds,progress,w);
                     ctx.rotate(p.spin+age*.33*Math.sin(p.seed));
                     ctx.globalAlpha=intensity*fade;
                     ctx.scale(1.20,.92);
                     ctx.lineWidth=1.4+intensity*4.6;
                     const s=p.size*(1+age*.68);
                    ctx.shadowBlur=0;
                     ctx.drawImage(sootSprites[p.sprite],-s/2,-s/2,s,s);
                     ctx.strokeStyle=intensity>.75?'#fff8d7':
                    ctx.restore();
                        intensity>.58?'#ffd369':'#ff831b';
                     ctx.beginPath();ctx.moveTo(x,y);
                     ctx.lineTo(nextX,y+5.8);ctx.stroke();
                 }
                 }
                 ctx.restore();
                 ctx.restore();
                 // Fragmented ignition on real microscopic perforations.
            }else{
                 // Missing arc sections stop them resembling punched circles.
                 // The inverse is a slow, organic unfurling: broad milky
                 // radiance behind the rebuilt sky, not a cold explosion.
                 ctx.save();
                 ctx.save();
                 ctx.globalCompositeOperation='screen';
                 ctx.globalCompositeOperation='screen';
                ctx.shadowBlur=0;
                 for(let n=0;n<3;n++){
                 for (let i=0; i<cells.length; i++) {
                     const yy=(n+.43)*h/3+Math.sin(seconds*.8+n*2)*30;
                     const c=cells[i];
                     const xx=edgeAt(yy,seconds,progress,w)-
                    ctx.globalAlpha=(.42+.34*c.heat)*fade;
                         (88+55*Math.sin(n*2.1+seconds*.7));
                     ctx.strokeStyle=c.heat>.73?'#fff6bf':
                     const radius=145+65*Math.sin(seconds*.7+n*1.7)**2;
                         c.heat>.38?'#ffd166':'#ff621c';
                    const bloom=ctx.createRadialGradient(xx,yy,8,xx,yy,radius);
                    ctx.lineWidth=.8+Math.min(2.6,c.radius*.24);
                    bloom.addColorStop(0,'rgba(250,255,255,.10)');
                     const n=c.points.length;
                     bloom.addColorStop(.33,'rgba(164,237,255,.07)');
                    ctx.beginPath();
                     bloom.addColorStop(.74,'rgba(117,198,255,.04)');
                    for (let j=0;j<n;j++) {
                     bloom.addColorStop(1,'rgba(117,198,255,0)');
                        if ((j+i*3)%5>2)continue;
                     ctx.globalAlpha=fade;
                        const a=c.points[j],b=c.points[(j+1)%n];
                     ctx.fillStyle=bloom;
                        ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
                     ctx.fillRect(xx-radius,yy-radius,radius*2,radius*2);
                     }
                    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();
                 ctx.restore();
             }
             }
            // COMPOSITING BARRIER: smoky background, curled tongues and
             // The impact happens at ignition and again as the rupture reaches
            // charcoal seam are now COMPLETELY behind the WebGL fire wall.
             // the middle of the screen; pulses are intentionally fleeting.
             // The upper layer contains additive V25 incandescent wake and
             for(const moment of (dark?[.10,.48]:[])){
            // embers only. No opaque smoke can obscure the white-hot core.
                 const age=(progress-moment)/.125;
            if(fireCtx){
                 if(age<0||age>1)continue;
                ctx=fireCtx;
                 const intensity=(1-age)*(1-age)*fade;
                if(dark)drawV25HotFibers(seconds,progress,fade,w,h);
                 // A single-frame-ish exposure bloom punches through
            }
                 // the entire page; the tone is red or icy blue by direction.
            // 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'?.93:p.kind==='glow'?.13:.62)*fade*
                    (dark?Math.exp(-Math.max(0,p.x-
                        edgeAt(p.y,seconds,progress,w))/
                        (p.kind==='ember'?475:145)):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';
                 ctx.globalCompositeOperation='screen';
                 for(const p of combustion){
                 ctx.fillStyle=dark
                     if(p.kind!=='leaf'&&p.kind!=='pollen')continue;
                     ? 'rgba(255,81,26,'+(.095*intensity)+')'
                     const age=1-p.remaining/p.life;
                     : 'rgba(150,219,255,'+(.080*intensity)+')';
                    const opacity=clamp01(age/.17)*clamp01(p.remaining/.55)*
                ctx.fillRect(0,0,w,h);
                        (p.kind==='leaf'?.47:.28)*fade;
                ctx.globalCompositeOperation='source-over';
                    if(opacity<.012)continue;
                const x=edgeAt(h*.51,seconds,progress,w);
                    ctx.save();
                const y=h*.51;
                    ctx.globalAlpha=opacity;
                const radius=95+age*Math.max(w,h)*.9;
                    ctx.translate(p.x+11*Math.sin(elapsed*1.6+p.seed),p.y);
                pressureWave(x,y,radius,intensity,dark,seconds);
                    ctx.rotate(p.angle+age*1.65*Math.sin(p.seed*2.5));
                ctx.globalAlpha=(dark?.20:.28)*intensity;
                    if(p.kind==='leaf'){
                ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
                        const r=p.radius;
                ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
                        ctx.shadowBlur=13;
                ctx.beginPath();
                        ctx.shadowColor='#bbf4ff';
                for(let i=0;i<=110;i++){
                        ctx.fillStyle=p.sprite%2?'#e6ffff':'#ffffff';
                     const a=i/110*Math.PI*2;
                        ctx.beginPath();
                    const jag=1+.03*Math.sin(a*29+seconds*12)+
                        ctx.moveTo(0,-r*1.65);
                         .05*Math.sin(a*13-seconds*5);
                        ctx.bezierCurveTo(r*.97,-r*.48,r*.85,r*.60,0,r*1.35);
                    const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag;
                        ctx.bezierCurveTo(-r*.92,r*.28,-r*.94,-r*.57,0,-r*1.65);
                    if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry);
                        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();
                 ctx.stroke();
            }
                ctx.globalAlpha=1;
            // Minute black flecks, never rectangular shards on the blue side.
                // Only combustion throws straight, fiery projectile trails.
            ctx.save();
                // Return-to-blue has no streaks or sparks whatsoever.
            ctx.globalCompositeOperation='source-over';
                if(dark){
            for(const p of combustion){
                  ctx.globalCompositeOperation='screen';
                if(p.kind!=='ash')continue;
                  for(const bit of shockDebris){
                ctx.globalAlpha=clamp01(p.remaining/.25)*.7*fade;
                    const f=age*(.5+bit.seed*.5);
                ctx.fillStyle='#190d10';
                    const travel=bit.velocity*f;
                ctx.save();ctx.translate(p.x,p.y);ctx.rotate(elapsed*2+p.angle);
                    const px=x+Math.cos(bit.angle)*travel;
                 ctx.fillRect(-p.radius*.5,-p.radius*.5,p.radius,p.radius*1.8);
                    const py=y+Math.sin(bit.angle)*travel;
                 ctx.restore();
                    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;
             }
             }
             ctx.restore();
             // Real flame tongues break OUT of the burn front into the sky.
             // Irregular burnt page sections tumble away and reveal their
             // They are thin, tapered Bezier ribbons with an unstable pulse,
             // blackened underside, with an occasional incandescent rim.
             // rather than round glowing particles or a uniform bright line.
             if(dark){
             if(dark){
                 ctx.save();
                 ctx.save();
                 ctx.globalCompositeOperation='source-over';
                 ctx.globalCompositeOperation='screen';
                 for(const p of tornPaper){
                const count=Math.ceil(h/17);
                     const age=1-p.remaining/p.life;
                 for(let j=0;j<count;j++){
                     const opacity=clamp01(age/.08)*clamp01(p.remaining/.4)*fade;
                     const seed=rand(j*13+167);
                     if(opacity<.01)continue;
                     const y=(j+seed*.78)*11.5-5;
                     ctx.save();
                    const x=edgeAt(y,seconds,progress,w);
                     ctx.globalAlpha=opacity;
                     if(x < -90 || x > w+90)continue;
                    ctx.translate(p.x,p.y);
                     const ripple=Math.sin(seconds*(3.4+rand(j*11+4)*2.4)+
                     ctx.rotate(p.angle);
                        j*2.09+seed*3.1);
                     ctx.beginPath();
                     const heat=clamp01(.58+ripple*.34+
                     p.outline.forEach((v,i)=>{
                        .16*Math.sin(j*1.17-seconds*7.1));
                        const px=Math.cos(v.a)*v.r*p.radius;
                     if(heat<.34)continue;
                        const py=Math.sin(v.a)*v.r*p.radius*.74;
                    const length=(22+rand(j*23+19)*75)*(.5+heat*.94);
                        if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
                     const bend=.18+rand(j*17+42)*.66;
                     });
                     const tipX=x-length*(.76+.19*seed);
                    const tipY=y-length*bend;
                    const thick=(3.2+rand(j*31+11)*10.3)*(.5+heat);
                    const gradient=ctx.createLinearGradient(x,y,tipX,tipY);
                    gradient.addColorStop(0,'rgba(255,255,242,.99)');
                    gradient.addColorStop(.28,'rgba(255,224,84,.96)');
                    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.closePath();
                    // Distinct silhouette first: some fragments are almost
                     ctx.globalAlpha=Math.min(1,heat*1.08)*fade;
                    // pitch black against the blazing furnace.
                     ctx.fillStyle=gradient;
                     ctx.shadowBlur=7;
                     ctx.shadowBlur=0;
                     ctx.shadowColor=age<.40?'#ff5d1b':'#0b0407';
                     ctx.fillStyle=p.seed>3?'#080408':'#241014';
                     ctx.fill();
                     ctx.fill();
                     ctx.shadowBlur=0;
                     if(heat>.66){
                    // Only discontinuous edges glow: a complete neon
                         ctx.globalAlpha=(heat-.54)*1.40*fade;
                    // polygon outline looks like a sci-fi icon, not char.
                         ctx.strokeStyle='#fffce0';ctx.lineWidth=.9+heat*1.1;
                    ctx.lineWidth=.70+Math.sin(p.seed)**2*.95;
                         ctx.beginPath();ctx.moveTo(x,y);
                    ctx.strokeStyle=age<.50?'rgba(255,112,34,.82)':
                         ctx.quadraticCurveTo(x-length*.3,y-length*.11,
                        'rgba(125,32,16,.31)';
                            tipX+length*.19,tipY+thick*.18);
                    ctx.beginPath();
                    for(let i=0;i<p.outline.length;i++){
                        if((i+Math.floor(p.seed*7))%4>1)continue;
                        const a=p.outline[i];
                        const b=p.outline[(i+1)%p.outline.length];
                         ctx.moveTo(Math.cos(a.a)*a.r*p.radius,
                            Math.sin(a.a)*a.r*p.radius*.74);
                        ctx.lineTo(Math.cos(b.a)*b.r*p.radius,
                            Math.sin(b.a)*b.r*p.radius*.74);
                    }
                    ctx.stroke();
                    // Cracks are molten for the first half of the flight.
                    if(age<.55){
                        ctx.globalCompositeOperation='lighter';
                        ctx.shadowBlur=6;
                        ctx.shadowColor='#ff5e0a';
                         ctx.strokeStyle=age<.22?'#ffe9a2':'#ff761e';
                        ctx.lineWidth=.60+(.55-age)*1.4;
                        ctx.globalAlpha=opacity*(1-age);
                         ctx.beginPath();
                        ctx.moveTo(-p.radius*.48,-p.radius*.06);
                         ctx.lineTo(-p.radius*.08,p.radius*.14);
                        ctx.lineTo(p.radius*.27,-p.radius*.22);
                        ctx.stroke();
                        ctx.shadowBlur=0;
                        ctx.globalCompositeOperation='source-over';
                        ctx.globalAlpha=opacity;
                    }
                    // 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.stroke();
                     }
                     }
                    ctx.restore();
                 }
                 }
                 ctx.restore();
                 ctx.restore();
             }
             }
             // Sharp, directional embers / crystalline fragments, not blobs.
 
             if(dark){ctx.save();
             // Carbonized paper has a fine, ragged edge. The charcoal layer
             ctx.globalCompositeOperation='screen';
            // persists continuously, but the white-hot core does NOT.
             for(const mote of motes){
             ctx.save();
                const age=(elapsed*.74+mote.phase)%1;
             ctx.beginPath();
                const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
             for(let y=-12,i=0;y<=h+12;y+=3.6,i++){
                 const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
                 const x=edgeAt(y,seconds,progress,w);
                 if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
                 if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
                 const opacity=(1-age)*fade*.98;
            }
                 ctx.globalAlpha=opacity;
            ctx.lineCap='round';ctx.lineJoin='round';
                 ctx.shadowColor='#ff8c28';
            if(dark){
                 ctx.shadowBlur=5;
                 ctx.strokeStyle='rgba(9,5,7,.81)';
                 ctx.strokeStyle=age<.37?'#fff2a1':'#ff922d';
                ctx.lineWidth=18;ctx.shadowBlur=0;
                 ctx.lineWidth=1.0+(.5-age)*.7;
                 ctx.stroke();
                 ctx.beginPath();
                 ctx.strokeStyle='rgba(210,45,9,.53)';
                 ctx.moveTo(x,yy);
                 ctx.lineWidth=6;
                 ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
                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.stroke();
             }
             }
             ctx.shadowBlur=0;
             ctx.restore();
             ctx.restore();}
             if(dark){
        };
                // Intermittent ignition cells: varying width/color/intensity
    }
                // make the bright edge resemble burning fibres, not neon.
 
                ctx.save();
    let atmosphereWarmPromise=null;
                ctx.globalCompositeOperation='screen';
    function prewarmAtmosphere() {
                ctx.lineCap='round';
        if(atmosphereWarmPromise)return atmosphereWarmPromise;
                for(let y=-8;y<h+8;y+=5.8){
        atmosphereWarmPromise=new Promise(resolve=>{
                    const intensity=clamp01(.58+
            const scratchCanvas=document.createElement('canvas');
                        .29*Math.sin(y*.054+seconds*3.7)+
            function step(){
                        .21*Math.sin(y*.147-seconds*8.9)+
                if(atmosphereAssetCache?.EMBER_GLOW){resolve();return;}
                        .13*Math.sin(y*.337+seconds*16.4));
                // Exactly one texture per task: no more 270ms blocking call.
                    if(intensity<.47)continue;
                createAtmosphere(scratchCanvas,true);
                    const x=edgeAt(y,seconds,progress,w);
                if(atmosphereAssetCache?.EMBER_GLOW){resolve();return;}
                    const nextX=edgeAt(y+5.8,seconds,progress,w);
                setTimeout(step,24);
                    ctx.globalAlpha=intensity*fade;
            }
                    ctx.lineWidth=1.4+intensity*4.6;
            setTimeout(step,40);
                    ctx.shadowBlur=0;
        });
                    ctx.strokeStyle=intensity>.75?'#fff8d7':
        return atmosphereWarmPromise;
                        intensity>.58?'#ffd369':'#ff831b';
    }
                    ctx.beginPath();ctx.moveTo(x,y);
 
                    ctx.lineTo(nextX,y+5.8);ctx.stroke();
    let atmosphereEdge=null;
                }
    function edgeAt(y,seconds,progress,width){
                ctx.restore();
        return atmosphereEdge
                // Fragmented ignition on real microscopic perforations.
            ? atmosphereEdge(y)
                // Missing arc sections stop them resembling punched circles.
            : makeEdge(progress,seconds,Boolean(window.__kkptVisualDark),width)(y);
                ctx.save();
    }
                ctx.globalCompositeOperation='screen';
 
                ctx.shadowBlur=0;
    // Original V2's moving flame sprites and three bright contours, rebuilt
                for (let i=0; i<cells.length; i++) {
    // with bounded particle counts. No WebGL, no full-screen noise pass,
                    const c=cells[i];
    // no 1,000-point polygon or per-frame image-texture upload.
                    ctx.globalAlpha=(.42+.34*c.heat)*fade;
    let sprites=null;
                    ctx.strokeStyle=c.heat>.73?'#fff6bf':
    function makeSprites() {
                        c.heat>.38?'#ffd166':'#ff621c';
        if (sprites) return sprites;
                    ctx.lineWidth=.8+Math.min(2.6,c.radius*.24);
        function radial(stops) {
                    const n=c.points.length;
            const el=document.createElement('canvas');
                    ctx.beginPath();
            el.width=el.height=96;
                    for (let j=0;j<n;j++) {
            const c=el.getContext('2d');
                        if ((j+i*3)%5>2)continue;
            const g=c.createRadialGradient(48,48,0,48,48,48);
                        const a=c.points[j],b=c.points[(j+1)%n];
            stops.forEach(pair=>g.addColorStop(pair[0],pair[1]));
                        ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
            c.fillStyle=g;c.fillRect(0,0,96,96);
                    }
            return el;
                    ctx.stroke();
        }
                }
        sprites={
                ctx.restore();
            flame:radial([[0,'#fffbed'],[.18,'#fff2a0'],
                // Small carbon flecks stick to the still-blue paper just
                [.43,'#ffb42b'],[.70,'#ff360088'],[1,'#8f050000']]),
                // ahead of the bite; no large rectangular glitch debris.
            ember:radial([[0,'#ffffff'],[.15,'#fff6a8'],
                ctx.save();
                [.44,'#ff9900cc'],[1,'#ef280000']]),
                for(let j=0;j<Math.ceil(h/10);j++){
            smoke:radial([[0,'#080608dd'],[.30,'#100b10bb'],
                    const y=(j+rand(j*5+61)*.8)*10;
                [.62,'#160f1677'],[1,'#08050800']]),
                    const x=edgeAt(y,seconds,progress,w)-
            sky:radial([[0,'#ffffff'],[.16,'#e6ffff'],
                        (5+rand(j*17+38)*24);
                [.46,'#94deffe0'],[1,'#4ab5ff00']]),
                    const size=.5+rand(j*29+11)*2.5;
            mist:radial([[0,'#f2fdffb0'],[.4,'#abdffff0'],
                    const visible=.25+.59*rand(j*13+7);
                [1,'#a9e7ff00']])
                    ctx.globalAlpha=visible*fade;
        };
                    ctx.fillStyle='#150b0a';
        return sprites;
                    ctx.beginPath();
    }
                    ctx.ellipse(x,y,size*.71,size,rand(j*7+31)*3.14,
 
                        0,Math.PI*2);
    // V30 fracture edge. Multi-scale piecewise-linear angular ridges have
                    ctx.fill();
    // HARD corners; the old three sinusoidal waves were far too smooth.
                }
    function edgeShard(q,seed){
                ctx.restore();
        const i=Math.floor(q),f=q-i;
            }
        const a=Math.sin(i*2.41+seed);
            // The key property V3-V6 lost: particles remain at their
        return a+(Math.sin((i+1)*2.41+seed)-a)*f;
            // birth position while the front advances. A wide, incandescent
    }
            // WAKE builds behind the tearing page instead of following
    function makeEdge(t,seconds,dark,w) {
            // it as one thin, plastic-looking stripe.
        const wave=Math.sin(Math.PI*t);
            ctx.save();
        const center=dark?w+105-(w+210)*smooth(t)
            ctx.globalCompositeOperation='lighter';
            :-105+(w+210)*smooth(t);
            for(const p of combustion){
        return y=>center+wave*(
                if(p.kind==='ash'||p.kind==='leaf'||p.kind==='pollen')continue;
            10*Math.sin(y*.023+seconds*5.5)+
                const age=1-p.remaining/p.life;
            5*Math.sin(y*.071-seconds*8.4)+
                const opacity=clamp01(p.remaining/.22)*
            3*Math.sin(y*.14+seconds*12)+
                    clamp01(age/.08)*(1-age*.30)*
            12*edgeShard(y/32+seconds*.48,1.73)+
                    (p.kind==='ember'?.74:p.kind==='glow'?.13:.66)*fade*
            6*edgeShard(y/10-seconds*1.2,4.19));
                    (dark?Math.exp(-Math.max(0,p.x-
    }
                        edgeAt(p.y,seconds,progress,w))/70):1);
 
                if(opacity<.012)continue;
    // Clip the ACTUAL preloaded Wiki frame, precisely where the fire is.
                 ctx.save();
    function clipFrame(edge) {
                 ctx.globalAlpha=opacity;
        const w=innerWidth,h=innerHeight;
                 ctx.translate(p.x,p.y);
        const rows=Math.max(128,Math.ceil(h/6));
                ctx.rotate(p.angle+Math.sin(elapsed*4+p.seed)*.13);
        const pts=[];
                 const large=p.kind==='flame';
        for(let i=rows;i>=0;i--) {
                 const sx=p.radius*(p.kind==='glow'?1.7:large?.96:1.32)*(1+age*.32);
            const y=h*i/rows;
                const sy=p.radius*(p.kind==='glow'?1.4:large?1.78:1.35)*(1+age*.57);
            pts.push(edge(y).toFixed(1)+'px '+y.toFixed(1)+'px');
                const sprite=p.kind==='flame'?FIRE_GLOW[p.sprite]:
        }
                     p.kind==='glow'?BLUE_GLOW[p.sprite]:EMBER_GLOW;
        frame.style.clipPath=clipSide==='right'
                 ctx.drawImage(sprite,-sx,-sy,sx*2,sy*2);
            ? 'polygon(100% 0,100% 100%,'+pts.join(',')+')'
                 ctx.restore();
            : 'polygon(0 0,0 100%,'+pts.join(',')+')';
    }
 
    // ================================================================
    // V30: three compositing planes:
    // smoke/tongues BEHIND, genuine WebGL2 incandescent furnace, then
    // additive V25 hot fibers and embers IN FRONT. Never soot over flame.
    // ================================================================
    function createWebGLCurtain(dark) {
        if(!window.WebGL2RenderingContext)return null;
        const surface=document.createElement('canvas');
        surface.id='kkpt-webgl';
        surface.setAttribute('aria-hidden','true');
        surface.style.cssText='position:fixed;inset:0;width:100vw;'+
            'height:100vh;z-index:2147482100;pointer-events:none;'+
            'display:block;background:transparent';
        document.body.appendChild(surface);
        let gl=null;
        let curtain=null,sparks=null,plumes=null;
        let attachedAtmosphere=null;
        let raf=0,disposed=false,contextLost=false;
        const mobile=innerWidth<700||
            matchMedia('(pointer:coarse)').matches;
        const view=[innerWidth,innerHeight];
        const sharedFns=`
            precision highp float;
            float hash(vec2 p) {
                p=fract(p*vec2(.3183099,.3678794)+.13787);
                p*=17.0;
                return fract(p.x*p.y*(p.x+p.y));
            }
            float noise2(vec2 p) {
                 vec2 i=floor(p),f=fract(p);
                 f=f*f*(3.0-2.0*f);
                 return mix(mix(hash(i),hash(i+vec2(1.0,0.0)),f.x),
                          mix(hash(i+vec2(0.0,1.0)),hash(i+1.0),f.x),f.y);
            }
            float fbm(vec2 p) {
                 float n=0.0,a=.56;
                 for(int j=0;j<3;j++){
                    n+=a*noise2(p);
                    p=mat2(1.61,-1.13,1.13,1.61)*p+13.31;
                     a*=.50;
                 }
                 return n;
             }
             }
             float smooth01(float p){return p*p*(3.0-2.0*p);}
             ctx.restore();
             float angular(float q,float seed){
             // Rebirth uses soft, rotating, organic leaf/petal silhouettes,
                float i=floor(q),f=fract(q);
             // minute circular pollen and spacious white-blue bokeh. No
                float a=sin(i*2.41+seed);
            // rectangular ions, no warm textures and NO glowing streaks.
                return mix(a,sin((i+1.0)*2.41+seed),f);
             if(!dark){
             }
                 ctx.save();
            float stormEdge(float y,float t,float clock,float w,float dark) {
                 ctx.globalCompositeOperation='screen';
                float s=smooth01(t);
                 for(const p of combustion){
                float center=mix(-105.0+(w+210.0)*s,
                    if(p.kind!=='leaf'&&p.kind!=='pollen')continue;
                                  w+105.0-(w+210.0)*s,dark);
                    const age=1-p.remaining/p.life;
                float wobble=sin(3.14159265*t);
                    const opacity=clamp01(age/.17)*clamp01(p.remaining/.55)*
                return center+wobble*(
                        (p.kind==='leaf'?.47:.28)*fade;
                    10.0*sin(y*.023+clock*5.5)+
                    if(opacity<.012)continue;
                    5.0*sin(y*.071-clock*8.4)+
                    ctx.save();
                    3.0*sin(y*.14+clock*12.0)+
                    ctx.globalAlpha=opacity;
                    12.0*angular(y/32.0+clock*.48,1.73)+
                    ctx.translate(p.x+11*Math.sin(elapsed*1.6+p.seed),p.y);
                    6.0*angular(y/10.0-clock*1.2,4.19));
                    ctx.rotate(p.angle+age*1.65*Math.sin(p.seed*2.5));
             }
                    if(p.kind==='leaf'){
        `;
                        const r=p.radius;
        const backgroundVert=`#version 300 es
                        ctx.shadowBlur=13;
            out vec2 vUV;
                        ctx.shadowColor='#bbf4ff';
            void main(){
                        ctx.fillStyle=p.sprite%2?'#e6ffff':'#ffffff';
                 vec2 p=vec2(gl_VertexID==1?3.0:-1.0,
                        ctx.beginPath();
                            gl_VertexID==2?3.0:-1.0);
                        ctx.moveTo(0,-r*1.65);
                 vUV=(p+1.0)*.5;
                        ctx.bezierCurveTo(r*.97,-r*.48,r*.85,r*.60,0,r*1.35);
                 gl_Position=vec4(p,0.0,1.0);
                        ctx.bezierCurveTo(-r*.92,r*.28,-r*.94,-r*.57,0,-r*1.65);
            }`;
                        ctx.fill();
        const backgroundFrag=`#version 300 es
                        ctx.globalAlpha=opacity*.38;
        precision highp float;
                        ctx.strokeStyle='#70c9ea';
            in vec2 vUV;
                        ctx.lineWidth=.65;
            out vec4 outColor;
                        ctx.beginPath();ctx.moveTo(0,-r*1.15);
 
                        ctx.quadraticCurveTo(-r*.17,0,0,r*.95);ctx.stroke();
        uniform vec2 uView;
                    }else{
 
                        const r=p.radius;
        uniform float uClock;
                        const halo=ctx.createRadialGradient(0,0,0,0,0,r*3);
        uniform float uProgress;
                        halo.addColorStop(0,'rgba(255,255,255,.78)');
        uniform float uDark;
                        halo.addColorStop(.25,'rgba(225,250,255,.40)');
 
                        halo.addColorStop(1,'rgba(150,215,255,0)');
        float random2(vec2 p) {
                        ctx.fillStyle=halo;
            return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123);
                        ctx.beginPath();ctx.arc(0,0,r*3,0,Math.PI*2);ctx.fill();
        }
        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 = uView.x;
            float wave=sin(3.14159265*uProgress);
            float yn=y/uView.y;
            float lobes=.63*sin(yn*9.3+uClock*.38+.4)+
                .27*sin(yn*22.7-uClock*.28+1.4)+
                .10*sin(yn*51.1+uClock*.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+uClock*1.8)+
                  13.0*sin(y*.061-uClock*3.6)+
                  6.0*sin(y*.135+uClock*7.9)+
                  17.0*(abs(sin(y*.133+uClock*3.7+
                      .8*sin(y*.043-uClock*1.1)))-.5)+
                  5.0*sin(y*.325-uClock*10.8)+
                  2.5*sin(y*.74+uClock*16.2)+
                  3.4*sin(y*.51-uClock*13.3)+
                  1.8*sin(y*.94+uClock*21.1)
                : 28.0*sin(y*.012+uClock*3.1)+
                  17.0*sin(y*.041-uClock*5.7)+
                  7.0*sin(y*.10+uClock*10.0);
            return c+wave*turbulent;
        }
        void main() {
            vec2 p = vec2(vUV.x*uView.x,(1.0-vUV.y)*uView.y);
            float d = p.x-edge(p.y);
            // Entire sky/background pixels outside the active burn wake
            // need no fbm work at all; this saves most pixels early/late.
            if ((uDark > .5 && (d < -138.0 || d > 970.0)) ||
                (uDark < .5 && (d > 148.0 || d < -1050.0))) {
                outColor = vec4(0.0);
                return;
            }
            float oldSide = uDark > .5 ? -d : d;
            float t = uClock;
            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,23.0,wakeSide)*
                    smoothstep(.17,.65,turbulence+.38*shredded);
                float ignitionNoise=fbm(vec2(p.y*.057+t*.19,
                    t*.84+sin(p.y*.029)*.44));
                float ignite=.33+.67*smoothstep(.25,.59,
                    ignitionNoise+.13*sin(p.y*.092-t*7.8));
                // Burning paper has three distinct temperatures: dark red
                // carbon, yellow incandescent fibers and discontinuous
                // white-hot ignition at the freshly eroded edge.
                float crackle = noise(vec2(p.y*.19-t*1.7,
                    sin(p.y*.12+t*2.4)*1.4+t*2.3));
                float brightGaps = smoothstep(.24,.65,
                    ignitionNoise*.78+crackle*.54);
                float core = exp(-abs(d)/6.8)*(.44+.56*brightGaps);
                float whiteCore = exp(-pow(d/4.2,2.0)) *
                    (.18+.82*brightGaps);
                float outer = exp(-abs(d)/28.0)*(.32+.68*ignite);
                float heat = clamp(flame*.98+core*.97,0.0,1.0);
                float hotPockets = smoothstep(.43,.73,turbulence*.78+shredded*.55)*
                    exp(-max(0.0,wakeSide)/62.0);
                vec3 fireColor = mix(vec3(.40,.012,.005),vec3(1.0,.18,.007),
                    smoothstep(.07,.51,heat));
                fireColor = mix(fireColor,vec3(1.0,.72,.075),
                    clamp(flame*.42+core*.82+hotPockets*.55,0.0,.97));
                fireColor = mix(fireColor,vec3(1.0,.995,.86),
                    clamp(whiteCore*1.10+hotPockets*.22,0.0,.96));
                col = mix(sootColor,fireColor,
                    clamp(flame*.93+core*1.22+whiteCore*.58,0.0,1.0));
                alpha = clamp(soot*.67+flame*.92+outer*.28+
                    core*.79+whiteCore*.38,0.0,.98);
 
                // 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);
            }
 
            if(uDark < .5) {
                col = mix(vec3(.065,.21,.35),col,.45);
                alpha = min(alpha*.52,.35);
            }
 
            // 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));
            outColor = vec4(col,alpha);
        }
    `;
        // Main GPU furnace: domain-warped flame bodies, torn contours and
        // extremely hot, irregular interiors. Separate from 2D glow sprites.
        // Flame body extends 20–26% of the full viewport into the dark
        // world, with torn white-hot cells, black voids and rolling soot.
        // Coordinates are in CSS pixels, so low-resolution mobile rendering
        // does not shrink the apparent width of the conflagration.
        const dominantFireFrag=`#version 300 es
            precision highp float;
            in vec2 vUV;
            out vec4 outColor;
            uniform vec2 uView;
            uniform float uClock,uProgress,uDark;
 
            float h2(vec2 p){
                return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);
            }
            float n2(vec2 p){
                vec2 i=floor(p),f=fract(p);
                f=f*f*(3.0-2.0*f);
                float a=mix(h2(i),h2(i+vec2(1.0,0.0)),f.x);
                float b=mix(h2(i+vec2(0.0,1.0)),h2(i+1.0),f.x);
                return mix(a,b,f.y);
            }
            float f4(vec2 p){
                float v=0.0,a=.5;
                for(int i=0;i<4;i++){
                    v+=a*n2(p);
                    p=mat2(1.61,-1.14,1.14,1.61)*p+7.4;
                    a*=.5;
                }
                return v;
            }
            float sm(float t){return t*t*(3.0-2.0*t);}
            float angular(float q,float seed){
                float i=floor(q),f=fract(q);
                float a=sin(i*2.41+seed);
                return mix(a,sin((i+1.0)*2.41+seed),f);
            }
 
            // Mirror the native page's clip-path: the flame actually
            // consumes the arriving wiki instead of floating above it.
            float front(float y){
                float wave=sin(3.14159265*uProgress);
                float c=uDark>.5
                    ?uView.x+105.0-(uView.x+210.0)*sm(uProgress)
                    :-105.0+(uView.x+210.0)*sm(uProgress);
                return c+wave*(10.0*sin(y*.023+uClock*5.5)+
                    5.0*sin(y*.071-uClock*8.4)+
                    3.0*sin(y*.14+uClock*12.0)+
                    12.0*angular(y/32.0+uClock*.48,1.73)+
                    6.0*angular(y/10.0-uClock*1.2,4.19));
            }
 
            void main(){
                vec2 p=vec2(vUV.x*uView.x,(1.0-vUV.y)*uView.y);
                float d=p.x-front(p.y);
                float phase=sin(3.14159265*clamp(uProgress,0.0,1.0));
                float appear=smoothstep(.005,.075,uProgress)*
                    (1.0-smoothstep(.945,1.0,uProgress));
                if(uDark<.5){
                    // Return to Kuranoka is blue haze, not a white screen.
                    float inward=-d;
                    if(inward< -85.0 || inward>660.0){
                        outColor=vec4(0.0);return;
                     }
                     }
                     float cloud=f4(vec2(p.y*.010-uClock*.18,
                     ctx.restore();
                        inward*.008+uClock*.11));
                    float haze=exp(-max(inward,0.0)/270.0)*
                        smoothstep(-74.0,25.0,inward);
                    float glimmer=exp(-pow(d/46.0,2.0));
                    vec3 col=mix(vec3(.075,.25,.42),
                        vec3(.48,.78,.94),clamp(cloud*.9,0.0,1.0));
                    float alpha=min(.28,haze*(.10+.16*cloud)+glimmer*.13);
                    outColor=vec4(col,alpha*appear);
                    return;
                 }
                 }
 
                 ctx.restore();
                 float span=clamp(uView.x*.225,175.0,475.0);
                if(d < -75.0 || d > span+800.0){
                    outColor=vec4(0.0);return;
                }
                float trail=max(0.0,d);
                // Three differently drifting, vertically anisotropic
                // fields make large flames ROLL upward rather than form
                // V27's orange horizontal bars.
                float wind=sin(p.y*.009+uClock*.61)*13.0+
                    sin(p.y*.027-uClock*1.17)*6.5;
                // Fire flows vertically. High spatial frequency across X,
                // lower frequency along Y: elongated rising structures,
                // not the old horizontal bands.
                float back=f4(vec2(trail*.015-uClock*.15,
                    p.y*.006-uClock*.23));
                float twist=f4(vec2((trail+wind)*.021-uClock*.39+
                    back*1.7,p.y*.013-uClock*.91));
                float tongues=f4(vec2((trail+wind+twist*41.0)*.032,
                    p.y*.010-uClock*1.26+back*.8));
                float cells=f4(vec2((trail+wind)*.053+twist*.72,
                    p.y*.024-uClock*1.95));
                float ragged=f4(vec2((trail+wind)*.075-uClock*.79,
                    p.y*.034-uClock*1.22));
 
                // Several layers of upward-advecting ignition pockets.
                // Their 2-D centers create *vertical flame lobes* and
                // bright internal cavities, never uniform horizontal bars.
                vec2 fireGrid=vec2(trail/83.0,
                    p.y/113.0+uClock*1.12);
                vec2 fireCell=floor(fireGrid);
                float cellGlow=0.0,cellGold=0.0,cellWhite=0.0;
                float erode=(f4(vec2(p.x*.037-uClock*.26,
                    p.y*.023-uClock*1.8))-.5)*.21;
                for(int cy=-1;cy<=1;cy++){
                    for(int cx=-1;cx<=1;cx++){
                        vec2 c=fireCell+vec2(float(cx),float(cy));
                        float seed=h2(c+vec2(6.4,13.2));
                        float gate=smoothstep(.12,.36,seed);
                        vec2 origin=c+vec2(.18+.64*h2(c+1.6),
                            .12+.76*h2(c+7.3));
                        vec2 delta=fireGrid-origin;
                        delta.x+=.21*sin(delta.y*3.4+uClock*2.8+
                            seed*6.28);
                        float metric=length(delta*vec2(
                            1.15-.16*seed,.69+.23*seed));
                        float localErode=erode*1.55+
                            .045*sin(seed*9.5);
                        float halo=1.0-smoothstep(.25,1.13,metric+localErode);
                        float gold=1.0-smoothstep(.13,.75,metric+localErode);
                        float whiteCore=1.0-smoothstep(.065,.43,
                            metric+localErode);
                        cellGlow=max(cellGlow,halo*gate);
                        cellGold=max(cellGold,gold*gate);
                        cellWhite=max(cellWhite,whiteCore*gate);
                    }
                }
 
                // The flame front has a contiguous furnace base. Further
                // into the black world, it tears into disjointed billows,
                // white cores, deep red chasms and hanging tongues.
                float field=.44*tongues+.32*twist+.24*cells+
                    .11*sin(p.y*.021-uClock*3.7+trail*.017);
                // Irregular fracture contour: high-frequency noise shears
                // edges into claw-like tongues. Narrow 10px thresholds
                // preserve jagged silhouettes rather than blurred ellipses.
                float tearing=f4(vec2(
                    trail*.024-uClock*.47+twist*.79,
                    p.y*.037-uClock*1.34+back*.39));
                float fiber=f4(vec2(
                    trail*.064+tearing*.7-uClock*.95,
                    p.y*.052-uClock*2.1));
                float edgeReach=span*(.39+.64*back+.42*twist)+
                    (tearing-.49)*92.0+(fiber-.50)*45.0;
                float fadeWidth=1.0-smoothstep(-19.0,19.0,
                    trail-edgeReach);
                float dense=smoothstep(.27,.55,
                    field+ragged*.16+(fiber-.5)*.18);
                float base=exp(-trail/25.0)*
                    (.15+.39*f4(vec2(p.x*.053-uClock*.6,
                        p.y*.021-uClock*.95)));
                // Dense, continuous furnace at the bite, branching into
                // separate caverns in the far wake.
                float body=clamp(max(base,dense)*fadeWidth,0.0,1.0);
                float gaps=smoothstep(.29,.57,
                    cells*.57+ragged*.35+fiber*.15);
                body*=mix(.50,1.0,gaps);
 
                float incandescent=smoothstep(.38,.61,
                    field+.18*tongues+.10*tearing+
                    .055*sin(p.y*.045-uClock*4.7));
                float yellow=smoothstep(.43,.68,
                    field+.18*cells+.11*base);
                float white=smoothstep(.57,.83,
                    field+.17*cells+.12*incandescent+.06*base);
                float luminous=clamp(body*(.42+.62*incandescent),0.0,1.0);
                // Larger WHITE hot pockets, not uniformly neon orange.
                float hot=body*white*fadeWidth;
                float lip=clamp((1.0-smoothstep(span*.57,span*1.12,
                    trail))*smoothstep(-17.0,14.0,d),0.0,1.0);
                float burnMask=smoothstep(-17.0,13.0,d);
                float near=exp(-abs(d)/12.0);
                float whiteRim=exp(-pow(d/4.5,2.0))*(.75+.25*ragged);
 
                float smoke=f4(vec2(p.y*.006-uClock*.14+back*1.9,
                    trail*.004-uClock*.10));
                float smokeFine=f4(vec2(p.y*.019-uClock*.33,
                    trail*.012+smoke));
                float shroud=smoothstep(.28,.66,smoke*.72+smokeFine*.34)*
                    (1.0-smoothstep(span+190.0,span+790.0,trail));
                shroud*=smoothstep(-12.0,55.0,d);
                vec3 coal=mix(vec3(.013,.004,.011),
                    vec3(.13,.013,.017),smoke);
                coal+=vec3(.13,.010,.007)*
                    exp(-trail/(span*.93))*(.4+.6*back);
                vec3 col=coal;
                col=mix(col,vec3(.48,.022,.004),clamp(body*.95,0.0,1.0));
                col=mix(col,vec3(1.0,.19,.003),luminous*.92);
                col=mix(col,vec3(1.0,.70,.035),
                    clamp(body*yellow*.72,0.0,1.0));
                col=mix(col,vec3(1.0,.23,.006),
                    clamp(cellGlow*.90*lip,0.0,1.0));
                col=mix(col,vec3(1.0,.78,.045),
                    clamp(cellGold*.98*lip,0.0,1.0));
                col=mix(col,vec3(1.0,.99,.82),
                    clamp(hot*.15+cellWhite*1.01*lip+
                        whiteRim*.86,0.0,1.0));
                col+=vec3(.15,.034,.0)*near;
 
                float alpha=clamp(shroud*.27*(1.0-body*.83)*burnMask+
                    body*.91*burnMask+cellGlow*.86*lip+
                    near*.68+whiteRim*.32,0.0,.99);
                // Remove flat horizontal slabs leaking into the old sky.
                col=mix(vec3(.36,.025,.006),col,
                    max(burnMask,whiteRim));
                // Side sparks, biased into the charred world. The richer
                // flying particles remain in the V13 atmosphere layer.
                vec2 moteCell=floor(vec2(p.x+uClock*24.0,
                    p.y+uClock*139.0)/17.0);
                vec2 moteUv=fract(vec2(p.x+uClock*24.0,
                    p.y+uClock*139.0)/17.0)-.5;
                float mote=(1.0-smoothstep(.04,.16,length(moteUv)))*
                    step(.989,h2(moteCell))*
                    exp(-trail/(span*1.4))*step(0.0,d);
                col=mix(col,vec3(1.0,.92,.38),mote);
                alpha=max(alpha,mote*.9);
                outColor=vec4(clamp(col,0.0,1.0),
                    alpha*appear*(.89+.11*phase));
             }
             }
        `;
             // Minute black flecks, never rectangular shards on the blue side.
        const sparkVert=`#version 300 es
             ctx.save();
             ${sharedFns}
            ctx.globalCompositeOperation='source-over';
            uniform vec2 uView;
            for(const p of combustion){
            uniform float uClock,uProgress,uDark,uPhase,uTail,uScale;
                 if(p.kind!=='ash')continue;
            out float vEnergy,vKind;
                 ctx.globalAlpha=clamp01(p.remaining/.25)*.7*fade;
            float rnd(float x){return hash(vec2(x,x*.713));}
                 ctx.fillStyle='#190d10';
             void main(){
                 ctx.save();ctx.translate(p.x,p.y);ctx.rotate(elapsed*2+p.angle);
                float id=float(gl_VertexID);
                 ctx.fillRect(-p.radius*.5,-p.radius*.5,p.radius,p.radius*1.8);
                float t=clamp(uProgress,0.0,1.0);
                 ctx.restore();
                float age=fract(rnd(id*1.07+7.3)+uClock*
                                mix(.16,.46,uDark)*(1.0+rnd(id+11.0)*.9));
                float y=rnd(id*17.7+3.0)*(uView.y+155.0)-68.0-
                    age*mix(71.0,195.0,uDark);
                float dir=mix(-1.0,1.0,uDark);
                float flight=(8.0+age*(170.0+rnd(id*4.2)*280.0));
                 float x=stormEdge(y,t,uClock,uView.x,uDark)+
                    dir*flight+sin(id*6.7+uClock*5.0)*age*22.0;
                float tailMix=uPhase;
                float xTail=rnd(id*41.2+13.0)*uView.x+
                    dir*(1.0-uTail)*89.0;
                float yTail=rnd(id*19.1+17.0)*uView.y-
                    (1.0-uTail)*105.0;
                x=mix(x,xTail,tailMix);
                y=mix(y,yTail,tailMix);
                float envelope=smoothstep(.01,.13,t)*
                    (1.0-smoothstep(.89,1.0,t));
                vEnergy=mix(envelope*(1.0-age)*(.64+.62*rnd(id+1.0)),
                            uTail*(.22+.64*(1.0-age)),tailMix);
                vKind=rnd(id*5.9+31.0);
                float size=mix(1.2,6.2,rnd(id+8.0))*
                    mix(.65,1.27,uDark);
                 gl_PointSize=max(1.0,size*uScale);
                gl_Position=vec4(x/uView.x*2.0-1.0,
                                1.0-y/uView.y*2.0,0.0,1.0);
            }`;
        const sparkFrag=`#version 300 es
            precision highp float;
            uniform float uDark,uRenderMode;
            in float vEnergy,vKind;
            out vec4 outColor;
            void main(){
                vec2 p=gl_PointCoord*2.0-1.0;
                p.x*=1.12;
                float radius=dot(p,p);
                if(radius>1.0)discard;
                // Draw real charcoal flecks with normal alpha blending,
                // then hot embers in their own additive pass. Dark shapes
                // would disappear if mixed into a single additive pass.
                if(uRenderMode<.5){
                    if(uDark<.5 || vKind>.23)discard;
                    float irregular=.78+.17*sin(atan(p.y,p.x)*7.0+
                                            vKind*80.0);
                    if(radius>irregular)discard;
                    outColor=vec4(vec3(.023,.017,.019),
                                  (1.0-radius)*vEnergy*.69);
                    return;
                }
                if(uDark>.5 && vKind<.23)discard;
                float a=pow(1.0-radius,2.8)*vEnergy;
                 vec3 hot=mix(vec3(.93,.095,.014),vec3(1.0,.85,.36),
                            smoothstep(.18,.90,vKind));
                vec3 blue=mix(vec3(.17,.38,.57),vec3(.45,.68,.75),
                              smoothstep(.2,.8,vKind));
                outColor=vec4(mix(blue,hot,uDark),
                              a*mix(.20,.85,uDark));
            }`;
 
        // GPU flame geometry, not Canvas pretending to be a WebGL effect.
        // Each instance is an upward-rising, tapered turbulent flame sheet.
        // Varied sizes/positions create hundreds of individual white-hot
        // lobes, replacing flat horizontal gradient bands.
        const plumeVert=`#version 300 es
            ${sharedFns}
            uniform vec2 uView;
            uniform float uClock,uProgress,uDark;
            out vec2 vLocal;
            out float vSeed,vHeat,vAge;
            void main(){
                 int v=gl_VertexID;
                vec2 corners[6]=vec2[6](
                    vec2(-1.0,-1.0),vec2(1.0,-1.0),vec2(-1.0,1.0),
                    vec2(-1.0,1.0),vec2(1.0,-1.0),vec2(1.0,1.0));
                vLocal=corners[v];
                float id=float(gl_InstanceID);
                float seed=hash(vec2(id*1.71+8.1,id*.37+2.4));
                float phase=fract(seed+uClock*(.19+
                    .22*hash(vec2(id*.91+2.1,3.4))));
                float y=hash(vec2(id*3.41+9.2,4.1))*
                    (uView.y+220.0)+80.0-
                    phase*(145.0+150.0*seed);
                float span=clamp(uView.x*.225,175.0,475.0);
                float trail=19.0+pow(hash(vec2(id*2.17,5.3)),.75)*
                    span*.88;
                float centerX=stormEdge(y,uProgress,uClock,
                    uView.x,uDark)+trail+
                    sin(uClock*2.9+seed*6.283+phase*5.0)*
                    (7.0+12.0*phase);
                float width=(14.0+50.0*hash(vec2(id*1.93,7.2)))*
                    (.75+.30*sin(phase*3.14159));
                 float height=(57.0+131.0*hash(vec2(id*.89,11.1)))*
                    (.76+.25*seed);
                vec2 pos=vec2(centerX+vLocal.x*width,
                    y-vLocal.y*height*.5);
                gl_Position=vec4(pos.x/uView.x*2.0-1.0,
                    1.0-pos.y/uView.y*2.0,0.0,1.0);
                vSeed=seed;
                vAge=phase;
                vHeat=smoothstep(.02,.17,phase)*
                    (1.0-smoothstep(.77,1.0,phase))*
                    sin(3.14159265*uProgress);
            }`;
        const plumeFrag=`#version 300 es
            precision highp float;
            uniform float uClock;
            in vec2 vLocal;
            in float vSeed,vHeat,vAge;
            out vec4 outColor;
            float hash2(vec2 p){
                 return fract(sin(dot(p,vec2(127.1,311.7)))*
                    43758.5453123);
             }
             }
             float ns(vec2 p){
             ctx.restore();
                vec2 i=floor(p),f=fract(p);
            // Irregular burnt page sections tumble away and reveal their
                f=f*f*(3.0-2.0*f);
            // blackened underside, with an occasional incandescent rim.
                 return mix(mix(hash2(i),hash2(i+vec2(1,0)),f.x),
            if(dark){
                    mix(hash2(i+vec2(0,1)),hash2(i+1.0),f.x),f.y);
                 ctx.save();
            }
                 ctx.globalCompositeOperation='source-over';
            float turb(vec2 p){
                 for(const p of tornPaper){
                 float f=0.0,a=.56;
                     const age=1-p.remaining/p.life;
                 for(int i=0;i<4;i++){
                     const opacity=clamp01(age/.08)*clamp01(p.remaining/.4)*fade;
                     f+=a*ns(p);
                    if(opacity<.01)continue;
                    p=mat2(1.72,-1.06,1.06,1.72)*p+8.12;
                    ctx.save();
                     a*=.50;
                     ctx.globalAlpha=opacity;
                }
                    ctx.translate(p.x,p.y);
                return f;
                    ctx.rotate(p.angle);
            }
                    ctx.beginPath();
            void main(){
                     p.outline.forEach((v,i)=>{
                float rise=(vLocal.y+1.0)*.5;
                        const px=Math.cos(v.a)*v.r*p.radius;
                vec2 u=vec2(vLocal.x*2.15+vSeed*9.0,
                        const py=Math.sin(v.a)*v.r*p.radius*.74;
                    rise*4.6-uClock*1.9+vSeed*14.0);
                        if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
                float warp=turb(u);
                     });
                float detail=turb(u*2.3+warp*1.8);
                     ctx.closePath();
                float swirl=.17*sin(rise*7.2+
                    ctx.shadowBlur=7;
                    uClock*3.0+vSeed*12.0)+
                    ctx.shadowColor='#090506';
                     (warp-.48)*.31;
                    ctx.fillStyle=p.seed>3?'#190d11':'#302025';
                float pinch=.13+.79*pow(1.0-rise,.69);
                    ctx.fill();
                float cut=abs(vLocal.x+swirl+(detail-.5)*.18);
                    ctx.shadowBlur=0;
                float shredded=pinch+(warp-.48)*.22+
                    ctx.lineWidth=.8+Math.sin(p.seed)**2*.8;
                    (detail-.5)*.15;
                    ctx.strokeStyle=age<.48?'rgba(255,107,35,.48)':
                float plume=1.0-smoothstep(shredded-.20,
                        'rgba(49,20,19,.47)';
                     shredded+.06,cut);
                    ctx.stroke();
                plume*=smoothstep(.015,.14,rise)*
                    // Thin blue-white unburnt fibers still visible on a
                    (1.0-smoothstep(.81,1.02,rise));
                    // few ripped faces, before the back chars completely.
                float core=1.0-smoothstep(pinch*.19,
                    if(p.seed>4.5&&age<.52){
                    pinch*.70,cut);
                        ctx.globalAlpha*=.45*(1-age);
                core*=smoothstep(.05,.16,rise)*
                        ctx.strokeStyle='#c5e7f7';
                    (1.0-smoothstep(.56,.89,rise));
                        ctx.beginPath();
                float flux=smoothstep(.22,.72,warp*.68+
                        ctx.moveTo(-p.radius*.42,-p.radius*.16);
                    detail*.43+.14*sin(rise*8.0+vSeed*11.0));
                        ctx.quadraticCurveTo(0,p.radius*.25,p.radius*.28,0);
                float gold=plume*(.56+.44*flux);
                        ctx.stroke();
                float white=core*(.31+.69*flux);
                    }
                vec3 col=mix(vec3(.94,.074,.008),
                    ctx.restore();
                    vec3(1.0,.46,.015),clamp(gold,0.0,1.0));
                 }
                col=mix(col,vec3(1.0,.91,.36),
                 ctx.restore();
                    clamp(gold*.43+white*.55,0.0,1.0));
                col=mix(col,vec3(1.0,.998,.86),
                     clamp(white*.80,0.0,1.0));
                float a=plume*vHeat*(.15+.15*flux)+
                     white*vHeat*.10;
                outColor=vec4(col,clamp(a,0.0,.56));
            }`;
 
        function compile(type,source){
            const shader=gl.createShader(type);
            gl.shaderSource(shader,source);
            gl.compileShader(shader);
            if(!gl.getShaderParameter(shader,gl.COMPILE_STATUS)){
                const log=gl.getShaderInfoLog(shader);
                gl.deleteShader(shader);
                throw new Error('WebGL shader compilation: '+log);
            }
            return shader;
        }
        function program(v,f){
            const vert=compile(gl.VERTEX_SHADER,v);
            const frag=compile(gl.FRAGMENT_SHADER,f);
            const result=gl.createProgram();
            gl.attachShader(result,vert);gl.attachShader(result,frag);
            gl.linkProgram(result);
            gl.deleteShader(vert);gl.deleteShader(frag);
            if(!gl.getProgramParameter(result,gl.LINK_STATUS)){
                const log=gl.getProgramInfoLog(result);
                gl.deleteProgram(result);
                throw new Error('WebGL program link: '+log);
            }
            const names=['uView','uClock','uProgress','uDark',
                'uPhase','uTail','uScale','uRenderMode'];
            return {program:result,locations:Object.fromEntries(
                names.map(name=>[name,gl.getUniformLocation(result,name)]))};
        }
        function setUniforms(p,clock,progress,phase,tail,scale){
            gl.useProgram(p.program);
            const u=p.locations;
            if(u.uView)gl.uniform2f(u.uView,view[0],view[1]);
            if(u.uClock)gl.uniform1f(u.uClock,clock);
            if(u.uProgress)gl.uniform1f(u.uProgress,progress);
            if(u.uDark)gl.uniform1f(u.uDark,dark?1:0);
            if(u.uPhase)gl.uniform1f(u.uPhase,phase);
            if(u.uTail)gl.uniform1f(u.uTail,tail);
            if(u.uScale)gl.uniform1f(u.uScale,scale);
        }
        function resize(){
            view[0]=innerWidth;view[1]=innerHeight;
            // A denser shader but a smaller framebuffer; smoke is naturally
            // soft, so sub-native resolution is barely visible and avoids
            // excessive GPU load on phones or high-DPI displays.
            // Resolve 10px fracture corners faithfully (V29's .61 was
            // visibly smoothing the very shape that should feel torn).
            const desired=mobile?.62:.82;
            const maxPixels=mobile?330000:1050000;
            const bound=Math.sqrt(maxPixels/Math.max(1,view[0]*view[1]));
            const scale=Math.min(desired,bound,window.devicePixelRatio||1);
            const w=Math.max(1,Math.round(view[0]*scale));
            const h=Math.max(1,Math.round(view[1]*scale));
            if(surface.width!==w||surface.height!==h){
                 surface.width=w;surface.height=h;
                 gl.viewport(0,0,w,h);
             }
             }
             return w/Math.max(1,view[0]);
             // Sharp, directional embers / crystalline fragments, not blobs.
        }
            if(dark){ctx.save();
        function render(progress,phase,tail,clock){
             ctx.globalCompositeOperation='screen';
             if(contextLost||disposed)return false;
             for(const mote of motes){
             try{
                 const age=(elapsed*.74+mote.phase)%1;
                 const scale=resize();
                 const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
                 gl.disable(gl.BLEND);
                 const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
                 gl.clearColor(0,0,0,0);
                 if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
                 gl.clear(gl.COLOR_BUFFER_BIT);
                 const opacity=(1-age)*fade*.89;
                 setUniforms(curtain,clock,progress,phase,tail,scale);
                 ctx.globalAlpha=opacity;
                 gl.drawArrays(gl.TRIANGLES,0,3);
                ctx.strokeStyle='#ffb45d';
                 if(dark){
                 ctx.lineWidth=.8;
                    gl.enable(gl.BLEND);
                ctx.beginPath();
                    // Charcoal dust is physically in FRONT of burning
                ctx.moveTo(x,yy);
                    // paper but BELOW the incandescent plume layer.
                ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
                    setUniforms(sparks,clock,progress,phase,tail,scale);
                ctx.stroke();
                    if(sparks.locations.uRenderMode)
            }
                        gl.uniform1f(sparks.locations.uRenderMode,0);
            ctx.restore();}
                    gl.blendFuncSeparate(gl.SRC_ALPHA,
        };
                        gl.ONE_MINUS_SRC_ALPHA,gl.ONE,
    }
                        gl.ONE_MINUS_SRC_ALPHA);
                    gl.drawArrays(gl.POINTS,0,mobile?750:1700);


                    gl.blendFuncSeparate(gl.SRC_ALPHA,gl.ONE,
    let atmosphereWarmPromise=null;
                        gl.ONE,gl.ONE_MINUS_SRC_ALPHA);
    function prewarmAtmosphere() {
                    setUniforms(plumes,clock,progress,phase,tail,scale);
        if(atmosphereWarmPromise)return atmosphereWarmPromise;
                    const flameCount=Math.round((view[1]/360)*
        atmosphereWarmPromise=new Promise(resolve=>{
                        (mobile?42:51)*Math.min(1.45,view[0]/640));
            const scratchCanvas=document.createElement('canvas');
                    gl.drawArraysInstanced(gl.TRIANGLES,0,6,
             function step(){
                        Math.max(20,Math.min(flameCount,mobile?115:210)));
                if(atmosphereAssetCache?.EMBER_GLOW){resolve();return;}
 
                // Exactly one texture per task: no more 270ms blocking call.
                    // Actual WebGL2 cinders above the flames, in addition
                createAtmosphere(scratchCanvas,true);
                    // to V25's longer 2D particle streaks.
                 if(atmosphereAssetCache?.EMBER_GLOW){resolve();return;}
                    setUniforms(sparks,clock,progress,phase,tail,scale);
                 setTimeout(step,24);
                    if(sparks.locations.uRenderMode)
                        gl.uniform1f(sparks.locations.uRenderMode,1);
                    gl.drawArrays(gl.POINTS,0,mobile?1500:3300);
                    gl.disable(gl.BLEND);
                }
                // The atmosphere callback paints background soot on a
                // lower canvas and white-hot embers on the upper one.
                return true;
            }catch(error){
                console.warn('[KuranokaPageTransition] GPU frame error:',error);
                contextLost=true;
                return false;
             }
        }
        function onContextLost(event){
            event.preventDefault();
            contextLost=true;
            console.warn('[KuranokaPageTransition] GPU context lost; '+
                'continuing with native navigation.');
        }
        function dispose(){
            if(disposed)return;
            disposed=true;
            cancelAnimationFrame(raf);
            surface.removeEventListener('webglcontextlost',onContextLost);
            window.removeEventListener('pagehide',dispose);
            if(!contextLost){
                 try{
                    if(curtain)gl.deleteProgram(curtain.program);
                    if(sparks)gl.deleteProgram(sparks.program);
                    if(plumes)gl.deleteProgram(plumes.program);
                 }catch(_){}
             }
             }
             surface.remove();
             setTimeout(step,40);
        }
         });
        try{
         return atmosphereWarmPromise;
            gl=surface.getContext('webgl2',{
                alpha:true,antialias:false,depth:false,stencil:false,
                // We intentionally render at ~30fps. Retain the last
                // frame between GPU submissions to avoid blank flashes.
                premultipliedAlpha:false,preserveDrawingBuffer:true,
                powerPreference:mobile?'low-power':'high-performance'
            });
            if(!gl){surface.remove();return null;}
            curtain=program(backgroundVert,dominantFireFrag);
            if(dark){
                plumes=program(plumeVert,plumeFrag);
                sparks=program(sparkVert,sparkFrag);
            }
            // No abstract, additive GPU spark pass: P3's fire requires
            // textured smoke and incandescent streaks, not neon points.
            gl.disable(gl.DEPTH_TEST);
            gl.disable(gl.SCISSOR_TEST);
            gl.disable(gl.CULL_FACE);
            surface.addEventListener('webglcontextlost',onContextLost);
            window.addEventListener('pagehide',dispose,{once:true});
            window.__kkptRenderer='WebGL2';
         }catch(error){
            console.warn('[KuranokaPageTransition] WebGL unavailable. '+
                'Using Canvas 2D fallback.',error);
            dispose();
            return null;
         }
        return {
            surface,
            dispose,
            attachAtmosphere(draw){attachedAtmosphere=draw;},
            play(duration){
                return new Promise(resolve=>{
                    let started=null,lastDraw=-Infinity,lastClip=-Infinity;
                    function tick(now){
                        if(started===null)started=now;
                        if(disposed||contextLost){resolve();return;}
                        const t=Math.min(1,(now-started)/duration);
                        const clock=now/1000;
                        const edge=makeEdge(t,clock,dark,innerWidth);
                        atmosphereEdge=edge;
                        window.__kkptVisualDark=dark;
                        if(now-lastClip>=(mobile?35:25)||t>=1){
                            clipFrame(edge);
                            lastClip=now;
                        }
                        if(now-lastDraw>=(mobile?43:34)||t>=1){
                            if(!render(t,0,0,clock)){resolve();return;}
                            if(attachedAtmosphere){
                                attachedAtmosphere(clock,Math.min(t,.985),dark,
                                    (now-started)/1000,[]);
                            }
                            lastDraw=now;
                        }
                        if(t<1)raf=requestAnimationFrame(tick);
                        else resolve();
                    }
                    raf=requestAnimationFrame(tick);
                });
            },
            dissipate(duration){
                return new Promise(resolve=>{
                    let started=null,lastDraw=-Infinity;
                    function tick(now){
                        if(started===null)started=now;
                        if(disposed||contextLost){resolve();return;}
                        const t=Math.min(1,(now-started)/duration);
                        if(now-lastDraw>=(mobile?50:40)||t>=1){
                            if(!render(1,1,Math.pow(1-t,1.25),now/1000)){
                                resolve();return;
                            }
                            lastDraw=now;
                        }
                        if(t<1)raf=requestAnimationFrame(tick);
                        else resolve();
                    }
                    raf=requestAnimationFrame(tick);
                });
            }
        };
     }
     }


     // Canvas 2D fallback: deliberately retained for WebGL-disabled
     let atmosphereEdge=null;
    // browsers, software rendering, or shader compilation failures.
     function edgeAt(y,seconds,progress,width){
     function canvasFire(canvas,dark,duration,drawAtmosphere,done) {
         return atmosphereEdge
         const ctx=dark&&canvas
             ? atmosphereEdge(y)
             ? canvas.getContext('2d',{alpha:true,desynchronized:true})
             : makeEdge(progress,seconds,Boolean(window.__kkptVisualDark),width)(y);
             : null;
    }
        if(dark&&!ctx){done();return;}
        const art=dark?makeSprites():null;
        const w=innerWidth,h=innerHeight;
        if(ctx){
            const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80);
            canvas.width=Math.max(1,Math.round(w*scale));
            canvas.height=Math.max(1,Math.round(h*scale));
            ctx.setTransform(scale,0,0,scale,0,0);
        }


        const rand=(a,b)=>a+Math.random()*(b-a);
    // Original V2's moving flame sprites and three bright contours, rebuilt
         const mobile=w<650;
    // with bounded particle counts. No WebGL, no full-screen noise pass,
        const maxCount=mobile?160:250;
    // no 1,000-point polygon or per-frame image-texture upload.
        const p=[];
    let sprites=null;
        const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0};
    function makeSprites() {
         const rates=dark
        if (sprites) return sprites;
             ? {flame:mobile?80:105,ember:mobile?72:96,
         function radial(stops) {
              shard:mobile?26:42}
            const el=document.createElement('canvas');
             : {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50};
            el.width=el.height=96;
        const started=performance.now();
            const c=el.getContext('2d');
         let previous=started;
            const g=c.createRadialGradient(48,48,0,48,48,48);
         let lastPaint=0;
            stops.forEach(pair=>g.addColorStop(pair[0],pair[1]));
        let lastClip=0;
            c.fillStyle=g;c.fillRect(0,0,96,96);
        function spawn(kind,edge,seconds) {
            return el;
             if(p.length>=maxCount)return;
        }
             const y=rand(-25,h+25);
         sprites={
            const x=edge(y);
             flame:radial([[0,'#fffbed'],[.18,'#fff2a0'],
             const type=kind;
                [.43,'#ffb42b'],[.70,'#ff360088'],[1,'#8f050000']]),
            const smoke=type==='smoke';
            ember:radial([[0,'#ffffff'],[.15,'#fff6a8'],
            const shard=type==='shard';
                [.44,'#ff9900cc'],[1,'#ef280000']]),
            const ember=type==='ember'||type==='pollen';
            smoke:radial([[0,'#080608dd'],[.30,'#100b10bb'],
             const blue=type==='sky'||type==='mist'||type==='pollen';
                [.62,'#160f1677'],[1,'#08050800']]),
             let radius,life,vx,vy;
             sky:radial([[0,'#ffffff'],[.16,'#e6ffff'],
            if(shard){
                [.46,'#94deffe0'],[1,'#4ab5ff00']]),
                 radius=rand(4,18);life=rand(.6,1.65);
            mist:radial([[0,'#f2fdffb0'],[.4,'#abdffff0'],
                vx=rand(-130,65);vy=rand(-125,30);
                [1,'#a9e7ff00']])
             }else if(smoke){
         };
                radius=rand(32,85);life=rand(1.1,2.5);
         return sprites;
                vx=rand(-20,65);vy=rand(-88,-22);
    }
            }else if(type==='mist'){
 
                radius=rand(45,100);life=rand(.9,1.8);
    // V2's simple advancing boundary. The reader asked for the
                vx=rand(-35,45);vy=rand(-75,-15);
    // vibrant fire curtain, not a mathematically fractal tearing wire.
             }else if(type==='flame'){
    function makeEdge(t,seconds,dark,w) {
                 radius=rand(20,57);life=rand(.3,.82);
        const center=dark
                 vx=rand(-100,30);vy=rand(-180,-45);
             ? w+105-(w+210)*smooth(t)
            }else{
             : -105+(w+210)*smooth(t);
                 radius=ember?rand(2.5,9):rand(18,47);
        const wobble=Math.sin(Math.PI*t);
                life=ember?rand(.45,1.1):rand(.35,.85);
        return y=>center+wobble*(
                 vx=rand(-95,75);vy=rand(-150,-25);
             17*Math.sin(y*.023+seconds*5.5)+
            9*Math.sin(y*.063-seconds*8.4)+
            4*Math.sin(y*.13+seconds*12)
        );
    }
 
    // GPU-only ember flecks: ONE small draw call at most 30fps.
    // The white-hot V25 fire, the smoke and the dawn stay unmodified 2D.
    // No fullscreen pixel shader, FBM, flame quads or cyan rectangle.
    function createGpuEmbers(){
        const canvas=document.createElement('canvas');
        canvas.id='kkpt-webgl';
        canvas.setAttribute('aria-hidden','true');
        let gl,program,buffer;
        try{
             gl=canvas.getContext('webgl2',{
                alpha:true,antialias:false,depth:false,stencil:false,
                premultipliedAlpha:false,preserveDrawingBuffer:false,
                powerPreference:'low-power'
            });
            if(!gl)throw new Error('WebGL2 context unavailable');
             const vert=`#version 300 es
                precision mediump float;
                layout(location=0) in vec4 aSpark;
                uniform vec2 uSize;
                uniform float uScale;
                out float vOpacity;
                 void main(){
                    vec2 xy=aSpark.xy/uSize*2.0-1.0;
                    gl_Position=vec4(xy.x,-xy.y,0.0,1.0);
                    gl_PointSize=aSpark.z*uScale;
                    vOpacity=aSpark.w;
                }`;
             const frag=`#version 300 es
                precision mediump float;
                in float vOpacity;
                out vec4 fragColor;
                void main(){
                    vec2 p=gl_PointCoord*2.0-1.0;
                    float r=length(p);
                    if(r>=1.0)discard;
                    float a=pow(1.0-r,1.65)*vOpacity;
                    vec3 c=mix(vec3(1.0,.30,.035),
                        vec3(1.0,.93,.57),smoothstep(.40,.92,1.0-r));
                    fragColor=vec4(c,a);
                }`;
             function shader(type,source){
                 const s=gl.createShader(type);
                 gl.shaderSource(s,source);gl.compileShader(s);
                 if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))
                    throw new Error(gl.getShaderInfoLog(s));
                 return s;
             }
             }
             p.push({
             const vs=shader(gl.VERTEX_SHADER,vert);
                type,life,left:life,
            const fs=shader(gl.FRAGMENT_SHADER,frag);
                 x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64),
            program=gl.createProgram();
                y:y+rand(-14,14),vx,vy,radius,
            gl.attachShader(program,vs);gl.attachShader(program,fs);
                angle:rand(-Math.PI,Math.PI),spin:rand(-5,5),
            gl.linkProgram(program);
                drift:rand(-.65,.65),blue
            gl.deleteShader(vs);gl.deleteShader(fs);
             });
            if(!gl.getProgramParameter(program,gl.LINK_STATUS))
                 throw new Error(gl.getProgramInfoLog(program));
            buffer=gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
            gl.bufferData(gl.ARRAY_BUFFER,170*4*4,gl.DYNAMIC_DRAW);
            gl.enableVertexAttribArray(0);
            gl.vertexAttribPointer(0,4,gl.FLOAT,false,0,0);
            gl.useProgram(program);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
            gl.disable(gl.DEPTH_TEST);
        }catch(err){
            console.warn('[KKPT] Ember-only WebGL disabled:',err);
            canvas.remove();
             return null;
         }
         }
         function spawnAtRate(dt,edge,seconds,strength){
        document.body.append(canvas);
             for(const type in rates){
        const sizeUniform=gl.getUniformLocation(program,'uSize');
                 accum[type]+=rates[type]*dt*strength;
        const scaleUniform=gl.getUniformLocation(program,'uScale');
                const n=Math.min(11,Math.floor(accum[type]));
        const max=innerWidth<700?80:170;
                accum[type]-=n;
        const data=new Float32Array(max*4);
                for(let i=0;i<n;i++)spawn(type,edge,seconds);
        const sparks=[];
        let carry=0,disabled=false,lastW=0,lastH=0;
        canvas.addEventListener('webglcontextlost',event=>{
            event.preventDefault();disabled=true;
            window.__kkptRenderer='Canvas2D (GPU context lost)';
        },{once:true});
        const rand=(min,max)=>min+Math.random()*(max-min);
         function draw(edge,seconds,dt,fade,progress){
             if(disabled)return;
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.67:.75;
            const cw=Math.round(w*scale),ch=Math.round(h*scale);
            if(cw!==lastW||ch!==lastH){
                 canvas.width=cw;canvas.height=ch;
                gl.viewport(0,0,cw,ch);lastW=cw;lastH=ch;
            }
            gl.clearColor(0,0,0,0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.useProgram(program);
            gl.uniform2f(sizeUniform,w,h);
            gl.uniform1f(scaleUniform,scale);
            carry+=dt*(w<700?27:56)*Math.max(0,fade);
            const births=Math.min(5,Math.floor(carry));carry-=births;
            for(let i=0;i<births&&sparks.length<max;i++){
                const y=rand(0,h),life=rand(.55,1.65);
                sparks.push({x:edge(y)+rand(-7,23),y,
                    vx:rand(48,265),vy:rand(-195,-26),life,left:life,
                    size:rand(3,13),alpha:rand(.24,.60)});
            }
            let count=0;
            for(let i=sparks.length-1;i>=0;i--){
                const p=sparks[i];
                p.left-=dt;
                if(p.left<=0){sparks[i]=sparks[sparks.length-1];sparks.pop();continue;}
                p.x+=p.vx*dt;p.y+=p.vy*dt;
                if(p.x < -30||p.x>w+30||p.y < -30||p.y>h+30)continue;
                const offset=count++*4;
                data[offset]=p.x;data[offset+1]=p.y;
                data[offset+2]=p.size;
                data[offset+3]=p.alpha*fade*Math.min(1,p.left/.24);
             }
             }
            if(!count)return;
            gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
            gl.bufferSubData(gl.ARRAY_BUFFER,0,data.subarray(0,count*4));
            gl.drawArrays(gl.POINTS,0,count);
         }
         }
         function advance(dt){
         function dispose(){
             for(let i=p.length-1;i>=0;i--) {
             disabled=true;
                 const q=p[i];
            if(!gl.isContextLost()){
                q.left-=dt;
                 gl.deleteBuffer(buffer);gl.deleteProgram(program);
                if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;}
                q.x+=q.vx*dt;
                q.y+=q.vy*dt;
                q.vx+=q.drift*dt*35;
                q.vy-=10*dt;
                q.angle+=q.spin*dt;
             }
             }
            canvas.remove();
         }
         }
         function spriteDraw(image,x,y,sx,sy,alpha){
         return {canvas,draw,dispose};
             ctx.globalAlpha=alpha;
    }
             ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2);
 
    // Clip the ACTUAL preloaded Wiki frame, precisely where the fire is.
    function clipFrame(edge) {
        const w=innerWidth,h=innerHeight;
        const rows=Math.max(24,Math.ceil(h/30));
        const pts=[];
        for(let i=rows;i>=0;i--) {
             const y=h*i/rows;
             pts.push(edge(y).toFixed(1)+'px '+y.toFixed(1)+'px');
         }
         }
         function drawSmoke(fade){
         frame.style.clipPath=clipSide==='right'
             ctx.globalCompositeOperation='source-over';
            ? 'polygon(100% 0,100% 100%,'+pts.join(',')+')'
            for(const q of p){
             : 'polygon(0 0,0 100%,'+pts.join(',')+')';
                if(q.type!=='smoke'&&q.type!=='mist')continue;
    }
                const age=1-q.left/q.life;
 
                const radius=q.radius*(1+age*.98);
    function canvasFire(canvas,dark,duration,drawAtmosphere,done,gpuEmbers,score) {
                const alpha=(q.type==='smoke'?.76:.32)*
        const ctx=dark&&canvas
                    Math.min(1,age*5)*Math.min(1,q.left/.55)*fade;
            ? canvas.getContext('2d',{alpha:true,desynchronized:true})
                spriteDraw(q.type==='smoke'?art.smoke:art.mist,
            : null;
                    q.x,q.y,radius*1.7,radius*1.3,alpha);
        if(dark&&!ctx){done();return;}
            }
        const art=dark?makeSprites():null;
            ctx.globalAlpha=1;
        const w=innerWidth,h=innerHeight;
        if(ctx){
            const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80);
            canvas.width=Math.max(1,Math.round(w*scale));
            canvas.height=Math.max(1,Math.round(h*scale));
            ctx.setTransform(scale,0,0,scale,0,0);
         }
         }
         function drawShards(fade) {
 
             if(!dark)return;
        const rand=(a,b)=>a+Math.random()*(b-a);
             ctx.save();
        const mobile=w<650;
             ctx.globalCompositeOperation='source-over';
        const maxCount=mobile?160:250;
             for(const q of p){
        const p=[];
                if(q.type!=='shard')continue;
        const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0};
                const age=1-q.left/q.life;
        const rates=dark
                 const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade;
            ? {flame:mobile?80:105,ember:mobile?72:96,
                 ctx.save();
              shard:mobile?26:42}
                ctx.translate(q.x,q.y);
            : {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50};
                ctx.rotate(q.angle);
        const started=performance.now();
                 ctx.globalAlpha=a;
        let previous=started;
                const r=q.radius;
        let lastPaint=0;
                 ctx.beginPath();
        let lastClip=0;
                ctx.moveTo(-r*.9,-r*.3);
         function spawn(kind,edge,seconds) {
                 ctx.lineTo(r*.7,-r*.75);
             if(p.length>=maxCount)return;
                ctx.lineTo(r*.48,r*.42);
             const y=rand(-25,h+25);
                 ctx.lineTo(-r*.35,r*.8);
            const x=edge(y);
                 ctx.closePath();
             const type=kind;
                ctx.fillStyle=age<.3?'#371d22':'#140d13';
            const smoke=type==='smoke';
                 ctx.fill();
             const shard=type==='shard';
                 ctx.strokeStyle=age<.42?'#f57d3c':'#5a2c2b';
            const ember=type==='ember'||type==='pollen';
                 ctx.lineWidth=age<.42?1.6:.75;
            const blue=type==='sky'||type==='mist'||type==='pollen';
                 ctx.stroke();
            let radius,life,vx,vy;
                ctx.restore();
            if(shard){
                 radius=rand(4,18);life=rand(.6,1.65);
                 vx=rand(-130,65);vy=rand(-125,30);
            }else if(smoke){
                 radius=rand(32,85);life=rand(1.1,2.5);
                 vx=rand(-20,65);vy=rand(-88,-22);
            }else if(type==='mist'){
                 radius=rand(45,100);life=rand(.9,1.8);
                 vx=rand(-35,45);vy=rand(-75,-15);
            }else if(type==='flame'){
                 radius=rand(20,57);life=rand(.3,.82);
                 vx=rand(-100,30);vy=rand(-180,-45);
            }else{
                 radius=ember?rand(2.5,9):rand(18,47);
                 life=ember?rand(.45,1.1):rand(.35,.85);
                 vx=rand(-95,75);vy=rand(-150,-25);
             }
             }
             ctx.restore();
             p.push({
                type,life,left:life,
                x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64),
                y:y+rand(-14,14),vx,vy,radius,
                angle:rand(-Math.PI,Math.PI),spin:rand(-5,5),
                drift:rand(-.65,.65),blue
            });
         }
         }
         function drawCore(edge,seconds,t,fade){
         function spawnAtRate(dt,edge,seconds,strength){
             const waviness=Math.sin(Math.PI*t);
             for(const type in rates){
            ctx.save();
                accum[type]+=rates[type]*dt*strength;
            ctx.beginPath();
                const n=Math.min(11,Math.floor(accum[type]));
             const count=Math.max(35,Math.ceil(h/19));
                accum[type]-=n;
             for(let i=0;i<=count;i++){
                for(let i=0;i<n;i++)spawn(type,edge,seconds);
                 const y=h*i/count;
             }
                 if(i===0)ctx.moveTo(edge(y),y);
        }
                 else ctx.lineTo(edge(y),y);
        function advance(dt){
             for(let i=p.length-1;i>=0;i--) {
                 const q=p[i];
                q.left-=dt;
                 if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;}
                q.x+=q.vx*dt;
                q.y+=q.vy*dt;
                q.vx+=q.drift*dt*35;
                q.vy-=10*dt;
                 q.angle+=q.spin*dt;
             }
             }
            ctx.lineCap='round';
        }
            ctx.lineJoin='round';
        function spriteDraw(image,x,y,sx,sy,alpha){
            if(dark){
            ctx.globalAlpha=alpha;
                ctx.globalCompositeOperation='lighter';
            ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2);
                ctx.globalAlpha=fade;
        }
                ctx.shadowColor='#ff3903';
        function drawSmoke(fade){
                ctx.shadowBlur=25;
            ctx.globalCompositeOperation='source-over';
                ctx.strokeStyle='rgba(255,55,5,.82)';
            for(const q of p){
                ctx.lineWidth=43;
                 if(q.type!=='smoke'&&q.type!=='mist')continue;
                ctx.stroke();
                 const age=1-q.left/q.life;
                ctx.shadowBlur=16;
                 const radius=q.radius*(1+age*.98);
                ctx.strokeStyle='#ff9b17';
                 const alpha=(q.type==='smoke'?.76:.32)*
                ctx.lineWidth=20;
                    Math.min(1,age*5)*Math.min(1,q.left/.55)*fade;
                ctx.stroke();
                 spriteDraw(q.type==='smoke'?art.smoke:art.mist,
                 ctx.shadowBlur=7;
                    q.x,q.y,radius*1.7,radius*1.3,alpha);
                ctx.strokeStyle='#ffe45e';
                ctx.lineWidth=9;
                ctx.stroke();
                ctx.shadowBlur=4;
                ctx.strokeStyle='#fffcde';
                 ctx.lineWidth=3.8;
                 ctx.stroke();
            }else{
                 ctx.globalAlpha=fade*.64;
                ctx.shadowColor='#8ccfff';
                ctx.shadowBlur=29;
                ctx.strokeStyle='rgba(139,219,255,.68)';
                 ctx.lineWidth=28;
                ctx.stroke();
                ctx.shadowBlur=10;
                ctx.strokeStyle='#e7f8ff';
                ctx.lineWidth=9;
                ctx.stroke();
                ctx.shadowBlur=0;
                ctx.strokeStyle='white';
                ctx.lineWidth=2;
                ctx.stroke();
             }
             }
             ctx.restore();
             ctx.globalAlpha=1;
 
        }
             // Dense GOLD / WHITE rolling fire, including on both sides
        function drawShards(fade) {
             // of the border. These are the luminous shapes from V2.
            if(!dark)return;
            if(dark){
            ctx.save();
             ctx.globalCompositeOperation='source-over';
             for(const q of p){
                if(q.type!=='shard')continue;
                const age=1-q.left/q.life;
                const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade;
                 ctx.save();
                 ctx.save();
                 ctx.globalCompositeOperation='lighter';
                 ctx.translate(q.x,q.y);
                for(let y=-70;y<h+80;y+=48){
                 ctx.rotate(q.angle);
                    const x=edge(y);
                ctx.globalAlpha=a;
                    const pulse=.65+.35*Math.sin(seconds*8.5+y*.083);
                const r=q.radius;
                    spriteDraw(art.flame,x+rand(-8,30),y,77,70,
                ctx.beginPath();
                        fade*(.26+.16*pulse));
                ctx.moveTo(-r*.9,-r*.3);
                 }
                ctx.lineTo(r*.7,-r*.75);
                for(let y=-20;y<h+52;y+=36){
                ctx.lineTo(r*.48,r*.42);
                    const x=edge(y);
                ctx.lineTo(-r*.35,r*.8);
                    const flicker=Math.sin(seconds*13+y*.19);
                ctx.closePath();
                    const length=52+30*flicker+17*Math.sin(seconds*6-y*.11);
                ctx.fillStyle=age<.3?'#371d22':'#140d13';
                    const lean=15+18*Math.sin(seconds*9+y*.069);
                ctx.fill();
                    ctx.globalAlpha=.62*fade;
                ctx.strokeStyle=age<.42?'#f57d3c':'#5a2c2b';
                    ctx.fillStyle='#ff5705';
                ctx.lineWidth=age<.42?1.6:.75;
                    ctx.beginPath();
                ctx.stroke();
                    ctx.moveTo(x+18,y+19);
                 ctx.restore();
                    ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57,
             }
                        x-lean,y-length);
            ctx.restore();
                    ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19);
                    ctx.fill();
                    ctx.globalAlpha=.58*fade;
                    ctx.fillStyle='#ffdb58';
                    ctx.beginPath();
                    ctx.moveTo(x+7,y+8);
                    ctx.quadraticCurveTo(x-10,y-length*.33,
                        x-lean*.8,y-length*.72);
                    ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8);
                    ctx.fill();
                    ctx.globalAlpha=.38*fade;
                    ctx.fillStyle='#fffced';
                    ctx.beginPath();
                    ctx.moveTo(x+3,y+5);
                    ctx.quadraticCurveTo(x-5,y-length*.2,
                        x-lean*.5,y-length*.48);
                    ctx.quadraticCurveTo(x+4,y-length*.19,x+5,y+5);
                    ctx.fill();
                }
                 ctx.restore();
             }else{
                ctx.save();
                ctx.globalCompositeOperation='lighter';
                for(let y=-50;y<h+55;y+=88){
                    spriteDraw(art.sky,edge(y),y,89,109,.27*fade);
                }
                ctx.restore();
            }
         }
         }
         function drawBrightParticles(fade) {
         function drawCore(edge,seconds,t,fade){
            const waviness=Math.sin(Math.PI*t);
             ctx.save();
             ctx.save();
             ctx.globalCompositeOperation='lighter';
             ctx.beginPath();
            for(const q of p){
            const count=Math.max(35,Math.ceil(h/19));
                if(q.type==='smoke'||q.type==='mist'||q.type==='shard')continue;
            for(let i=0;i<=count;i++){
                const age=1-q.left/q.life;
                 const y=h*i/count;
                const brightness=Math.min(1,q.left/.25)*(1-age*.25)*fade;
                 if(i===0)ctx.moveTo(edge(y),y);
                let artName='flame',sx=q.radius,sy=q.radius;
                 else ctx.lineTo(edge(y),y);
                 if(q.type==='ember'){artName='ember';sx*=1.2;sy*=1.2;}
                 else if(q.type==='pollen'){artName='sky';sx*=1.15;sy*=1.15;}
                else if(q.type==='sky'){artName='sky';sx*=1.1;sy*=1.55;}
                 else {sx*=.9;sy*=1.65;}
                spriteDraw(art[artName],q.x,q.y,sx,sy,
                    brightness*(q.type==='ember'?.93:.78));
             }
             }
             ctx.restore();
             ctx.lineCap='round';
            ctx.globalAlpha=1;
            ctx.lineJoin='round';
            ctx.globalCompositeOperation='source-over';
            if(dark){
        }
                ctx.globalCompositeOperation='lighter';
        function paint(now) {
                ctx.globalAlpha=fade;
            const t=Math.min(1,(now-started)/duration);
                ctx.shadowColor='#ff3903';
            const seconds=now/1000;
                ctx.shadowBlur=25;
            const edge=makeEdge(t,seconds,dark,w);
                ctx.strokeStyle='rgba(255,55,5,.82)';
             // Clip-path polygon reconstruction can cause expensive iframe
                ctx.lineWidth=43;
            // compositor work. It runs at a bounded rate independent of rAF.
                ctx.stroke();
            atmosphereEdge=edge;
                ctx.shadowBlur=16;
            window.__kkptVisualDark=dark;
                ctx.strokeStyle='#ff9b17';
            if(now-lastClip>=(mobile?33:23)||t>=1){
                ctx.lineWidth=20;
                 clipFrame(edge);
                ctx.stroke();
                 lastClip=now;
                ctx.shadowBlur=7;
                ctx.strokeStyle='#ffe45e';
                ctx.lineWidth=9;
                ctx.stroke();
                ctx.shadowBlur=4;
                ctx.strokeStyle='#fffcde';
                ctx.lineWidth=3.8;
                ctx.stroke();
             }else{
                ctx.globalAlpha=fade*.64;
                ctx.shadowColor='#8ccfff';
                ctx.shadowBlur=29;
                ctx.strokeStyle='rgba(139,219,255,.68)';
                ctx.lineWidth=28;
                ctx.stroke();
                ctx.shadowBlur=10;
                ctx.strokeStyle='#e7f8ff';
                ctx.lineWidth=9;
                 ctx.stroke();
                 ctx.shadowBlur=0;
                ctx.strokeStyle='white';
                ctx.lineWidth=2;
                ctx.stroke();
             }
             }
             if(now-lastPaint<(mobile?35:32) && t<1){
             ctx.restore();
                requestAnimationFrame(paint);return;
 
            }
             // Dense GOLD / WHITE rolling fire, including on both sides
            const dt=Math.min(.070,(now-previous)/1000);
             // of the border. These are the luminous shapes from V2.
            previous=now;lastPaint=now;
             const fade=Math.min(1,t/.055,(1-t)/.045);
            // Restore V14's broad smoke banks, long-lived char, pressure
             // waves and delicate blue leaves. V2 only paints the FIRE.
            drawAtmosphere(seconds,Math.min(t,.925),dark,(now-started)/1000,[]);
             if(dark){
             if(dark){
                 advance(dt);
                ctx.save();
                 if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade));
                ctx.globalCompositeOperation='lighter';
                 ctx.clearRect(0,0,w,h);
                for(let y=-70;y<h+80;y+=48){
                 drawShards(fade);
                    const x=edge(y);
                 drawCore(edge,seconds,t,fade);
                    const pulse=.65+.35*Math.sin(seconds*8.5+y*.083);
                 drawBrightParticles(fade);
                    spriteDraw(art.flame,x+rand(-8,30),y,77,70,
             }
                        fade*(.26+.16*pulse));
             if(t<1)requestAnimationFrame(paint);
                }
             else done();
                for(let y=-20;y<h+52;y+=36){
                    const x=edge(y);
                    const flicker=Math.sin(seconds*13+y*.19);
                    const length=52+30*flicker+17*Math.sin(seconds*6-y*.11);
                    const lean=15+18*Math.sin(seconds*9+y*.069);
                    ctx.globalAlpha=.62*fade;
                    ctx.fillStyle='#ff5705';
                    ctx.beginPath();
                    ctx.moveTo(x+18,y+19);
                    ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57,
                        x-lean,y-length);
                    ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19);
                    ctx.fill();
                    ctx.globalAlpha=.58*fade;
                    ctx.fillStyle='#ffdb58';
                    ctx.beginPath();
                    ctx.moveTo(x+7,y+8);
                    ctx.quadraticCurveTo(x-10,y-length*.33,
                        x-lean*.8,y-length*.72);
                    ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8);
                    ctx.fill();
                    ctx.globalAlpha=.38*fade;
                    ctx.fillStyle='#fffced';
                    ctx.beginPath();
                    ctx.moveTo(x+3,y+5);
                    ctx.quadraticCurveTo(x-5,y-length*.2,
                        x-lean*.5,y-length*.48);
                    ctx.quadraticCurveTo(x+4,y-length*.19,x+5,y+5);
                    ctx.fill();
                }
                ctx.restore();
            }else{
                ctx.save();
                ctx.globalCompositeOperation='lighter';
                for(let y=-50;y<h+55;y+=88){
                    spriteDraw(art.sky,edge(y),y,89,109,.27*fade);
                }
                ctx.restore();
            }
        }
        function drawBrightParticles(fade) {
            ctx.save();
            ctx.globalCompositeOperation='lighter';
            for(const q of p){
                if(q.type==='smoke'||q.type==='mist'||q.type==='shard')continue;
                const age=1-q.left/q.life;
                const brightness=Math.min(1,q.left/.25)*(1-age*.25)*fade;
                let artName='flame',sx=q.radius,sy=q.radius;
                if(q.type==='ember'){artName='ember';sx*=1.2;sy*=1.2;}
                else if(q.type==='pollen'){artName='sky';sx*=1.15;sy*=1.15;}
                else if(q.type==='sky'){artName='sky';sx*=1.1;sy*=1.55;}
                else {sx*=.9;sy*=1.65;}
                spriteDraw(art[artName],q.x,q.y,sx,sy,
                    brightness*(q.type==='ember'?.93:.78));
            }
            ctx.restore();
            ctx.globalAlpha=1;
            ctx.globalCompositeOperation='source-over';
        }
        function paint(now) {
            // Both original V25 curtains follow their OWN song cue clock.
            // Framerate drops cannot move 00:33 / 02:39 off the midpoint/end.
            const t=score
                ? Math.max(0,Math.min(1,(score.audio.currentTime-
                    score.config.fireStart)/
                    (score.config.fireEnd-score.config.fireStart)))
                : Math.min(1,(now-started)/duration);
            const seconds=now/1000;
            const edge=makeEdge(t,seconds,dark,w);
            // Clip-path polygon reconstruction can cause expensive iframe
            // compositor work. It runs at a bounded rate independent of rAF.
            atmosphereEdge=edge;
            window.__kkptVisualDark=dark;
            if(now-lastClip>=(mobile?33:23)||t>=1){
                clipFrame(edge);
                lastClip=now;
            }
            if(now-lastPaint<(mobile?35:32) && t<1){
                requestAnimationFrame(paint);return;
            }
            const dt=Math.min(.070,(now-previous)/1000);
            previous=now;lastPaint=now;
            const fade=Math.min(1,t/.055,(1-t)/.045);
            // Restore V14's broad smoke banks, long-lived char, pressure
            // waves and delicate blue leaves. V2 only paints the FIRE.
            drawAtmosphere(seconds,Math.min(t,.925),dark,(now-started)/1000,[]);
            if(dark&&gpuEmbers)gpuEmbers.draw(edge,seconds,dt,fade,t);
            if(dark){
                 advance(dt);
                 if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade));
                 ctx.clearRect(0,0,w,h);
                 drawShards(fade);
                 drawCore(edge,seconds,t,fade);
                 drawBrightParticles(fade);
             }
             if(t<1)requestAnimationFrame(paint);
             else done();
        }
        requestAnimationFrame(paint);
    }
 
    function setDisplayed(nextActive) {
        active=nextActive;
        frame.classList.toggle('kkpt-active',active);
        document.body.classList.toggle('kkpt-other-active',active);
        frame.style.clipPath=active?'inset(0)':hideClip;
        frame.style.visibility=active?'visible':'hidden';
        frame.style.pointerEvents='';
        frame.tabIndex=active?0:-1;
        frame.setAttribute('aria-hidden',active?'false':'true');
        document.title=active?otherTitle:baseTitle;
        showTheme((active?otherPage:basePage)==='Kuronata');
        // Stop the invisible Kuronata ambience in either document.
        root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active);
        try{
            frame.contentDocument?.documentElement.classList.toggle(
                'kkpt-frame-inactive',!active);
        }catch(_){}
        updateButton();
    }
 
    async function animateSwap(nextActive) {
        if(animating)return;
        animating=true;
        button.classList.add('kkpt-busy');
        labelSub.textContent='PREPARING THE OTHER SIDE...';
        const dark=(nextActive?otherPage:basePage)==='Kuronata';
        let score=returnScore?.config?.target===otherPage?
            returnScore:null;
        if(!loading)warmFrame();
        const loadWait=score?.config?.id==='marenoia'?41000:
            score?22000:11000;
        const ok=await Promise.race([loading,sleep(loadWait)
            .then(()=>false)]);
        if(!ok||!ready){
            // Preserve the audio cue even if the iframe preview is unavailable.
            if(score&&await score.playPromise){
                await awaitScoreTime(score,score.config.fireEnd);
            }
            removeScoreOmen();
            saveReturnScoreHandoff(score);
            navigateRealPage(nextActive?otherUrl.href:baseUrl);return;
        }
        // Finish the incremental prewarm before opening the curtain.
        await prewarmAtmosphere();
        if(score){
            const started=await Promise.race([
                score.playPromise,sleep(6500).then(()=>false)
            ]);
            if(!started||!await awaitScoreTime(score,
                score.config.fireStart)){
                clearReturnScore(score);
                score=null; // Preserve the exact V25 visuals on audio failure.
            }
        }
        removeScoreOmen();
        frame.style.visibility='visible';
        frame.classList.add('kkpt-animating');
        // Give the now-visible destination its own first composited frame.
        await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
 
        labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
        // When returning to the blue page, force the Day palette
        // BEFORE unmasking it. The dark iframe still fully covers it.
        if(!dark)showTheme(false);
        frame.classList.remove('kkpt-active');
        frame.style.pointerEvents='none';
        frame.style.clipPath=active?'inset(0)':hideClip;
 
        const atmosphere=document.createElement('canvas');
        atmosphere.id='kkpt-atmosphere';
        atmosphere.setAttribute('aria-hidden','true');
        document.body.append(atmosphere);
        const drawAtmosphere=createAtmosphere(atmosphere);
        const canvas=dark?document.createElement('canvas'):null;
        if(canvas){
            canvas.id='kkpt-vfx';
            canvas.setAttribute('aria-hidden','true');
            document.body.append(canvas);
         }
         }
         requestAnimationFrame(paint);
         const gpuEmbers=dark?createGpuEmbers():null;
    }
         window.__kkptRenderer=dark
 
            ? gpuEmbers?'WebGL2 embers + original V25 Canvas fire':
    function setDisplayed(nextActive) {
                'Original V25 Canvas fire (WebGL fallback)'
        active=nextActive;
            : 'Original V25 Canvas dawn';
        frame.classList.toggle('kkpt-active',active);
         const duration=dark?3450:3100;
        document.body.classList.toggle('kkpt-other-active',active);
         if(!score)transitionSound(dark,duration);
        frame.style.clipPath=active?'inset(0)':hideClip;
         await new Promise(resolve=>canvasFire(
         frame.style.visibility=active?'visible':'hidden';
             canvas,dark,duration,drawAtmosphere,resolve,gpuEmbers,
        frame.style.pointerEvents='';
            score));
        frame.tabIndex=active?0:-1;
        frame.setAttribute('aria-hidden',active?'false':'true');
         document.title=active?otherTitle:baseTitle;
         showTheme((active?otherPage:basePage)==='Kuronata');
         // Stop the invisible Kuronata ambience in either document.
        root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active);
        try{
             frame.contentDocument?.documentElement.classList.toggle(
                'kkpt-frame-inactive',!active);
        }catch(_){}
        updateButton();
    }


    async function animateSwap(nextActive) {
        setDisplayed(nextActive);
         if(animating)return;
         if(canvas){
        animating=true;
            canvas.style.opacity='0';
        button.classList.add('kkpt-busy');
            setTimeout(()=>canvas.remove(),210);
        labelSub.textContent='PREPARING THE OTHER SIDE...';
        if(!loading)warmFrame();
        const ok=await Promise.race([loading,sleep(11000).then(()=>false)]);
        if(!ok||!ready){
            navigateRealPage(nextActive?otherUrl.href:baseUrl);return;
        }
        const dark=(nextActive?otherPage:basePage)==='Kuronata';
        // GPU renderer is compiled while the destination is still hidden.
        // Only the actual fallback needs 17 CPU sprite textures.
        const gpu=createWebGLCurtain(dark);
        if(!gpu || dark){
            if(!gpu)window.__kkptRenderer='Canvas2D';
            await prewarmAtmosphere();
         }
         }
        let gpuAtmosphere=null;
         if(gpuEmbers){
        let gpuFirelight=null;
             gpuEmbers.canvas.style.transition='opacity 280ms ease-out';
         if(gpu&&dark){
             gpuEmbers.canvas.style.opacity='0';
             gpuAtmosphere=document.createElement('canvas');
            setTimeout(()=>gpuEmbers.dispose(),320);
            gpuAtmosphere.id='kkpt-atmosphere';
            gpuAtmosphere.setAttribute('aria-hidden','true');
            gpuFirelight=document.createElement('canvas');
            gpuFirelight.id='kkpt-firelight';
            gpuFirelight.setAttribute('aria-hidden','true');
            document.body.append(gpuAtmosphere,gpuFirelight);
            gpu.attachAtmosphere(createAtmosphere(
                gpuAtmosphere,false,gpuFirelight));
        }
        frame.style.visibility='visible';
        frame.classList.add('kkpt-animating');
        // Give the now-visible destination its own first composited frame.
        await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
 
        labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
        // When returning to the blue page, force the Day palette
        // BEFORE unmasking it. The dark iframe still fully covers it.
        if(!dark)showTheme(false);
        frame.classList.remove('kkpt-active');
        frame.style.pointerEvents='none';
        frame.style.clipPath=active?'inset(0)':hideClip;
 
        const duration=dark?3950:3150;
        transitionSound(dark,duration);
        const tail=dark?2150:1450;
        if(gpu){
            try{
                await gpu.play(duration);
                setDisplayed(nextActive);
                if(gpuAtmosphere){
                    gpuAtmosphere.style.transition='opacity '+tail+
                        'ms ease-out,transform '+tail+'ms ease-out';
                    void gpuAtmosphere.offsetWidth;
                    gpuAtmosphere.style.opacity='0';
                    gpuAtmosphere.style.transform=
                        'translate3d(0,-22px,0) scale(1.035)';
                    gpuFirelight.style.transition='opacity '+tail+
                        'ms ease-out,transform '+tail+'ms ease-out';
                    gpuFirelight.style.opacity='0';
                    gpuFirelight.style.transform=
                        'translate3d(0,-22px,0) scale(1.035)';
                }
                await gpu.dissipate(tail);
            }finally{
                gpu.dispose();
                gpuAtmosphere?.remove();
                gpuFirelight?.remove();
            }
        }else{
            // Fully working older Canvas renderer if WebGL2 is unavailable.
            const atmosphere=document.createElement('canvas');
             atmosphere.id='kkpt-atmosphere';
            atmosphere.setAttribute('aria-hidden','true');
            document.body.append(atmosphere);
            const drawAtmosphere=createAtmosphere(atmosphere);
            const canvas=dark?document.createElement('canvas'):null;
            if(canvas){
                canvas.id='kkpt-vfx';
                canvas.setAttribute('aria-hidden','true');
                document.body.append(canvas);
            }
            await new Promise(resolve=>canvasFire(
                canvas,dark,duration,drawAtmosphere,resolve));
            setDisplayed(nextActive);
            if(canvas){
                canvas.style.opacity='0';
                setTimeout(()=>canvas.remove(),210);
            }
            atmosphere.style.transition='opacity '+tail+
                'ms ease-out, transform '+tail+'ms ease-out';
            await new Promise(resolve=>requestAnimationFrame(resolve));
            atmosphere.style.opacity='0';
            atmosphere.style.transform='translate3d(0,-22px,0) scale(1.035)';
            await sleep(tail+70);
            atmosphere.remove();
         }
         }
        // The last frame holds living smoke and drifting fragments.
        // Let it disperse with a lightweight compositor fade AFTER the
        // destination is revealed; no costly extra canvas render loop.
        const tail=dark?2000:1320;
        atmosphere.style.transition='opacity '+tail+
            'ms ease-out, transform '+tail+'ms ease-out';
        await new Promise(resolve=>requestAnimationFrame(resolve));
        atmosphere.style.opacity='0';
        atmosphere.style.transform='translate3d(0,-22px,0) scale(1.035)';
        await sleep(tail+70);
        atmosphere.remove();
         animating=false;
         animating=false;
         button.classList.remove('kkpt-busy','kkpt-quiet');
         button.classList.remove('kkpt-busy','kkpt-quiet');
第3,008行: 第2,407行:
         // Hand off to the ACTUAL Wiki page: native sidebars, scrollbar,
         // Hand off to the ACTUAL Wiki page: native sidebars, scrollbar,
         // history, refresh, accessibility and links all work normally.
         // history, refresh, accessibility and links all work normally.
        saveReturnScoreHandoff(score);
         navigateRealPage(otherUrl.href);
         navigateRealPage(otherUrl.href);
     }
     }
第3,014行: 第2,414行:
         if (animating || nextActive === active) return;
         if (animating || nextActive === active) return;
         if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
         if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
                navigateRealPage(otherUrl.href);
            saveReturnScoreHandoff(returnScore);
            navigateRealPage(otherUrl.href);
             return;
             return;
         }
         }
         animateSwap(nextActive).catch(err => {
         animateSwap(nextActive).catch(err => {
             console.error('[KuranokaPageTransition] V30:',err);
             console.error('[KuranokaPageTransition] V34:',err);
            removeScoreOmen();
             document.getElementById('kkpt-vfx')?.remove();
             document.getElementById('kkpt-vfx')?.remove();
             document.getElementById('kkpt-atmosphere')?.remove();
             document.getElementById('kkpt-atmosphere')?.remove();
            document.getElementById('kkpt-firelight')?.remove();
             document.getElementById('kkpt-webgl')?.remove();
             document.getElementById('kkpt-webgl')?.remove();
             if (frame) frame.style.filter = '';
             if (frame) frame.style.filter = '';
            saveReturnScoreHandoff(returnScore);
             navigateRealPage(nextActive ? otherUrl.href : baseUrl);
             navigateRealPage(nextActive ? otherUrl.href : baseUrl);
         });
         });
第3,047行: 第2,449行:
         watchPortalLinks(document, otherPage);
         watchPortalLinks(document, otherPage);
         mountFateGear(document,otherPage);
         mountFateGear(document,otherPage);
         warmFrame();
         const continuingScore=restoreReturnScoreHandoff();
         // WebGL2 has no CPU smoke atlas: defer the 2D fallback until needed.
        if(continuingScore)setTimeout(warmFrame,3000);
        else warmFrame();
         prewarmAtmosphere();
         preloadRebirthPad();
         preloadRebirthPad();


第3,054行: 第2,458行:
             if (!isPlainClick(event)) return;
             if (!isPlainClick(event)) return;
             event.preventDefault();
             event.preventDefault();
            armReturnScore(otherPage);
             primeSound();
             primeSound();
             requestSwap(!active);
             requestSwap(!active);
第3,076行: 第2,481行:
         document.getElementById('kkpt-vfx')?.remove();
         document.getElementById('kkpt-vfx')?.remove();
         document.getElementById('kkpt-atmosphere')?.remove();
         document.getElementById('kkpt-atmosphere')?.remove();
        document.getElementById('kkpt-firelight')?.remove();
        document.getElementById('kkpt-webgl')?.remove();
         if(frame){
         if(frame){
             frame.style.filter='';
             frame.style.filter='';

2026年10月24日 (六) 13:13的最新版本

/* ============================================================
   Kuranoka / Kuronata — Page Transition V34 / V25 faithful restore
   V25 ORIGINAL fire edge, translucent fire pockets, smoke and dawn.
   Lightweight WebGL2 is restricted to subtle ember points, NEVER the
   fire curtain nor the reverse transition. Carbon sheets are 2D.
   Two-way scores: MARENOIA peaks at 00:33; AnotherMe ends at 02:39.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Real page navigation after the transition; iframe is visual-only.
   ============================================================ */
(function initKkpt(retry) {
    'use strict';
    retry=retry||0;
    if (!window.mw || !mw.config || typeof mw.config.get!=='function') {
        if(retry<40)setTimeout(()=>initKkpt(retry+1),100);
        return;
    }

    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;

    // V24: the two article worlds have deliberate, deterministic palettes.
    // "os" is NOT a light theme: it can resolve to dark on the reader's
    // computer. Session storage may also be unavailable. Therefore, never
    // restore a stale "os" token when landing back on Kuranoka.
    const root=document.documentElement;
    const themeNames=['skin-theme-clientpref-day','skin-theme-clientpref-os',
        'skin-theme-clientpref-night','skin-theme-clientpref-black',
        'skin-citizen-light'];
    let wantsNight=basePage==='Kuronata';
    let changingTheme=false;
    function syncTheme(){
        if(changingTheme)return;
        const wanted=wantsNight?'skin-theme-clientpref-night':
            'skin-theme-clientpref-day';
        const shadow=wantsNight?'kkpt-kuronata-dark':null;
        const unexpected=themeNames.filter(c=>c!==wanted)
            .concat(wantsNight?[]:['kkpt-kuronata-dark']);
        if(root.classList.contains(wanted)&&
            (!shadow||root.classList.contains(shadow))&&
            unexpected.every(c=>!root.classList.contains(c))){
            return;
        }
        changingTheme=true;
        root.classList.remove(...themeNames,'kkpt-kuronata-dark');
        root.classList.add(wanted);
        if(shadow)root.classList.add(shadow);
        changingTheme=false;
    }
    function showTheme(dark){wantsNight=dark;syncTheme();}
    syncTheme();
    // Citizen may reapply OS-preference classes after its own scripts load.
    // Keep each world's class correct without changing the user's setting.
    new MutationObserver(syncTheme).observe(root,{
        attributes:true,attributeFilter:['class']
    });
    // V31's page-style retry solves occasional initial unstyled portals.
    function ensureWorldStyles(){
        if(!document.head || document.getElementById('kkpt-css-retry'))return;
        try{
            if(getComputedStyle(root).getPropertyValue('--kkpt-css-v34').trim()==='ready')return;
        }catch(_){}
        const link=document.createElement('link');
        link.id='kkpt-css-retry';
        link.rel='stylesheet';
        const target=new URL('/index.php',location.origin);
        target.searchParams.set('title','MediaWiki:KuranokaPageTransition.css');
        target.searchParams.set('action','raw');
        target.searchParams.set('ctype','text/css');
        target.searchParams.set('kkptv','34');
        link.href=target.href;
        document.head.appendChild(link);
    }
    setTimeout(ensureWorldStyles,1200);
    function navigateRealPage(href){
        window.location.assign(href);
    }

    // V17: the dark-world extras run in *both* the visible document and
    // its same-origin preview iframe. They never alter other Wiki pages.
    function installKuronataAfterglow() {
        if(basePage!=='Kuronata'||window.__kkptDarkExtrasReady)return;
        window.__kkptDarkExtrasReady=true;
        const prefersReduced=window.matchMedia('(prefers-reduced-motion: reduce)');
        // Multiline .heimu cannot use a single nowrap pseudo-element:
        // it piles all copied text onto line one. Project each rendered
        // line into two SHORT clipped chromatic slices only on hover.
        // Range measurements leave the original article DOM and wrapping
        // entirely untouched, including italic/link children.
        const spoilerOverlays=new WeakMap();
        const renderedSpoilerLines=(node)=>{
            const lines=[];
            const walk=document.createTreeWalker(node,NodeFilter.SHOW_TEXT);
            const range=document.createRange();
            let text,budget=0;
            while((text=walk.nextNode())&&budget<340){
                for(let i=0;i<text.length&&budget<340;i++,budget++){
                    const ch=text.nodeValue[i];
                    if(ch==='\n'||ch==='\r')continue;
                    range.setStart(text,i);
                    range.setEnd(text,i+1);
                    const r=range.getBoundingClientRect();
                    if(!r.width||!r.height)continue;
                    let line=lines.find(row=>Math.abs(row.top-r.top)<3);
                    if(!line){
                        line={top:r.top,left:r.left,right:r.right,
                            height:r.height,text:''};
                        lines.push(line);
                    }
                    line.left=Math.min(line.left,r.left);
                    line.right=Math.max(line.right,r.right);
                    line.height=Math.max(line.height,r.height);
                    line.text+=ch;
                }
            }
            range.detach?.();
            return lines.sort((a,b)=>a.top-b.top);
        };
        const spoilerFault=(node)=>{
            if(prefersReduced.matches)return;
            const rects=node.getClientRects();
            if(rects.length<2)return;
            const lines=renderedSpoilerLines(node);
            if(lines.length<2)return;
            node.setAttribute('data-kkpt-multiline','1');
            spoilerOverlays.get(node)?.remove();
            const overlay=document.createElement('div');
            overlay.className='kkpt-heimu-fault';
            overlay.setAttribute('aria-hidden','true');
            const font=getComputedStyle(node);
            for(const row of lines){
                const fragment=document.createElement('span');
                fragment.className='kkpt-heimu-fault-line';
                fragment.dataset.copy=row.text;
                fragment.style.left=row.left+'px';
                fragment.style.top=row.top+'px';
                fragment.style.width=(row.right-row.left+6)+'px';
                fragment.style.height=(row.height+4)+'px';
                fragment.style.fontFamily=font.fontFamily;
                fragment.style.fontSize=font.fontSize;
                fragment.style.fontWeight=font.fontWeight;
                fragment.style.fontStyle=font.fontStyle;
                fragment.style.lineHeight=font.lineHeight;
                fragment.style.letterSpacing=font.letterSpacing;
                overlay.append(fragment);
            }
            document.body.append(overlay);
            spoilerOverlays.set(node,overlay);
        };
        const clearSpoilerFault=(node)=>{
            const overlay=spoilerOverlays.get(node);
            if(!overlay)return;
            spoilerOverlays.delete(node);
            overlay.classList.add('kkpt-fault-away');
            setTimeout(()=>overlay.remove(),210);
        };
        const decorateSpoilers=()=>{
            document.querySelectorAll('span.heimu').forEach(node=>{
                if(node.dataset.kkptBound)return;
                node.dataset.kkptBound='1';
                const label=(node.textContent||'').replace(/\s+/g,' ').trim();
                if(label&&label.length<=240&&!node.children.length)
                    node.setAttribute('data-kkpt-glitch',label);
                node.addEventListener('mouseenter',()=>spoilerFault(node));
                node.addEventListener('mouseleave',()=>clearSpoilerFault(node));
            });
        };
        const decorateLinks=()=>{
            const article=document.querySelector('#mw-content-text,.mw-parser-output');
            if(!article)return;
            article.querySelectorAll('a[href]').forEach(link=>{
                if(link.dataset.kkptLinkReady ||
                    link.classList.contains('kkpt-fate-gear') ||
                    link.querySelector('img,svg,video'))return;
                link.dataset.kkptLinkReady='1';
                link.classList.add('kkpt-link-glitch');
                try{
                    const url=new URL(link.href,document.baseURI);
                    const path=url.pathname.match(/\/wiki\/([^/?#]+)/);
                    const name=url.searchParams.get('title')||
                        (path?decodeURIComponent(path[1]):'');
                    if(url.origin===location.origin &&
                        name.replace(/_/g,' ')==='Kuranoka'){
                        link.classList.add('kkpt-link-origin');
                        // Copy a link only if it truly occupies one
                        // rendered line. Multiline links keep real text.
                        const label=(link.textContent||'').trim();
                        if(!link.children.length &&
                           link.getClientRects().length===1 &&
                           label.length>0&&label.length<100)
                            link.dataset.kkptLinkCopy=label;
                    }
                }catch(_){}
            });
        };
        // V29's useful identity faults, transplanted without its transition
        // shaders. Only text nodes are wrapped; Wiki links stay clickable.
        const decorateIdentityMentions=()=>{
            const article=document.querySelector('#mw-content-text,.mw-parser-output');
            if(!article)return;
            const hasIdentity=/Kuranoka|Kurano\s+Kanata|天野[ \u00a0\u3000]*彼方/i;
            const exactIdentity=/Kuranoka|Kurano\s+Kanata|天野[ \u00a0\u3000]*彼方/gi;
            const walker=document.createTreeWalker(article,NodeFilter.SHOW_TEXT,{
                acceptNode(node){
                    if(!node.nodeValue||!hasIdentity.test(node.nodeValue))
                        return NodeFilter.FILTER_REJECT;
                    const parent=node.parentElement;
                    if(!parent||parent.closest('script,style,textarea,code,pre,'+
                        'input,button,select,[contenteditable="true"],'+
                        '.kkpt-identity-glitch,.kkpt-heimu-fault,'+
                        'span.heimu,a.kkpt-link-origin,'+
                        '#kkpt-afterglow,#kkpt-toggle'))
                        return NodeFilter.FILTER_REJECT;
                    return NodeFilter.FILTER_ACCEPT;
                }
            });
            const matches=[];
            let node;
            while((node=walker.nextNode())&&matches.length<1800)matches.push(node);
            let index=0;
            for(const textNode of matches){
                const raw=textNode.nodeValue;
                const fragment=document.createDocumentFragment();
                let previous=0,match;
                exactIdentity.lastIndex=0;
                while((match=exactIdentity.exec(raw))){
                    if(match.index>previous)
                        fragment.append(document.createTextNode(raw.slice(previous,match.index)));
                    const el=document.createElement('span');
                    el.className='kkpt-identity-glitch';
                    el.dataset.kkptIdentity=match[0];
                    el.textContent=match[0];
                    el.style.setProperty('--kkpt-identity-delay',
                        (-((index++*7)%23)*.21).toFixed(2)+'s');
                    fragment.append(el);
                    previous=match.index+match[0].length;
                }
                if(previous<raw.length)
                    fragment.append(document.createTextNode(raw.slice(previous)));
                textNode.replaceWith(fragment);
            }
        };
        const createAmbience=()=>{
            if(prefersReduced.matches||document.getElementById('kkpt-afterglow')||!document.body)return;
            const overlay=document.createElement('div');
            overlay.id='kkpt-afterglow';
            overlay.setAttribute('aria-hidden','true');
            const el=(type,i)=>{
                const part=document.createElement('span');
                part.className=type==='soot'?'kkpt-soot-cell':'kkpt-ash-cell';
                const smoke=type==='soot';
                // All sprites are static layers with compositor-only
                // transform/opacity animation. No persistent canvas or RAF.
                // The soot is a side-bank haze, not a random full-screen
                // spread of isolated circular clouds.
                const fromLeft=i%2===0;
                part.style.left=(smoke
                    ? (fromLeft?-26+Math.random()*22:79+Math.random()*21)
                    : Math.random()*100)+'%';
                part.style.top=(smoke?8+Math.random()*94:15+Math.random()*94)+'%';
                part.style.setProperty('--size',(smoke?220+Math.random()*240:2+Math.random()*4).toFixed(1)+'px');
                part.style.setProperty('--delay',(-Math.random()*(smoke?29:18)).toFixed(2)+'s');
                part.style.setProperty('--duration',(smoke?18+Math.random()*14:8+Math.random()*11).toFixed(2)+'s');
                part.style.setProperty('--wind',((Math.random()-.5)*(smoke?100:230)).toFixed(1)+'px');
                return part;
            };
            for(let i=0;i<(innerWidth<700?8:16);i++)overlay.append(el('soot',i));
            for(let i=0;i<30;i++)overlay.append(el('ash',i));
            document.body.append(overlay);
        };
        const init=()=>{
            decorateSpoilers();
            decorateLinks();
            decorateIdentityMentions();
            createAmbience();
            mw.hook('wikipage.content').add(()=>{
                decorateSpoilers();
                decorateLinks();
                decorateIdentityMentions();
            });
        };
        if(document.readyState==='loading')
            document.addEventListener('DOMContentLoaded',init,{once:true});
        else init();

        // One brief spatial fault roughly every 14-36 seconds, never a
        // full-screen white strobe. Pause while the iframe is hidden.
        const pulse=()=>{
            const inactive=document.hidden||prefersReduced.matches||
                root.classList.contains('kkpt-frame-inactive')||
                root.classList.contains('kkpt-world-suspended')||
                (window!==window.top&&window.frameElement?.getAttribute('aria-hidden')==='true');
            if(!inactive){
                root.style.setProperty('--kkpt-rift-y',(14+Math.random()*70).toFixed(1)+'%');
                root.classList.add('kkpt-rift');
                window.setTimeout(()=>root.classList.remove('kkpt-rift'),390);
            }
            window.setTimeout(pulse,5000+Math.random()*15000);
        };
        window.setTimeout(pulse,5000+Math.random()*15000);
    }
    installKuronataAfterglow();

    // The nested preview only applies its own theme; no recursive iframe.
    if (window!==window.top || window.__kkptV12) return;
    window.__kkptV12=true;
    window.__kkptVersion='34.0';

    // Easter eggs + Infobox gear are the default. No floating pill.
    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();
    // V24: preload both original background photographs. The target
    // image is decoded while its real-document iframe is preparing,
    // reducing flashes of the bare background-color at navigation.
    const worldBackdrops={
        Kuranoka:'https://blockland.wiki/images/0/05/%E7%A7%AF%E9%9B%A8%E4%BA%911.png',
        Kuronata:'https://blockland.wiki/images/a/ab/%E9%BB%84%E6%98%8F%EF%BC%8C%E9%BB%91%E4%BA%91.png'
    };
    function primeWorldBackdrops(){
        const current=worldBackdrops[basePage];
        if(current&&document.head){
            const link=document.createElement('link');
            link.rel='preload';
            link.as='image';
            link.href=current;
            link.setAttribute('fetchpriority','high');
            link.dataset.kkptBackgroundPreload='1';
            document.head.appendChild(link);
            warmImage(current).catch(()=>{});
        }
        // Give the current photo first priority; then prepare the next
        // world so the iframe and the eventual real page can reuse it.
        setTimeout(()=>{
            const next=worldBackdrops[otherPage];
            if(next)warmImage(next).catch(()=>{});
        },300);
    }
    primeWorldBackdrops();

    const ENABLE_TRANSITION_AUDIO = true;
    const FIRE_VOLUME = .30;
    const DAWN_VOLUME = .20;
    let soundContext=null,fireBuffer=null,activeSound=null;

    // Public-domain PADsynth recording, ~8.4s / 120KB. Change this
    // to a same-origin Wiki-upload URL if external media are blocked.
    const REBIRTH_PAD_URL=
        'https://upload.wikimedia.org/wikipedia/commons/e/ed/Padsynth_example_synth_piano.ogg';
    let padBytesPromise=null,padDecodePromise=null,padBuffer=null;
    function preloadRebirthPad(){
        if(padBytesPromise)return padBytesPromise;
        padBytesPromise=fetch(REBIRTH_PAD_URL,{mode:'cors',cache:'force-cache'})
            .then(resp=>{
                if(!resp.ok)throw new Error('PAD HTTP '+resp.status);
                return resp.arrayBuffer();
            })
            .catch(err=>{
                console.debug('[KKPT] PAD file unavailable:',err);
                return null;
            });
        return padBytesPromise;
    }
    function decodeRebirthPad(ctx){
        if(padBuffer)return Promise.resolve(padBuffer);
        if(!padDecodePromise){
            padDecodePromise=preloadRebirthPad()
                .then(bytes=>bytes?ctx.decodeAudioData(bytes.slice(0)):null)
                .then(result=>(padBuffer=result))
                .catch(err=>{
                    console.debug('[KKPT] Cannot decode PAD:',err);
                    return null;
                });
        }
        return padDecodePromise;
    }
    // A plain media element can still play the recording when a site's
    // CORS policy disallows reading the file into Web Audio.
    function playRebirthPadFallback(durationMs){
        const sound=new Audio(REBIRTH_PAD_URL);
        sound.volume=0;
        sound.play().then(()=>{
            const begin=performance.now();
            const timer=setInterval(()=>{
                const t=Math.min(1,(performance.now()-begin)/durationMs);
                sound.volume=Math.max(0,Math.min(DAWN_VOLUME,
                    DAWN_VOLUME*Math.min(t/.27,(1-t)/.26)));
                if(t>=1){clearInterval(timer);sound.pause();sound.src='';}
            },80);
        }).catch(err=>console.debug('[KKPT] PAD playback unavailable:',err));
    }

    function primeSound(){
        if(!ENABLE_TRANSITION_AUDIO)return;
        try{
            const AudioEngine=window.AudioContext||window.webkitAudioContext;
            if(!AudioEngine)return;
            if(!soundContext)soundContext=new AudioEngine();
            if(soundContext.state==='suspended')soundContext.resume().catch(()=>{});
            decodeRebirthPad(soundContext);
        }catch(err){console.debug('[KKPT] Audio unavailable:',err);}
    }
    function createFireAudio(ctx){
        const length=Math.round(ctx.sampleRate*4.5);
        const buffer=ctx.createBuffer(1,length,ctx.sampleRate);
        const data=buffer.getChannelData(0);
        let low=0,middle=0;
        for(let i=0;i<length;i++){
            const random=Math.random()*2-1;
            low=low*.992+random*.008;
            middle=middle*.92+random*.08;
            data[i]=Math.max(-1,Math.min(1,
                low*3.9+middle*.33+random*.055));
        }
        // Micro-crackles: impulsive bursts with different decay times.
        for(let k=0;k<350;k++){
            const at=Math.floor(Math.random()*(length-1500));
            const decay=36+Math.random()*330;
            const duration=Math.min(1400,Math.ceil(decay*4));
            const power=.20+Math.pow(Math.random(),3)*.68;
            for(let j=0;j<duration;j++){
                const env=Math.exp(-j/decay);
                data[at+j]=Math.max(-1,Math.min(1,data[at+j]+
                    (Math.random()*2-1)*power*env));
            }
        }
        return buffer;
    }
    function transitionSound(dark,durationMs){
        const ctx=soundContext;
        if(!ctx||ctx.state!=='running')return;
        const start=ctx.currentTime+.018;
        const span=durationMs/1000;
        if(activeSound){
            const old=activeSound.gain;
            old.cancelScheduledValues(start);
            old.setTargetAtTime(.0001,start,.05);
        }
        const master=ctx.createGain();
        master.gain.setValueAtTime(.0001,start);
        master.gain.linearRampToValueAtTime(dark?FIRE_VOLUME:DAWN_VOLUME,
            start+(dark?.75:1.10));
        master.gain.setValueAtTime(dark?FIRE_VOLUME:DAWN_VOLUME,
            start+span-(dark?.88:1.0));
        master.gain.exponentialRampToValueAtTime(.0001,start+span);
        master.connect(ctx.destination);
        activeSound={gain:master};
        if(dark){
            if(!fireBuffer)fireBuffer=createFireAudio(ctx);
            const src=ctx.createBufferSource();
            src.buffer=fireBuffer;src.loop=true;
            const lowpass=ctx.createBiquadFilter();
            lowpass.type='lowpass';lowpass.frequency.value=2050;
            const bass=ctx.createBiquadFilter();
            bass.type='highpass';bass.frequency.value=55;
            src.connect(lowpass);lowpass.connect(bass);bass.connect(master);
            src.start(start);src.stop(start+span+.12);
        }else{
            // A REAL recorded PADsynth sample; no generated wind-chimes.
            decodeRebirthPad(ctx).then(buffer=>{
                if(!buffer){playRebirthPadFallback(durationMs);return;}
                const at=Math.max(start,ctx.currentTime+.020);
                if(at>start+span-.55)return;
                const player=ctx.createBufferSource();
                player.buffer=buffer;
                player.connect(master);
                player.start(at,Math.max(0,at-start)+.95);
                player.stop(start+span);
            }).catch(()=>{});
        }
        setTimeout(()=>{
            if(activeSound&&activeSound.gain===master)activeSound=null;
            try{master.disconnect();}catch(_){}
        },durationMs+350);
    }

    // Audio is a CLOCK, not just background music. V25's actual fire pixels
    // and particle layers are intentionally unchanged. Only their progress
    // timebase changes: the burning curtain crosses 50% at Marenoia 00:33;
    // the returning dawn completes at AnotherMe 02:39.
    const SCORE_KEY='kkpt:score-handoff:34';
    const SCORE_MUTE_KEY='kkpt:score-muted';
    const SCORES={
        Kuronata:{
            id:'marenoia',target:'Kuronata',
            url:'https://blkl.fun/wp-content/uploads/2026/10/Marenoia.mp3?kkpt_audio=v34',
            startAt:0,fireStart:31.275,fireEnd:34.725,
            stopAt:93,fadeSeconds:1.45
        },
        Kuranoka:{
            id:'anotherme',target:'Kuranoka',
            url:'https://blkl.fun/wp-content/uploads/2026/10/AnotherMe.mp3?kkpt_audio=20261024_043627',
            startAt:155.9,fireStart:155.9,fireEnd:159,
            stopAt:224.914286,fadeSeconds:0
        }
    };
    let returnScore=null;
    let scoreOmen=null;
    function userWantsMute(){
        try{return sessionStorage.getItem(SCORE_MUTE_KEY)==='1';}
        catch(_){return false;}
    }
    const AUDIO_ICONS={
        speaker:'<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15 9a5 5 0 0 1 0 6"/><path d="M18 6a9 9 0 0 1 0 12"/>',
        mute:'<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>',
        play:'<path d="m8 5 11 7-11 7V5Z"/>'
    };
    function updateAudioToggle(score){
        if(!score?.hud)return;
        const paused=score.blocked||score.audio.paused;
        const icon=paused?'play':score.audio.muted?'mute':'speaker';
        score.hud.innerHTML='<svg viewBox="0 0 24 24" fill="none" '+
            'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '+
            'stroke-linejoin="round" aria-hidden="true">'+AUDIO_ICONS[icon]+'</svg>';
        score.hud.setAttribute('aria-label',paused?'恢复播放':
            score.audio.muted?'取消静音':'静音');
        score.hud.setAttribute('aria-pressed',String(score.audio.muted));
        score.hud.classList.toggle('kkpt-audio-needs-resume',paused);
    }
    function mountAudioToggle(score){
        const old=document.getElementById('kkpt-audio-toggle');
        old?.remove();
        const hud=document.createElement('button');
        hud.type='button';hud.id='kkpt-audio-toggle';
        hud.setAttribute('aria-label','静音');
        score.hud=hud;
        document.body.append(hud);
        const audio=score.audio;
        hud.addEventListener('click',()=>{
            if(audio.paused||score.blocked){
                score.blocked=false;
                const resume=typeof score.resume==='function'?
                    score.resume():audio.play();
                Promise.resolve(resume).then(()=>{
                    updateAudioToggle(score);
                }).catch(err=>{
                    console.info('[KKPT] Audio resume blocked:',err);
                    score.blocked=true;updateAudioToggle(score);
                });
                updateAudioToggle(score);
                return;
            }
            audio.muted=!audio.muted;
            try{sessionStorage.setItem(SCORE_MUTE_KEY,audio.muted?'1':'0');}
            catch(_){}
            updateAudioToggle(score);
        });
        audio.addEventListener('play',()=>updateAudioToggle(score));
        audio.addEventListener('pause',()=>updateAudioToggle(score));
        updateAudioToggle(score);
    }
    function removeScoreOmen(){
        scoreOmen?.remove();scoreOmen=null;
        document.body.classList.remove('kkpt-omen-running');
    }
    function startScoreOmen(score){
        if(score?.config?.id!=='marenoia'||scoreOmen)return;
        const omen=document.createElement('div');
        omen.id='kkpt-omen';omen.setAttribute('aria-hidden','true');
        omen.innerHTML='<i class="kkpt-omen-glow"></i>'+
            '<i class="kkpt-omen-line"></i>';
        document.body.append(omen);
        scoreOmen=omen;
        document.body.classList.add('kkpt-omen-running');
    }
    function clearReturnScore(score){
        if(!score)return;
        clearInterval(score.displayTimer);
        score.hud?.remove();
        try{
            score.audio.pause();
            score.audio.removeAttribute('src');score.audio.load();
        }catch(_){}
        if(returnScore===score)returnScore=null;
        if(score.config?.id==='marenoia')removeScoreOmen();
    }
    function watchScore(score){
        const audio=score.audio,config=score.config;
        score.displayTimer=setInterval(()=>{
            if(audio.ended||audio.error||audio.currentTime>=config.stopAt-.018){
                clearReturnScore(score);return;
            }
            const remaining=config.stopAt-audio.currentTime;
            if(config.fadeSeconds&&remaining<config.fadeSeconds){
                audio.volume=.78*Math.max(0,remaining/config.fadeSeconds);
            }
        },95);
        audio.addEventListener('ended',()=>clearReturnScore(score),{once:true});
        audio.addEventListener('error',()=>{
            score.failed=true;clearReturnScore(score);
        },{once:true});
    }
    function createScore(config){
        const audio=new Audio(config.url);
        audio.preload='auto';audio.playsInline=true;
        audio.volume=.78;audio.muted=userWantsMute();
        const score={audio,config,failed:false,blocked:false,
            displayTimer:null,hud:null,playPromise:null};
        returnScore=score;
        mountAudioToggle(score);
        watchScore(score);
        return score;
    }
    function armReturnScore(targetPage){
        const config=SCORES[targetPage];
        if(!config||targetPage===basePage)return null;
        if(returnScore&&!returnScore.failed&&
            returnScore.config.id===config.id)return returnScore;
        if(returnScore)clearReturnScore(returnScore);
        const score=createScore(config);
        // Set the offset in the original, trusted CLICK. No 2:35 pre-roll.
        try{score.audio.currentTime=config.startAt;}catch(_){}
        score.audio.addEventListener('loadedmetadata',()=>{
            if(score.audio.currentTime<config.startAt-.2){
                try{score.audio.currentTime=config.startAt;}catch(_){}
            }
        },{once:true});
        try{
            score.playPromise=Promise.resolve(score.audio.play()).then(()=>{
                startScoreOmen(score);return true;
            },err=>{
                console.warn('[KKPT] Soundtrack playback blocked:',err);
                score.failed=true;clearReturnScore(score);return false;
            });
        }catch(err){
            console.warn('[KKPT] Soundtrack playback failed:',err);
            score.failed=true;clearReturnScore(score);
            score.playPromise=Promise.resolve(false);
        }
        return score;
    }
    function awaitScoreTime(score,seconds){
        if(!score||score.failed)return Promise.resolve(false);
        return new Promise(resolve=>{
            const audio=score.audio;
            let timer=null,lastTime=audio.currentTime;
            let lastMovement=performance.now();
            let settled=false;
            const done=ok=>{
                if(settled)return;
                settled=true;clearInterval(timer);
                audio.removeEventListener('timeupdate',check);
                audio.removeEventListener('ended',check);
                audio.removeEventListener('error',check);
                resolve(ok);
            };
            const check=()=>{
                if(audio.currentTime>=seconds-.018){done(true);return;}
                if(score.failed||audio.error||audio.ended){done(false);return;}
                if(audio.currentTime>lastTime+.015){
                    lastTime=audio.currentTime;lastMovement=performance.now();
                }
                // A stalled soundtrack cannot trap navigation indefinitely.
                if(performance.now()-lastMovement>20000)done(false);
            };
            audio.addEventListener('timeupdate',check);
            audio.addEventListener('ended',check);
            audio.addEventListener('error',check);
            timer=setInterval(check,75);
            check();
        });
    }
    function saveReturnScoreHandoff(score){
        if(!score||score.failed||score.audio.ended||
            score.audio.currentTime>=score.config.stopAt-.15)return;
        try{
            sessionStorage.setItem(SCORE_KEY,JSON.stringify({
                stamp:Date.now(),time:score.audio.currentTime,
                target:score.config.target,
                expires:Date.now()+115000,revision:34
            }));
        }catch(err){
            console.warn('[KKPT] Cannot preserve soundtrack position:',err);
        }
    }
    function restoreReturnScoreHandoff(){
        if(window!==window.top)return false;
        let saved=null;
        try{
            saved=JSON.parse(sessionStorage.getItem(SCORE_KEY));
            sessionStorage.removeItem(SCORE_KEY);
        }catch(_){}
        if(!saved||saved.revision!==34||saved.target!==basePage||
            Date.now()>saved.expires)return false;
        const config=SCORES[saved.target];
        if(!config)return false;
        const currentTarget=()=>saved.time+
            (Date.now()-saved.stamp)/1000;
        if(currentTarget()>=config.stopAt-.15)return false;
        const score=createScore(config);
        const audio=score.audio;
        let attempted=false,blockedAt=null;
        function playFromPosition(){
            const requested=blockedAt===null?currentTarget():blockedAt;
            const position=Math.min(config.stopAt-.12,requested);
            if(position>=config.stopAt-.15){clearReturnScore(score);return;}
            try{audio.currentTime=Math.max(config.startAt,position);}catch(_){}
            Promise.resolve(audio.play()).then(()=>{
                blockedAt=null;
                score.blocked=false;updateAudioToggle(score);
            }).catch(err=>{
                // The only fallback is the SAME small icon, no audio popup.
                console.info('[KKPT] Browser requires an audio click:',err);
                blockedAt=position;score.blocked=true;
                updateAudioToggle(score);
            });
        }
        score.resume=()=>{
            if(blockedAt!==null){
                try{audio.currentTime=blockedAt;}catch(_){}
            }
            return Promise.resolve(audio.play()).then(value=>{
                blockedAt=null;return value;
            });
        };
        const autoplay=()=>{
            if(attempted)return;attempted=true;
            playFromPosition();
        };
        audio.addEventListener('loadedmetadata',autoplay,{once:true});
        audio.load();
        setTimeout(()=>{if(audio.readyState>=1)autoplay();},1100);
        return true;
    }

    let active = false; // true only during the visual handoff; destination navigates natively
    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;
        let pending=false;
        const observer = new MutationObserver(() => {
            if(pending)return;
            pending=true;
            setTimeout(()=>{
                pending=false;
                decoratePortalLinks(doc,targetPage);
            },120);
        });
        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],button.kkpt-fate-gear') : null;
    }

    function portalClick(event, doc, targetPage, nextActive) {
        if (!isPlainClick(event)) return false;
        const anchor = findAnchor(event, doc);
        const gear=anchor?.classList.contains('kkpt-fate-gear')&&
            anchor.dataset.kkptTargetPage===targetPage;
        if (!anchor || (anchor.target && anchor.target !== '_self') ||
            anchor.hasAttribute('download') ||
            (!gear&&wikiLinkPage(anchor, doc)!==targetPage)) return false;
        event.preventDefault();
        // Both directions start playback during the trusted user gesture.
        armReturnScore(targetPage);
        primeSound(); // Synchronous click activation unlocks Web Audio.
        if(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-gear-turning');
        setTimeout(()=>{
            gearPending=false;
            requestSwap(nextActive);
        },570);
        setTimeout(()=>anchor.classList.remove('kkpt-fate-gear-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;
        // The fate wheel stays inside the native Infobox caption, but is
        // positioned independently: it must NEVER push the title off-center.
        const box=content.querySelector('table.infobox.vcard,table.infobox');
        const host=box?.caption ||
            box?.querySelector('th.infobox-above,th.infobox-title,th[colspan]')||
            doc.querySelector('#firstHeading .mw-page-title-main,'+
                '.mw-page-title-main,#firstHeading');
        if(!host)return;
        host.classList.add('kkpt-fate-host');
        const anchor=doc.createElement('button');
        anchor.type='button';
        anchor.className='kkpt-fate-gear';
        // A native button cannot be mistaken for an article link by
        // MediaWiki Popups, including in previews loaded into iframes.
        anchor.dataset.kkptTargetPage=targetPage;
        const gearLabel=targetPage==='Kuronata'
            ?'命运的齿轮 · 唤醒 Kuronata':'命运的齿轮 · 回到 Kuranoka';
        anchor.setAttribute('aria-label',gearLabel);
        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>';
        host.appendChild(anchor);

        // Fixed corner placement: no title measurements, ResizeObserver
        // or reflow during the animation.
    }

    // 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 < 18) 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;
            // The preview exists only for the animated reveal. Pausing
            // its hidden ambience avoids doubling ongoing render work.
            doc.documentElement.classList.add('kkpt-frame-inactive');
            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 temporary preview.
                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();
                navigateRealPage(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;
        // Load all resources in advance, but don't composite the hidden
        // full-screen Wiki until its first transition frame.
        frame.style.visibility = 'hidden';

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


    // Shared, low-resolution atmospherics: return of V14's dark smoke,
    // long-lived torn paper, shockwaves, blue-white petals and pollen.
    // Only the combustion CURTAIN is replaced by the bright V2 engine.
    let atmosphereAssetCache=null;
    const atmosphereNoiseCache=new Map();
    function createAtmosphere(canvas,buildOneOnly=false) {
        const ctx = canvas.getContext('2d', {alpha:true});
        const rand = n => {
            const cached=atmosphereNoiseCache.get(n);
            if(cached!==undefined)return cached;
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
            const value=x-Math.floor(x);
            atmosphereNoiseCache.set(n,value);
            return value;
        };
        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;
        }
        // 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,'#fffef1'],[.14,'#fff4bb'],[.34,'#ffd04d'],
                   [.55,'#ff841bcc'],[.80,'#bc290749'],[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;
        }
        // V25: the profiler found one 270ms long task here. Build the
        // EXACT SAME 17 textures in separate tasks, never all at once.
        // The stable noise cache avoids repeating the costly sin hash.
        const cache=atmosphereAssetCache ||= {
            sootSprites:[],mistSprites:[],FIRE_GLOW:[],BLUE_GLOW:[],
            EMBER_GLOW:null
        };
        function buildNextAsset(){
            let n=cache.sootSprites.length;
            if(n<4){cache.sootSprites.push(smokeTexture(n+17,true));return true;}
            n=cache.mistSprites.length;
            if(n<4){cache.mistSprites.push(smokeTexture(n+51,false));return true;}
            n=cache.FIRE_GLOW.length;
            if(n<4){cache.FIRE_GLOW.push(glowTexture(n+1,'fire'));return true;}
            n=cache.BLUE_GLOW.length;
            if(n<4){cache.BLUE_GLOW.push(glowTexture(n+8,'blue'));return true;}
            if(!cache.EMBER_GLOW){
                cache.EMBER_GLOW=glowTexture(21,'ember');return true;
            }
            return false;
        }
        if(buildOneOnly)return buildNextAsset();
        // Normally prewarming is already complete before a user clicks.
        // The fallback also works if an animation is started immediately.
        while(buildNextAsset()){}
        const {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW}=cache;
        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){
                // More long-lived sparks than V25, far below V31's 370/s.
                const flow=(dark?132:96)*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<.38?'flame':r<.91?'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?.9+Math.random()*1.55:.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?(small?65+Math.random()*245:
                            14+Math.random()*154):
                            16+Math.random()*69),
                        vy:-(dark?(small?12+Math.random()*190:
                            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?32:46);
                    const sootBirths=Math.min(11,Math.floor(sootRemainder));
                    sootRemainder-=sootBirths;
                    for(let i=0;i<sootBirths&&residualSoot.length<(w<700?92:155);i++){
                        const y=Math.random()*h;
                        const life=2.15+Math.random()*1.60;
                        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:155+Math.random()*195,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?18:30);
                    const shardBirths=Math.min(7,Math.floor(shardRemainder));
                    shardRemainder-=shardBirths;
                    for(let i=0;i<shardBirths&&tornPaper.length<175;i++){
                        const y=Math.random()*h;
                        const life=.90+Math.random()*1.85;
                        const points=8+Math.floor(Math.random()*7);
                        const outline=Array.from({length:points},(_,j)=>({
                            a:Math.PI*2*j/points+(Math.random()-.5)*.50,
                            r:.25+Math.random()*1.06
                        }));
                        tornPaper.push({
                            x:edgeAt(y,seconds,progress,w)+(Math.random()-.20)*58,
                            y, vx:55+Math.random()*295,
                            vy:-190+Math.random()*270,
                            gravity:90+Math.random()*150,
                            radius:2+Math.pow(Math.random(),2.5)*22,
                            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?40:62},(_,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?56:115},(_,i)=>({
            y:rand(i*3+17),phase:rand(i*9+43),
            speed:100+rand(i*12+5)*340,
            drift:82+rand(i*15+2)*330,
            length:3+rand(i*17+7)*16
        }));
        const shockDebris=Array.from({length:innerWidth<700?32:66},(_,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,cells) {
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.43:.55;
            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?174:145)*item.scale*(.68+age*1.9);
                const opacity=(dark?.43:.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)*(.66+.24*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/17);
                for(let j=0;j<count;j++){
                    const seed=rand(j*13+167);
                    const y=(j+seed*.78)*11.5-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(.58+ripple*.34+
                        .16*Math.sin(j*1.17-seconds*7.1));
                    if(heat<.34)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=(3.2+rand(j*31+11)*10.3)*(.5+heat);
                    const gradient=ctx.createLinearGradient(x,y,tipX,tipY);
                    gradient.addColorStop(0,'rgba(255,255,242,.99)');
                    gradient.addColorStop(.28,'rgba(255,224,84,.96)');
                    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=Math.min(1,heat*1.08)*fade;
                    ctx.fillStyle=gradient;
                    ctx.shadowBlur=0;
                    ctx.fill();
                    if(heat>.66){
                        ctx.globalAlpha=(heat-.54)*1.40*fade;
                        ctx.strokeStyle='#fffce0';ctx.lineWidth=.9+heat*1.1;
                        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+=3.6,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,.81)';
                ctx.lineWidth=18;ctx.shadowBlur=0;
                ctx.stroke();
                ctx.strokeStyle='rgba(210,45,9,.53)';
                ctx.lineWidth=6;
                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.8){
                    const intensity=clamp01(.58+
                        .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<.47)continue;
                    const x=edgeAt(y,seconds,progress,w);
                    const nextX=edgeAt(y+5.8,seconds,progress,w);
                    ctx.globalAlpha=intensity*fade;
                    ctx.lineWidth=1.4+intensity*4.6;
                    ctx.shadowBlur=0;
                    ctx.strokeStyle=intensity>.75?'#fff8d7':
                        intensity>.58?'#ffd369':'#ff831b';
                    ctx.beginPath();ctx.moveTo(x,y);
                    ctx.lineTo(nextX,y+5.8);ctx.stroke();
                }
                ctx.restore();
                // Fragmented ignition on real microscopic perforations.
                // Missing arc sections stop them resembling punched circles.
                ctx.save();
                ctx.globalCompositeOperation='screen';
                ctx.shadowBlur=0;
                for (let i=0; i<cells.length; i++) {
                    const c=cells[i];
                    ctx.globalAlpha=(.42+.34*c.heat)*fade;
                    ctx.strokeStyle=c.heat>.73?'#fff6bf':
                        c.heat>.38?'#ffd166':'#ff621c';
                    ctx.lineWidth=.8+Math.min(2.6,c.radius*.24);
                    const n=c.points.length;
                    ctx.beginPath();
                    for (let j=0;j<n;j++) {
                        if ((j+i*3)%5>2)continue;
                        const a=c.points[j],b=c.points[(j+1)%n];
                        ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
                    }
                    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'?.74:p.kind==='glow'?.13:.66)*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,107,35,.48)':
                        '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();}
        };
    }

    let atmosphereWarmPromise=null;
    function prewarmAtmosphere() {
        if(atmosphereWarmPromise)return atmosphereWarmPromise;
        atmosphereWarmPromise=new Promise(resolve=>{
            const scratchCanvas=document.createElement('canvas');
            function step(){
                if(atmosphereAssetCache?.EMBER_GLOW){resolve();return;}
                // Exactly one texture per task: no more 270ms blocking call.
                createAtmosphere(scratchCanvas,true);
                if(atmosphereAssetCache?.EMBER_GLOW){resolve();return;}
                setTimeout(step,24);
            }
            setTimeout(step,40);
        });
        return atmosphereWarmPromise;
    }

    let atmosphereEdge=null;
    function edgeAt(y,seconds,progress,width){
        return atmosphereEdge
            ? atmosphereEdge(y)
            : makeEdge(progress,seconds,Boolean(window.__kkptVisualDark),width)(y);
    }

    // Original V2's moving flame sprites and three bright contours, rebuilt
    // with bounded particle counts. No WebGL, no full-screen noise pass,
    // no 1,000-point polygon or per-frame image-texture upload.
    let sprites=null;
    function makeSprites() {
        if (sprites) return sprites;
        function radial(stops) {
            const el=document.createElement('canvas');
            el.width=el.height=96;
            const c=el.getContext('2d');
            const g=c.createRadialGradient(48,48,0,48,48,48);
            stops.forEach(pair=>g.addColorStop(pair[0],pair[1]));
            c.fillStyle=g;c.fillRect(0,0,96,96);
            return el;
        }
        sprites={
            flame:radial([[0,'#fffbed'],[.18,'#fff2a0'],
                [.43,'#ffb42b'],[.70,'#ff360088'],[1,'#8f050000']]),
            ember:radial([[0,'#ffffff'],[.15,'#fff6a8'],
                [.44,'#ff9900cc'],[1,'#ef280000']]),
            smoke:radial([[0,'#080608dd'],[.30,'#100b10bb'],
                [.62,'#160f1677'],[1,'#08050800']]),
            sky:radial([[0,'#ffffff'],[.16,'#e6ffff'],
                [.46,'#94deffe0'],[1,'#4ab5ff00']]),
            mist:radial([[0,'#f2fdffb0'],[.4,'#abdffff0'],
                [1,'#a9e7ff00']])
        };
        return sprites;
    }

    // V2's simple advancing boundary. The reader asked for the
    // vibrant fire curtain, not a mathematically fractal tearing wire.
    function makeEdge(t,seconds,dark,w) {
        const center=dark
            ? w+105-(w+210)*smooth(t)
            : -105+(w+210)*smooth(t);
        const wobble=Math.sin(Math.PI*t);
        return y=>center+wobble*(
            17*Math.sin(y*.023+seconds*5.5)+
             9*Math.sin(y*.063-seconds*8.4)+
             4*Math.sin(y*.13+seconds*12)
        );
    }

    // GPU-only ember flecks: ONE small draw call at most 30fps.
    // The white-hot V25 fire, the smoke and the dawn stay unmodified 2D.
    // No fullscreen pixel shader, FBM, flame quads or cyan rectangle.
    function createGpuEmbers(){
        const canvas=document.createElement('canvas');
        canvas.id='kkpt-webgl';
        canvas.setAttribute('aria-hidden','true');
        let gl,program,buffer;
        try{
            gl=canvas.getContext('webgl2',{
                alpha:true,antialias:false,depth:false,stencil:false,
                premultipliedAlpha:false,preserveDrawingBuffer:false,
                powerPreference:'low-power'
            });
            if(!gl)throw new Error('WebGL2 context unavailable');
            const vert=`#version 300 es
                precision mediump float;
                layout(location=0) in vec4 aSpark;
                uniform vec2 uSize;
                uniform float uScale;
                out float vOpacity;
                void main(){
                    vec2 xy=aSpark.xy/uSize*2.0-1.0;
                    gl_Position=vec4(xy.x,-xy.y,0.0,1.0);
                    gl_PointSize=aSpark.z*uScale;
                    vOpacity=aSpark.w;
                }`;
            const frag=`#version 300 es
                precision mediump float;
                in float vOpacity;
                out vec4 fragColor;
                void main(){
                    vec2 p=gl_PointCoord*2.0-1.0;
                    float r=length(p);
                    if(r>=1.0)discard;
                    float a=pow(1.0-r,1.65)*vOpacity;
                    vec3 c=mix(vec3(1.0,.30,.035),
                        vec3(1.0,.93,.57),smoothstep(.40,.92,1.0-r));
                    fragColor=vec4(c,a);
                }`;
            function shader(type,source){
                const s=gl.createShader(type);
                gl.shaderSource(s,source);gl.compileShader(s);
                if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))
                    throw new Error(gl.getShaderInfoLog(s));
                return s;
            }
            const vs=shader(gl.VERTEX_SHADER,vert);
            const fs=shader(gl.FRAGMENT_SHADER,frag);
            program=gl.createProgram();
            gl.attachShader(program,vs);gl.attachShader(program,fs);
            gl.linkProgram(program);
            gl.deleteShader(vs);gl.deleteShader(fs);
            if(!gl.getProgramParameter(program,gl.LINK_STATUS))
                throw new Error(gl.getProgramInfoLog(program));
            buffer=gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
            gl.bufferData(gl.ARRAY_BUFFER,170*4*4,gl.DYNAMIC_DRAW);
            gl.enableVertexAttribArray(0);
            gl.vertexAttribPointer(0,4,gl.FLOAT,false,0,0);
            gl.useProgram(program);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
            gl.disable(gl.DEPTH_TEST);
        }catch(err){
            console.warn('[KKPT] Ember-only WebGL disabled:',err);
            canvas.remove();
            return null;
        }
        document.body.append(canvas);
        const sizeUniform=gl.getUniformLocation(program,'uSize');
        const scaleUniform=gl.getUniformLocation(program,'uScale');
        const max=innerWidth<700?80:170;
        const data=new Float32Array(max*4);
        const sparks=[];
        let carry=0,disabled=false,lastW=0,lastH=0;
        canvas.addEventListener('webglcontextlost',event=>{
            event.preventDefault();disabled=true;
            window.__kkptRenderer='Canvas2D (GPU context lost)';
        },{once:true});
        const rand=(min,max)=>min+Math.random()*(max-min);
        function draw(edge,seconds,dt,fade,progress){
            if(disabled)return;
            const w=innerWidth,h=innerHeight;
            const scale=w<700?.67:.75;
            const cw=Math.round(w*scale),ch=Math.round(h*scale);
            if(cw!==lastW||ch!==lastH){
                canvas.width=cw;canvas.height=ch;
                gl.viewport(0,0,cw,ch);lastW=cw;lastH=ch;
            }
            gl.clearColor(0,0,0,0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.useProgram(program);
            gl.uniform2f(sizeUniform,w,h);
            gl.uniform1f(scaleUniform,scale);
            carry+=dt*(w<700?27:56)*Math.max(0,fade);
            const births=Math.min(5,Math.floor(carry));carry-=births;
            for(let i=0;i<births&&sparks.length<max;i++){
                const y=rand(0,h),life=rand(.55,1.65);
                sparks.push({x:edge(y)+rand(-7,23),y,
                    vx:rand(48,265),vy:rand(-195,-26),life,left:life,
                    size:rand(3,13),alpha:rand(.24,.60)});
            }
            let count=0;
            for(let i=sparks.length-1;i>=0;i--){
                const p=sparks[i];
                p.left-=dt;
                if(p.left<=0){sparks[i]=sparks[sparks.length-1];sparks.pop();continue;}
                p.x+=p.vx*dt;p.y+=p.vy*dt;
                if(p.x < -30||p.x>w+30||p.y < -30||p.y>h+30)continue;
                const offset=count++*4;
                data[offset]=p.x;data[offset+1]=p.y;
                data[offset+2]=p.size;
                data[offset+3]=p.alpha*fade*Math.min(1,p.left/.24);
            }
            if(!count)return;
            gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
            gl.bufferSubData(gl.ARRAY_BUFFER,0,data.subarray(0,count*4));
            gl.drawArrays(gl.POINTS,0,count);
        }
        function dispose(){
            disabled=true;
            if(!gl.isContextLost()){
                gl.deleteBuffer(buffer);gl.deleteProgram(program);
            }
            canvas.remove();
        }
        return {canvas,draw,dispose};
    }

    // Clip the ACTUAL preloaded Wiki frame, precisely where the fire is.
    function clipFrame(edge) {
        const w=innerWidth,h=innerHeight;
        const rows=Math.max(24,Math.ceil(h/30));
        const pts=[];
        for(let i=rows;i>=0;i--) {
            const y=h*i/rows;
            pts.push(edge(y).toFixed(1)+'px '+y.toFixed(1)+'px');
        }
        frame.style.clipPath=clipSide==='right'
            ? 'polygon(100% 0,100% 100%,'+pts.join(',')+')'
            : 'polygon(0 0,0 100%,'+pts.join(',')+')';
    }

    function canvasFire(canvas,dark,duration,drawAtmosphere,done,gpuEmbers,score) {
        const ctx=dark&&canvas
            ? canvas.getContext('2d',{alpha:true,desynchronized:true})
            : null;
        if(dark&&!ctx){done();return;}
        const art=dark?makeSprites():null;
        const w=innerWidth,h=innerHeight;
        if(ctx){
            const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80);
            canvas.width=Math.max(1,Math.round(w*scale));
            canvas.height=Math.max(1,Math.round(h*scale));
            ctx.setTransform(scale,0,0,scale,0,0);
        }

        const rand=(a,b)=>a+Math.random()*(b-a);
        const mobile=w<650;
        const maxCount=mobile?160:250;
        const p=[];
        const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0};
        const rates=dark
            ? {flame:mobile?80:105,ember:mobile?72:96,
               shard:mobile?26:42}
            : {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50};
        const started=performance.now();
        let previous=started;
        let lastPaint=0;
        let lastClip=0;
        function spawn(kind,edge,seconds) {
            if(p.length>=maxCount)return;
            const y=rand(-25,h+25);
            const x=edge(y);
            const type=kind;
            const smoke=type==='smoke';
            const shard=type==='shard';
            const ember=type==='ember'||type==='pollen';
            const blue=type==='sky'||type==='mist'||type==='pollen';
            let radius,life,vx,vy;
            if(shard){
                radius=rand(4,18);life=rand(.6,1.65);
                vx=rand(-130,65);vy=rand(-125,30);
            }else if(smoke){
                radius=rand(32,85);life=rand(1.1,2.5);
                vx=rand(-20,65);vy=rand(-88,-22);
            }else if(type==='mist'){
                radius=rand(45,100);life=rand(.9,1.8);
                vx=rand(-35,45);vy=rand(-75,-15);
            }else if(type==='flame'){
                radius=rand(20,57);life=rand(.3,.82);
                vx=rand(-100,30);vy=rand(-180,-45);
            }else{
                radius=ember?rand(2.5,9):rand(18,47);
                life=ember?rand(.45,1.1):rand(.35,.85);
                vx=rand(-95,75);vy=rand(-150,-25);
            }
            p.push({
                type,life,left:life,
                x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64),
                y:y+rand(-14,14),vx,vy,radius,
                angle:rand(-Math.PI,Math.PI),spin:rand(-5,5),
                drift:rand(-.65,.65),blue
            });
        }
        function spawnAtRate(dt,edge,seconds,strength){
            for(const type in rates){
                accum[type]+=rates[type]*dt*strength;
                const n=Math.min(11,Math.floor(accum[type]));
                accum[type]-=n;
                for(let i=0;i<n;i++)spawn(type,edge,seconds);
            }
        }
        function advance(dt){
            for(let i=p.length-1;i>=0;i--) {
                const q=p[i];
                q.left-=dt;
                if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;}
                q.x+=q.vx*dt;
                q.y+=q.vy*dt;
                q.vx+=q.drift*dt*35;
                q.vy-=10*dt;
                q.angle+=q.spin*dt;
            }
        }
        function spriteDraw(image,x,y,sx,sy,alpha){
            ctx.globalAlpha=alpha;
            ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2);
        }
        function drawSmoke(fade){
            ctx.globalCompositeOperation='source-over';
            for(const q of p){
                if(q.type!=='smoke'&&q.type!=='mist')continue;
                const age=1-q.left/q.life;
                const radius=q.radius*(1+age*.98);
                const alpha=(q.type==='smoke'?.76:.32)*
                    Math.min(1,age*5)*Math.min(1,q.left/.55)*fade;
                spriteDraw(q.type==='smoke'?art.smoke:art.mist,
                    q.x,q.y,radius*1.7,radius*1.3,alpha);
            }
            ctx.globalAlpha=1;
        }
        function drawShards(fade) {
            if(!dark)return;
            ctx.save();
            ctx.globalCompositeOperation='source-over';
            for(const q of p){
                if(q.type!=='shard')continue;
                const age=1-q.left/q.life;
                const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade;
                ctx.save();
                ctx.translate(q.x,q.y);
                ctx.rotate(q.angle);
                ctx.globalAlpha=a;
                const r=q.radius;
                ctx.beginPath();
                ctx.moveTo(-r*.9,-r*.3);
                ctx.lineTo(r*.7,-r*.75);
                ctx.lineTo(r*.48,r*.42);
                ctx.lineTo(-r*.35,r*.8);
                ctx.closePath();
                ctx.fillStyle=age<.3?'#371d22':'#140d13';
                ctx.fill();
                ctx.strokeStyle=age<.42?'#f57d3c':'#5a2c2b';
                ctx.lineWidth=age<.42?1.6:.75;
                ctx.stroke();
                ctx.restore();
            }
            ctx.restore();
        }
        function drawCore(edge,seconds,t,fade){
            const waviness=Math.sin(Math.PI*t);
            ctx.save();
            ctx.beginPath();
            const count=Math.max(35,Math.ceil(h/19));
            for(let i=0;i<=count;i++){
                const y=h*i/count;
                if(i===0)ctx.moveTo(edge(y),y);
                else ctx.lineTo(edge(y),y);
            }
            ctx.lineCap='round';
            ctx.lineJoin='round';
            if(dark){
                ctx.globalCompositeOperation='lighter';
                ctx.globalAlpha=fade;
                ctx.shadowColor='#ff3903';
                ctx.shadowBlur=25;
                ctx.strokeStyle='rgba(255,55,5,.82)';
                ctx.lineWidth=43;
                ctx.stroke();
                ctx.shadowBlur=16;
                ctx.strokeStyle='#ff9b17';
                ctx.lineWidth=20;
                ctx.stroke();
                ctx.shadowBlur=7;
                ctx.strokeStyle='#ffe45e';
                ctx.lineWidth=9;
                ctx.stroke();
                ctx.shadowBlur=4;
                ctx.strokeStyle='#fffcde';
                ctx.lineWidth=3.8;
                ctx.stroke();
            }else{
                ctx.globalAlpha=fade*.64;
                ctx.shadowColor='#8ccfff';
                ctx.shadowBlur=29;
                ctx.strokeStyle='rgba(139,219,255,.68)';
                ctx.lineWidth=28;
                ctx.stroke();
                ctx.shadowBlur=10;
                ctx.strokeStyle='#e7f8ff';
                ctx.lineWidth=9;
                ctx.stroke();
                ctx.shadowBlur=0;
                ctx.strokeStyle='white';
                ctx.lineWidth=2;
                ctx.stroke();
            }
            ctx.restore();

            // Dense GOLD / WHITE rolling fire, including on both sides
            // of the border. These are the luminous shapes from V2.
            if(dark){
                ctx.save();
                ctx.globalCompositeOperation='lighter';
                for(let y=-70;y<h+80;y+=48){
                    const x=edge(y);
                    const pulse=.65+.35*Math.sin(seconds*8.5+y*.083);
                    spriteDraw(art.flame,x+rand(-8,30),y,77,70,
                        fade*(.26+.16*pulse));
                }
                for(let y=-20;y<h+52;y+=36){
                    const x=edge(y);
                    const flicker=Math.sin(seconds*13+y*.19);
                    const length=52+30*flicker+17*Math.sin(seconds*6-y*.11);
                    const lean=15+18*Math.sin(seconds*9+y*.069);
                    ctx.globalAlpha=.62*fade;
                    ctx.fillStyle='#ff5705';
                    ctx.beginPath();
                    ctx.moveTo(x+18,y+19);
                    ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57,
                        x-lean,y-length);
                    ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19);
                    ctx.fill();
                    ctx.globalAlpha=.58*fade;
                    ctx.fillStyle='#ffdb58';
                    ctx.beginPath();
                    ctx.moveTo(x+7,y+8);
                    ctx.quadraticCurveTo(x-10,y-length*.33,
                        x-lean*.8,y-length*.72);
                    ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8);
                    ctx.fill();
                    ctx.globalAlpha=.38*fade;
                    ctx.fillStyle='#fffced';
                    ctx.beginPath();
                    ctx.moveTo(x+3,y+5);
                    ctx.quadraticCurveTo(x-5,y-length*.2,
                        x-lean*.5,y-length*.48);
                    ctx.quadraticCurveTo(x+4,y-length*.19,x+5,y+5);
                    ctx.fill();
                }
                ctx.restore();
            }else{
                ctx.save();
                ctx.globalCompositeOperation='lighter';
                for(let y=-50;y<h+55;y+=88){
                    spriteDraw(art.sky,edge(y),y,89,109,.27*fade);
                }
                ctx.restore();
            }
        }
        function drawBrightParticles(fade) {
            ctx.save();
            ctx.globalCompositeOperation='lighter';
            for(const q of p){
                if(q.type==='smoke'||q.type==='mist'||q.type==='shard')continue;
                const age=1-q.left/q.life;
                const brightness=Math.min(1,q.left/.25)*(1-age*.25)*fade;
                let artName='flame',sx=q.radius,sy=q.radius;
                if(q.type==='ember'){artName='ember';sx*=1.2;sy*=1.2;}
                else if(q.type==='pollen'){artName='sky';sx*=1.15;sy*=1.15;}
                else if(q.type==='sky'){artName='sky';sx*=1.1;sy*=1.55;}
                else {sx*=.9;sy*=1.65;}
                spriteDraw(art[artName],q.x,q.y,sx,sy,
                    brightness*(q.type==='ember'?.93:.78));
            }
            ctx.restore();
            ctx.globalAlpha=1;
            ctx.globalCompositeOperation='source-over';
        }
        function paint(now) {
            // Both original V25 curtains follow their OWN song cue clock.
            // Framerate drops cannot move 00:33 / 02:39 off the midpoint/end.
            const t=score
                ? Math.max(0,Math.min(1,(score.audio.currentTime-
                    score.config.fireStart)/
                    (score.config.fireEnd-score.config.fireStart)))
                : Math.min(1,(now-started)/duration);
            const seconds=now/1000;
            const edge=makeEdge(t,seconds,dark,w);
            // Clip-path polygon reconstruction can cause expensive iframe
            // compositor work. It runs at a bounded rate independent of rAF.
            atmosphereEdge=edge;
            window.__kkptVisualDark=dark;
            if(now-lastClip>=(mobile?33:23)||t>=1){
                clipFrame(edge);
                lastClip=now;
            }
            if(now-lastPaint<(mobile?35:32) && t<1){
                requestAnimationFrame(paint);return;
            }
            const dt=Math.min(.070,(now-previous)/1000);
            previous=now;lastPaint=now;
            const fade=Math.min(1,t/.055,(1-t)/.045);
            // Restore V14's broad smoke banks, long-lived char, pressure
            // waves and delicate blue leaves. V2 only paints the FIRE.
            drawAtmosphere(seconds,Math.min(t,.925),dark,(now-started)/1000,[]);
            if(dark&&gpuEmbers)gpuEmbers.draw(edge,seconds,dt,fade,t);
            if(dark){
                advance(dt);
                if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade));
                ctx.clearRect(0,0,w,h);
                drawShards(fade);
                drawCore(edge,seconds,t,fade);
                drawBrightParticles(fade);
            }
            if(t<1)requestAnimationFrame(paint);
            else done();
        }
        requestAnimationFrame(paint);
    }

    function setDisplayed(nextActive) {
        active=nextActive;
        frame.classList.toggle('kkpt-active',active);
        document.body.classList.toggle('kkpt-other-active',active);
        frame.style.clipPath=active?'inset(0)':hideClip;
        frame.style.visibility=active?'visible':'hidden';
        frame.style.pointerEvents='';
        frame.tabIndex=active?0:-1;
        frame.setAttribute('aria-hidden',active?'false':'true');
        document.title=active?otherTitle:baseTitle;
        showTheme((active?otherPage:basePage)==='Kuronata');
        // Stop the invisible Kuronata ambience in either document.
        root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active);
        try{
            frame.contentDocument?.documentElement.classList.toggle(
                'kkpt-frame-inactive',!active);
        }catch(_){}
        updateButton();
    }

    async function animateSwap(nextActive) {
        if(animating)return;
        animating=true;
        button.classList.add('kkpt-busy');
        labelSub.textContent='PREPARING THE OTHER SIDE...';
        const dark=(nextActive?otherPage:basePage)==='Kuronata';
        let score=returnScore?.config?.target===otherPage?
            returnScore:null;
        if(!loading)warmFrame();
        const loadWait=score?.config?.id==='marenoia'?41000:
            score?22000:11000;
        const ok=await Promise.race([loading,sleep(loadWait)
            .then(()=>false)]);
        if(!ok||!ready){
            // Preserve the audio cue even if the iframe preview is unavailable.
            if(score&&await score.playPromise){
                await awaitScoreTime(score,score.config.fireEnd);
            }
            removeScoreOmen();
            saveReturnScoreHandoff(score);
            navigateRealPage(nextActive?otherUrl.href:baseUrl);return;
        }
        // Finish the incremental prewarm before opening the curtain.
        await prewarmAtmosphere();
        if(score){
            const started=await Promise.race([
                score.playPromise,sleep(6500).then(()=>false)
            ]);
            if(!started||!await awaitScoreTime(score,
                score.config.fireStart)){
                clearReturnScore(score);
                score=null; // Preserve the exact V25 visuals on audio failure.
            }
        }
        removeScoreOmen();
        frame.style.visibility='visible';
        frame.classList.add('kkpt-animating');
        // Give the now-visible destination its own first composited frame.
        await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));

        labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
        // When returning to the blue page, force the Day palette
        // BEFORE unmasking it. The dark iframe still fully covers it.
        if(!dark)showTheme(false);
        frame.classList.remove('kkpt-active');
        frame.style.pointerEvents='none';
        frame.style.clipPath=active?'inset(0)':hideClip;

        const atmosphere=document.createElement('canvas');
        atmosphere.id='kkpt-atmosphere';
        atmosphere.setAttribute('aria-hidden','true');
        document.body.append(atmosphere);
        const drawAtmosphere=createAtmosphere(atmosphere);
        const canvas=dark?document.createElement('canvas'):null;
        if(canvas){
            canvas.id='kkpt-vfx';
            canvas.setAttribute('aria-hidden','true');
            document.body.append(canvas);
        }
        const gpuEmbers=dark?createGpuEmbers():null;
        window.__kkptRenderer=dark
            ? gpuEmbers?'WebGL2 embers + original V25 Canvas fire':
                'Original V25 Canvas fire (WebGL fallback)'
            : 'Original V25 Canvas dawn';
        const duration=dark?3450:3100;
        if(!score)transitionSound(dark,duration);
        await new Promise(resolve=>canvasFire(
            canvas,dark,duration,drawAtmosphere,resolve,gpuEmbers,
            score));

        setDisplayed(nextActive);
        if(canvas){
            canvas.style.opacity='0';
            setTimeout(()=>canvas.remove(),210);
        }
        if(gpuEmbers){
            gpuEmbers.canvas.style.transition='opacity 280ms ease-out';
            gpuEmbers.canvas.style.opacity='0';
            setTimeout(()=>gpuEmbers.dispose(),320);
        }
        // The last frame holds living smoke and drifting fragments.
        // Let it disperse with a lightweight compositor fade AFTER the
        // destination is revealed; no costly extra canvas render loop.
        const tail=dark?2000:1320;
        atmosphere.style.transition='opacity '+tail+
            'ms ease-out, transform '+tail+'ms ease-out';
        await new Promise(resolve=>requestAnimationFrame(resolve));
        atmosphere.style.opacity='0';
        atmosphere.style.transform='translate3d(0,-22px,0) scale(1.035)';
        await sleep(tail+70);
        atmosphere.remove();
        animating=false;
        button.classList.remove('kkpt-busy','kkpt-quiet');
        // The iframe was only the moving scenery during the reveal.
        // Hand off to the ACTUAL Wiki page: native sidebars, scrollbar,
        // history, refresh, accessibility and links all work normally.
        saveReturnScoreHandoff(score);
        navigateRealPage(otherUrl.href);
    }

    function requestSwap(nextActive) {
        if (animating || nextActive === active) return;
        if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
            saveReturnScoreHandoff(returnScore);
            navigateRealPage(otherUrl.href);
            return;
        }
        animateSwap(nextActive).catch(err => {
            console.error('[KuranokaPageTransition] V34:',err);
            removeScoreOmen();
            document.getElementById('kkpt-vfx')?.remove();
            document.getElementById('kkpt-atmosphere')?.remove();
            document.getElementById('kkpt-webgl')?.remove();
            if (frame) frame.style.filter = '';
            saveReturnScoreHandoff(returnScore);
            navigateRealPage(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);
        const continuingScore=restoreReturnScoreHandoff();
        if(continuingScore)setTimeout(warmFrame,3000);
        else warmFrame();
        prewarmAtmosphere();
        preloadRebirthPad();

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

    // A plain [[Kuronata|your mysterious text]] now triggers everything.
    // The inverse [[Kuranoka|...]] uses the same transition, then real navigation.
    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);
    });

    // Browser history is now real document history. A bfcache return
    // must restore THIS document, not leave its animated preview visible.
    window.addEventListener('pageshow', event => {
        if (!event.persisted) return;
        animating=false;
        document.getElementById('kkpt-vfx')?.remove();
        document.getElementById('kkpt-atmosphere')?.remove();
        if(frame){
            frame.style.filter='';
            setDisplayed(false);
            frame.classList.remove('kkpt-animating');
        }
        button?.classList.remove('kkpt-busy','kkpt-quiet');
    });

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