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

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
无编辑摘要
无编辑摘要
第1行: 第1行:
/* ============================================================
/* ============================================================
   Kuranoka / Kuronata — Page Transition V20
   Kuranoka / Kuronata — Page Transition V22
   V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments.
   V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments.
   V18 smooths the mask, uses sampled PADsynth and stabilizes scroll,
   V18 smooths the mask, uses sampled PADsynth and stabilizes scroll,
   with capped particle work and infrequent spatial glitches.
   with capped particle work and infrequent spatial glitches.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Two living same-origin Wiki documents; no reload for toggles.
   Real page navigation after the transition; iframe is visual-only.
   ============================================================ */
   ============================================================ */
(function () {
(function () {
第27行: 第27行:
         'skin-citizen-light'];
         'skin-citizen-light'];
     let savedTheme=themeNames.filter(c=>root.classList.contains(c));
     let savedTheme=themeNames.filter(c=>root.classList.contains(c));
    // Native navigation creates a new document; remember the original
    // reader palette across Kuronata's forced-dark world.
    const readerThemeKey='kkpt-reader-theme-v22';
    function readReaderTheme(){
        try{
            const value=JSON.parse(sessionStorage.getItem(readerThemeKey)||'null');
            return Array.isArray(value)&&
                value.length<=themeNames.length&&
                value.every(c=>themeNames.includes(c))?value:null;
        }catch(_){return null;}
    }
    function saveReaderTheme(classes){
        try{sessionStorage.setItem(readerThemeKey,JSON.stringify(classes));}
        catch(_){}
    }
    if(window===window.top){
        const previous=readReaderTheme();
        if(previous!==null)savedTheme=previous;
        else saveReaderTheme(savedTheme);
    }
     let enforceFramePreference=false;
     let enforceFramePreference=false;
     if(window!==window.top && basePage==='Kuranoka'){
     if(window!==window.top && basePage==='Kuranoka'){
第65行: 第85行:
         // Only Kuronata needs an enforced appearance.
         // Only Kuronata needs an enforced appearance.
         if(wantsNight||enforceFramePreference)syncTheme();
         if(wantsNight||enforceFramePreference)syncTheme();
         else savedTheme=themeNames.filter(c=>root.classList.contains(c));
         else {
            savedTheme=themeNames.filter(c=>root.classList.contains(c));
            if(window===window.top&&basePage==='Kuranoka')
                saveReaderTheme(savedTheme);
        }
     }).observe(root,{
     }).observe(root,{
         attributes:true,attributeFilter:['class']
         attributes:true,attributeFilter:['class']
第250行: 第274行:
     if (window!==window.top || window.__kkptV12) return;
     if (window!==window.top || window.__kkptV12) return;
     window.__kkptV12=true;
     window.__kkptV12=true;
     window.__kkptVersion='20.0';
     window.__kkptVersion='22.0';


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


     let active = false; // false: original top-level doc; true: otherPage iframe
     let active = false; // true only during the visual handoff; destination navigates natively
     let animating = false;
     let animating = false;
     let gearPending = false;
     let gearPending = false;
第580行: 第604行:
                 range.selectNodeContents(titleText);
                 range.selectNodeContents(titleText);
                 const rect=range.getBoundingClientRect();
                 const rect=range.getBoundingClientRect();
                 x=rect.right-hostRect.left+12;
                 x=Math.max(rect.right-hostRect.left+28,hostRect.width*.83);
                 y=rect.top-hostRect.top+rect.height*.5;
                 y=rect.top-hostRect.top+rect.height*.5;
             }
             }
第663行: 第687行:
             if (!doc) return;
             if (!doc) return;
             otherTitle = doc.title || otherTitle;
             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);
             watchPortalLinks(doc, basePage);
             mountFateGear(doc, basePage);
             mountFateGear(doc, basePage);
第678行: 第705行:
                 if (!href || href.startsWith('javascript:')) return;
                 if (!href || href.startsWith('javascript:')) return;
                 const next = new URL(href, doc.baseURI);
                 const next = new URL(href, doc.baseURI);
                 // Same-document fragments stay inside the iframe.
                 // Same-document fragments stay inside the temporary preview.
                 const currentFrame = new URL(frame.contentWindow.location.href);
                 const currentFrame = new URL(frame.contentWindow.location.href);
                 if (next.origin === currentFrame.origin &&
                 if (next.origin === currentFrame.origin &&
第1,870行: 第1,897行:
         animating=false;
         animating=false;
         button.classList.remove('kkpt-busy','kkpt-quiet');
         button.classList.remove('kkpt-busy','kkpt-quiet');
         // The target remains visible in a live iframe. No reload.
         // The iframe was only the moving scenery during the reveal.
         history.pushState({kkptV12:true,active},'',
         // Hand off to the ACTUAL Wiki page: native sidebars, scrollbar,
            active?otherUrl.href:baseUrl);
        // history, refresh, accessibility and links all work normally.
        saveReaderTheme(savedTheme);
        window.location.assign(otherUrl.href);
     }
     }


     function requestSwap(nextActive) {
     function requestSwap(nextActive) {
         if (animating || nextActive === active) return;
         if (animating || nextActive === active) return;
         if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
         if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
             setDisplayed(nextActive);
             saveReaderTheme(savedTheme);
             history.pushState({kkptV12:true,active},'',active?otherUrl.href:baseUrl);
             window.location.assign(otherUrl.href);
             return;
             return;
         }
         }
第1,923行: 第1,952行:


     // A plain [[Kuronata|your mysterious text]] now triggers everything.
     // A plain [[Kuronata|your mysterious text]] now triggers everything.
     // The inverse [[Kuranoka|...]] works inside the already-loaded iframe.
     // The inverse [[Kuranoka|...]] uses the same transition, then real navigation.
     document.addEventListener('click', event => {
     document.addEventListener('click', event => {
         if (active) return;
         if (active) return;
第1,932行: 第1,961行:
     });
     });


     // Back and Forward restore the two already-loaded documents instantly.
     // Browser history is now real document history. A bfcache return
     window.addEventListener('popstate', () => {
     // must restore THIS document, not leave its animated preview visible.
        if (!frame || !ready || animating) return;
        const route = pathPage();
        if (route === basePage || route === otherPage) {
            setDisplayed(route === otherPage);
        }
    });
 
     window.addEventListener('pageshow', event => {
     window.addEventListener('pageshow', event => {
         if (!event.persisted || !frame || !ready) return;
         if (!event.persisted) return;
         animating=false;
         animating=false;
         document.getElementById('kkpt-vfx')?.remove();
         document.getElementById('kkpt-vfx')?.remove();
         document.getElementById('kkpt-atmosphere')?.remove();
         document.getElementById('kkpt-atmosphere')?.remove();
         frame.style.filter = '';
         if(frame){
        setDisplayed(pathPage() === otherPage);
            frame.style.filter='';
            setDisplayed(false);
        }
         button?.classList.remove('kkpt-busy','kkpt-quiet');
         button?.classList.remove('kkpt-busy','kkpt-quiet');
     });
     });

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

/* ============================================================
   Kuranoka / Kuronata — Page Transition V22
   V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments.
   V18 smooths the mask, uses sampled PADsynth and stabilizes scroll,
   with capped particle work and infrequent spatial glitches.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Real page navigation after the transition; iframe is visual-only.
   ============================================================ */
(function () {
    'use strict';
    if (!window.mw || !mw.config) 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;

    // Snapshot the user's actual Citizen theme. An entering Kuronata
    // is dark only for this document; returning to Kuranoka restores
    // exactly the former Day / OS / Night preference classes.
    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 savedTheme=themeNames.filter(c=>root.classList.contains(c));
    // Native navigation creates a new document; remember the original
    // reader palette across Kuronata's forced-dark world.
    const readerThemeKey='kkpt-reader-theme-v22';
    function readReaderTheme(){
        try{
            const value=JSON.parse(sessionStorage.getItem(readerThemeKey)||'null');
            return Array.isArray(value)&&
                value.length<=themeNames.length&&
                value.every(c=>themeNames.includes(c))?value:null;
        }catch(_){return null;}
    }
    function saveReaderTheme(classes){
        try{sessionStorage.setItem(readerThemeKey,JSON.stringify(classes));}
        catch(_){}
    }
    if(window===window.top){
        const previous=readReaderTheme();
        if(previous!==null)savedTheme=previous;
        else saveReaderTheme(savedTheme);
    }
    let enforceFramePreference=false;
    if(window!==window.top && basePage==='Kuranoka'){
        // Iframes are independent documents. Restore the parent's ORIGINAL
        // reader preference, never the forced dark state during a swap.
        try {
            const inherited=window.top.__kkptReaderTheme;
            if(Array.isArray(inherited) &&
                inherited.every(c=>themeNames.includes(c))){
                savedTheme=inherited.slice();
                enforceFramePreference=true;
            }
        }catch(_){}
    } else if(window===window.top) {
        window.__kkptReaderTheme=savedTheme.slice();
    }
    let wantsNight=basePage==='Kuronata';
    let changingTheme=false;
    function syncTheme() {
        if (changingTheme) return;
        const expected=wantsNight
            ? ['skin-theme-clientpref-night','kkpt-kuronata-dark']
            : savedTheme;
        const forbidden=wantsNight
            ? themeNames.filter(c=>c!=='skin-theme-clientpref-night')
            : ['kkpt-kuronata-dark',...themeNames.filter(c=>!savedTheme.includes(c))];
        if(expected.every(c=>root.classList.contains(c)) &&
            forbidden.every(c=>!root.classList.contains(c))) return;
        changingTheme=true;
        root.classList.remove(...themeNames,'kkpt-kuronata-dark');
        root.classList.add(...expected);
        changingTheme=false;
    }
    function showTheme(dark) { wantsNight=dark;syncTheme(); }
    syncTheme();
    new MutationObserver(()=>{
        // Follow later preference changes while Kuranoka is visible.
        // Only Kuronata needs an enforced appearance.
        if(wantsNight||enforceFramePreference)syncTheme();
        else {
            savedTheme=themeNames.filter(c=>root.classList.contains(c));
            if(window===window.top&&basePage==='Kuranoka')
                saveReaderTheme(savedTheme);
        }
    }).observe(root,{
        attributes:true,attributeFilter:['class']
    });


    // 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(_){}
            });
        };
        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();
            createAmbience();
            mw.hook('wikipage.content').add(()=>{
                decorateSpoilers();
                decorateLinks();
            });
        };
        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='22.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();

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

    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]') : null;
    }

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

    // The Infobox gear begins to turn before the actual world changes.
    function beginFateSwap(anchor,nextActive) {
        if(animating||gearPending||nextActive===active)return;
        gearPending=true;
        anchor.classList.add('kkpt-fate-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('a');
        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>';
        host.appendChild(anchor);

        // A DOM Range tracks the actual first-line title, including
        // different font sizes and mobile widths. The wheel has no layout
        // footprint, so the text remains perfectly centered.
        function positionWheel() {
            if(!anchor.isConnected)return;
            const hostRect=host.getBoundingClientRect();
            if(!hostRect.width)return;
            const walker=doc.createTreeWalker(host,4);
            let titleText;
            while((titleText=walker.nextNode())) {
                if(titleText.parentElement.closest('.kkpt-fate-gear'))continue;
                if(titleText.textContent.trim())break;
            }
            let x=hostRect.width*.70, y=20;
            if(titleText) {
                const range=doc.createRange();
                range.selectNodeContents(titleText);
                const rect=range.getBoundingClientRect();
                x=Math.max(rect.right-hostRect.left+28,hostRect.width*.83);
                y=rect.top-hostRect.top+rect.height*.5;
            }
            x=Math.max(5,Math.min(x,hostRect.width-32));
            anchor.style.setProperty('--kkpt-gear-left',x.toFixed(1)+'px');
            anchor.style.setProperty('--kkpt-gear-top',y.toFixed(1)+'px');
        }
        positionWheel();
        if(doc.defaultView?.ResizeObserver) {
            new doc.defaultView.ResizeObserver(positionWheel).observe(host);
        }
        doc.defaultView?.addEventListener('resize',positionWheel,{passive:true});
        doc.fonts?.ready?.then(positionWheel).catch(()=>{});
    }

    // 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();
                window.location.assign(next.href);
            }, true);

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

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

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

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


    // 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;
    function createAtmosphere(canvas) {
        const ctx = canvas.getContext('2d', {alpha:true});
        const rand = n => {
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
            return x-Math.floor(x);
        };
        const clamp01 = n => Math.max(0,Math.min(1,n));
        function valueNoise(x,y,seed) {
            const ix=Math.floor(x), iy=Math.floor(y);
            let fx=x-ix,fy=y-iy;
            fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
            const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
            const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
            const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
            return a*(1-fy)+b*fy;
        }
        function smokeTexture(seed,dark) {
            const tex=document.createElement('canvas');
            tex.width=tex.height=112;
            const g=tex.getContext('2d');
            const data=g.createImageData(112,112);
            for(let y=0;y<112;y++) for(let x=0;x<112;x++){
                const u=(x-56)/56,v=(y-56)/56;
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
                const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
                const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
                const envelope=clamp01((1.05-radius)/.56);
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
                    envelope*envelope;
                const i=(y*112+x)*4;
                const variation=low*28+high*15;
                if(dark){
                    data.data[i]=5+variation*.37;
                    data.data[i+1]=5+variation*.33;
                    data.data[i+2]=8+variation*.38;
                }else{
                    data.data[i]=44+variation;
                    data.data[i+1]=91+variation;
                    data.data[i+2]=125+variation;
                }
                data.data[i+3]=Math.round(240*density);
            }
            g.putImageData(data,0,0);
            return tex;
        }
        const sootSprites=atmosphereAssetCache?.sootSprites || [0,1,2,3].map(n=>smokeTexture(n+17,true));
        const mistSprites=atmosphereAssetCache?.mistSprites || [0,1,2,3].map(n=>smokeTexture(n+51,false));
        // V2-inspired white-hot additive sprites, but with noise-eroded
        // silhouettes instead of repetitive smooth, elliptical balls.
        function glowTexture(seed,mode) {
            const tex=document.createElement('canvas');
            tex.width=96;tex.height=128;
            const g=tex.getContext('2d');
            const gradient=g.createRadialGradient(48,68,0,48,68,64);
            const colors=mode==='fire'
                ? [[0,'#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;
        }
        const FIRE_GLOW=atmosphereAssetCache?.FIRE_GLOW || [1,2,3,4].map(seed=>glowTexture(seed,'fire'));
        const BLUE_GLOW=atmosphereAssetCache?.BLUE_GLOW || [1,2,3,4].map(seed=>glowTexture(seed+7,'blue'));
        const EMBER_GLOW=atmosphereAssetCache?.EMBER_GLOW || glowTexture(21,'ember');
        atmosphereAssetCache ||= {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW};
        const combustion=[];
        const residualSoot=[];
        const tornPaper=[];
        let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0;
        function advanceCombustion(seconds,progress,dark,elapsed,w,h) {
            const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed));
            lastElapsed=elapsed;
            const live=progress>.01&&progress<.985;
            if(live){
                const flow=(dark?70: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<.55?'flame':r<.90?'ember':'ash'
                        : r<.37?'glow':r<.81?'leaf':'pollen';
                    const small=kind==='ember'||kind==='pollen';
                    const soot=kind==='ash';
                    const life=dark
                        ? (soot?.7+Math.random()*1.1:
                            small?.65+Math.random()*1.1:.42+Math.random()*.76)
                        : 1.15+Math.random()*1.55;
                    combustion.push({
                        kind,x:edge+(dark?1:-1)*(Math.random()*47-8),
                        y:y+(Math.random()-.5)*25,
                        vx:(dark?1:-1)*(dark?14+Math.random()*154:
                            16+Math.random()*69),
                        vy:-(dark?32+Math.random()*171:12+Math.random()*57),
                        life,remaining:life,
                        radius:dark
                            ? (soot?1.2+Math.random()*2.7:
                                small?2+Math.random()*5:9+Math.random()*19)
                            : kind==='glow'?26+Math.random()*49:
                                kind==='leaf'?4+Math.random()*6:
                                2+Math.random()*3,
                        angle:(Math.random()-.5)*1.1,
                        sprite:(Math.random()*4)|0,
                        seed:Math.random()*6.28
                    });
                }
                if(dark) {
                    // Unlike the traveling front, these black smoke plumes
                    // are born in world-space and remain over burnt paper.
                    sootRemainder+=dt*(h<680?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?9:15);
                    const shardBirths=Math.min(5,Math.floor(shardRemainder));
                    shardRemainder-=shardBirths;
                    for(let i=0;i<shardBirths&&tornPaper.length<135;i++){
                        const y=Math.random()*h;
                        const life=.82+Math.random()*1.55;
                        const points=6+Math.floor(Math.random()*4);
                        const outline=Array.from({length:points},(_,j)=>({
                            a:Math.PI*2*j/points+(Math.random()-.5)*.22,
                            r:.50+Math.random()*.61
                        }));
                        tornPaper.push({
                            x:edgeAt(y,seconds,progress,w)+(Math.random()-.5)*47,
                            y, vx:34+Math.random()*210,
                            vy:-170+Math.random()*260,
                            gravity:90+Math.random()*150,
                            radius:3+Math.pow(Math.random(),1.6)*15,
                            angle:Math.random()*6.28,
                            spin:(Math.random()-.5)*7,
                            outline,life,remaining:life,
                            seed:Math.random()*6.28
                        });
                    }
                }
            }
            for(let i=combustion.length-1;i>=0;i--){
                const p=combustion[i];
                p.remaining-=dt;
                if(p.remaining<=0){combustion.splice(i,1);continue;}
                p.x+=(p.vx+Math.sin(elapsed*(dark?5:1.6)+p.seed)*
                    (dark?22:24))*dt;
                p.y+=p.vy*dt;
                p.vy-=(dark?17:7)*dt;
            }
            for(let i=residualSoot.length-1;i>=0;i--){
                const p=residualSoot[i];
                p.remaining-=dt;
                if(p.remaining<=0){residualSoot.splice(i,1);continue;}
                p.x+=(p.vx+Math.sin(elapsed*2+p.seed)*27)*dt;
                p.y+=p.vy*dt;
                p.size+=32*dt;
            }
            for(let i=tornPaper.length-1;i>=0;i--){
                const p=tornPaper[i];
                p.remaining-=dt;
                if(p.remaining<=0){tornPaper.splice(i,1);continue;}
                p.x+=(p.vx+Math.sin(elapsed*4+p.seed)*42)*dt;
                p.y+=p.vy*dt;
                p.vy+=p.gravity*dt;
                p.angle+=p.spin*dt;
            }
        }
        const particles=Array.from({length:innerWidth<700?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?37:72},(_,i)=>({
            y:rand(i*3+17),phase:rand(i*9+43),
            speed:85+rand(i*12+5)*260,
            drift:75+rand(i*15+2)*170,
            length:3+rand(i*17+7)*11
        }));
        const shockDebris=Array.from({length:innerWidth<700?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,98,34,.29)':
                        'rgba(49,20,19,.47)';
                    ctx.stroke();
                    // Thin blue-white unburnt fibers still visible on a
                    // few ripped faces, before the back chars completely.
                    if(p.seed>4.5&&age<.52){
                        ctx.globalAlpha*=.45*(1-age);
                        ctx.strokeStyle='#c5e7f7';
                        ctx.beginPath();
                        ctx.moveTo(-p.radius*.42,-p.radius*.16);
                        ctx.quadraticCurveTo(0,p.radius*.25,p.radius*.28,0);
                        ctx.stroke();
                    }
                    ctx.restore();
                }
                ctx.restore();
            }
            // Sharp, directional embers / crystalline fragments, not blobs.
            if(dark){ctx.save();
            ctx.globalCompositeOperation='screen';
            for(const mote of motes){
                const age=(elapsed*.74+mote.phase)%1;
                const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
                const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
                if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
                const opacity=(1-age)*fade*.89;
                ctx.globalAlpha=opacity;
                ctx.strokeStyle='#ffb45d';
                ctx.lineWidth=.8;
                ctx.beginPath();
                ctx.moveTo(x,yy);
                ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
                ctx.stroke();
            }
            ctx.restore();}
        };
    }

    function prewarmAtmosphere() {
        if(atmosphereAssetCache)return;
        const prepare=()=>{
            if(!atmosphereAssetCache)
                createAtmosphere(document.createElement('canvas'));
        };
        if('requestIdleCallback' in window)
            requestIdleCallback(prepare,{timeout:6000});
        else setTimeout(prepare,1400);
    }

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

    // 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) {
        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;
        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) {
            const t=Math.min(1,(now-started)/duration);
            const seconds=now/1000;
            const edge=makeEdge(t,seconds,dark,w);
            // Update the actual Wiki page reveal at display refresh rate.
            // The costly flame/smoke pass has an independent 30fps budget.
            atmosphereEdge=edge;
            window.__kkptVisualDark=dark;
            clipFrame(edge);
            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){
                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.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...';
        if(!loading)warmFrame();
        const ok=await Promise.race([loading,sleep(11000).then(()=>false)]);
        if(!ok||!ready){
            window.location.assign(nextActive?otherUrl.href:baseUrl);return;
        }

        const dark=(nextActive?otherPage:basePage)==='Kuronata';
        labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
        // When returning to the blue page, restore its light 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 duration=dark?3450:3100;
        transitionSound(dark,duration);
        await new Promise(resolve=>canvasFire(
            canvas,dark,duration,drawAtmosphere,resolve));

        setDisplayed(nextActive);
        if(canvas){
            canvas.style.opacity='0';
            setTimeout(()=>canvas.remove(),210);
        }
        // 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.
        saveReaderTheme(savedTheme);
        window.location.assign(otherUrl.href);
    }

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

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

        button.addEventListener('click', event => {
            if (!isPlainClick(event)) return;
            event.preventDefault();
            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);
        }
        button?.classList.remove('kkpt-busy','kkpt-quiet');
    });

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