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

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
无编辑摘要
标签:已被回退
无编辑摘要
标签:手工回退
第1行: 第1行:
/* ============================================================
/* ============================================================
   Kuranoka / Kuronata — Page Transition V28 / WebGL2
   Kuranoka / Kuronata — Page Transition V25
   Original V13/P3 textured, violent, white-hot fire and smoke layer,
   V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments.
  now integrated into the real-page navigation system of V27.
   V18 smooths the mask, uses sampled PADsynth and stabilizes scroll,
   Restore the V13 flame/ember intensity and the old atmospheric canvas.
   with capped particle work and infrequent spatial glitches.
   Canvas 2D engine retained as the no-WebGL fallback.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Only this page-scoped JS/CSS; never modify Citizen or loader.js.
   Real page navigation after the transition; iframe is visual-only.
   Real page navigation after the transition; iframe is visual-only.
   ============================================================ */
   ============================================================ */
(function initKkpt(retry) {
(function () {
     'use strict';
     'use strict';
    retry=retry||0;
     if (!window.mw || !mw.config) return;
    // A separately imported page script may arrive before mw.config.
    // Retry rather than silently abandoning the entire world.
     if (!window.mw || !mw.config || typeof mw.config.get!=='function') {
        if(retry<40)setTimeout(()=>initKkpt(retry+1),100);
        return;
    }


     const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/);
     const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/);
第56行: 第49行:
     function showTheme(dark){wantsNight=dark;syncTheme();}
     function showTheme(dark){wantsNight=dark;syncTheme();}
     syncTheme();
     syncTheme();
    // CSS and JS are sometimes loaded independently on this wiki.
    // A sentinel distinguishes a missing stylesheet from slow image
    // decoding. A single same-origin retry prevents unstyled effects.
    function ensureWorldStyles(){
        if(!document.head || document.getElementById('kkpt-css-retry'))return;
        try{
            if(getComputedStyle(root).getPropertyValue('--kkpt-css-v28').trim()==='ready')
                return;
        }catch(_){}
        const link=document.createElement('link');
        link.id='kkpt-css-retry';
        link.rel='stylesheet';
        const target=new URL('/index.php',location.origin);
        target.searchParams.set('title','MediaWiki:KuranokaPageTransition.css');
        target.searchParams.set('action','raw');
        target.searchParams.set('ctype','text/css');
        target.searchParams.set('kkptv','28');
        link.href=target.href;
        link.addEventListener('error',()=>console.warn(
            '[KuranokaPageTransition] Stylesheet retry failed; '+
            'check ResourceLoader / CSS permissions.'));
        document.head.appendChild(link);
    }
    setTimeout(ensureWorldStyles,1200);
     // Citizen's later initialization may reinstate the OS-preference class.
     // Citizen's later initialization may reinstate the OS-preference class.
     // Keep the intended world palette without touching the user's global
     // Keep the intended world palette without touching the user's global
第269行: 第238行:
     if (window!==window.top || window.__kkptV12) return;
     if (window!==window.top || window.__kkptV12) return;
     window.__kkptV12=true;
     window.__kkptV12=true;
     window.__kkptVersion='28.0';
     window.__kkptVersion='25.0';


     // Easter eggs + Infobox gear are the default. No floating pill.
     // Easter eggs + Infobox gear are the default. No floating pill.
第606行: 第575行:
             '"/><circle cx="24" cy="24" r="8.5"/>'+
             '"/><circle cx="24" cy="24" r="8.5"/>'+
             '<circle cx="24" cy="24" r="3"/></svg>';
             '<circle cx="24" cy="24" r="3"/></svg>';
        // Critical inline dimensions: if page-specific CSS arrives late,
        // a raw SVG must NEVER expand into the giant black gear again.
        host.style.position='relative';
        anchor.style.cssText='position:absolute;right:7px;top:7px;'+
            'width:26px;height:26px;padding:4px;box-sizing:border-box;'+
            'display:grid;place-items:center;float:none;z-index:3;'+
            'color:#e5eff5;background:rgba(30,45,60,.35);'+
            'border:1px solid rgba(255,255,255,.30);border-radius:50%';
        anchor.firstElementChild?.setAttribute('width','100%');
        anchor.firstElementChild?.setAttribute('height','100%');
         host.appendChild(anchor);
         host.appendChild(anchor);


第913行: 第872行:
             const live=progress>.01&&progress<.985;
             const live=progress>.01&&progress<.985;
             if(live){
             if(live){
                // Restore the actual V13 flame density: the V27 reduction
                 const flow=(dark?70:96)*Math.min(1.5,h/720)*
                // from 370 to 70 removed most of P3's oppressive fire wall.
                 const flow=(dark?370:115)*Math.min(1.5,h/720)*
                     (.75+.25*Math.sin(Math.PI*progress));
                     (.75+.25*Math.sin(Math.PI*progress));
                 spawnRemainder+=dt*flow;
                 spawnRemainder+=dt*flow;
第954行: 第911行:
                     // Unlike the traveling front, these black smoke plumes
                     // Unlike the traveling front, these black smoke plumes
                     // are born in world-space and remain over burnt paper.
                     // are born in world-space and remain over burnt paper.
                     sootRemainder+=dt*(h<680?41:62);
                     sootRemainder+=dt*(h<680?32:46);
                     const sootBirths=Math.min(11,Math.floor(sootRemainder));
                     const sootBirths=Math.min(11,Math.floor(sootRemainder));
                     sootRemainder-=sootBirths;
                     sootRemainder-=sootBirths;
                     for(let i=0;i<sootBirths&&residualSoot.length<(w<700?125:225);i++){
                     for(let i=0;i<sootBirths&&residualSoot.length<(w<700?92:155);i++){
                         const y=Math.random()*h;
                         const y=Math.random()*h;
                         const life=2.15+Math.random()*1.60;
                         const life=2.15+Math.random()*1.60;
第1,024行: 第981行:
             }
             }
         }
         }
         const particles=Array.from({length:innerWidth<700?54:85},(_,i)=>({
         const particles=Array.from({length:innerWidth<700?40:62},(_,i)=>({
             seed:rand(i*4+71),height:rand(i*5+13),
             seed:rand(i*4+71),height:rand(i*5+13),
             lifetime:.5+rand(i*8+37)*.7,
             lifetime:.5+rand(i*8+37)*.7,
第1,037行: 第994行:
             length:3+rand(i*17+7)*11
             length:3+rand(i*17+7)*11
         }));
         }));
         const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({
         const shockDebris=Array.from({length:innerWidth<700?32:66},(_,i)=>({
             angle:rand(i*17+131)*Math.PI*2,
             angle:rand(i*17+131)*Math.PI*2,
             velocity:190+rand(i*11+51)*900,
             velocity:190+rand(i*11+51)*900,
第1,063行: 第1,020行:
         return function drawAtmosphere(seconds,progress,dark,elapsed,cells) {
         return function drawAtmosphere(seconds,progress,dark,elapsed,cells) {
             const w=innerWidth,h=innerHeight;
             const w=innerWidth,h=innerHeight;
             const scale=w<700?.56:.69;
             const scale=w<700?.43:.55;
             const bw=Math.max(1,Math.round(w*scale));
             const bw=Math.max(1,Math.round(w*scale));
             const bh=Math.max(1,Math.round(h*scale));
             const bh=Math.max(1,Math.round(h*scale));
第1,218行: 第1,175行:
                 ctx.save();
                 ctx.save();
                 ctx.globalCompositeOperation='screen';
                 ctx.globalCompositeOperation='screen';
                 const count=Math.ceil(h/11.5);
                 const count=Math.ceil(h/17);
                 for(let j=0;j<count;j++){
                 for(let j=0;j<count;j++){
                     const seed=rand(j*13+167);
                     const seed=rand(j*13+167);
第1,557行: 第1,514行:
     }
     }


     // Follow the exact ragged flame front from the V13/P3 WebGL shader.
     // V2's simple advancing boundary. The reader asked for the
     // Keeping CSS clipping and GPU edge in sync prevents detached fire.
     // vibrant fire curtain, not a mathematically fractal tearing wire.
     function makeEdge(t,seconds,dark,w) {
     function makeEdge(t,seconds,dark,w) {
         const wave=Math.sin(Math.PI*t);
         const center=dark
        return y=>{
             ? w+105-(w+210)*smooth(t)
            const yn=y/innerHeight;
            : -105+(w+210)*smooth(t);
            const lobes=.63*Math.sin(yn*9.3+seconds*.38+.4)+
        const wobble=Math.sin(Math.PI*t);
                .27*Math.sin(yn*22.7-seconds*.28+1.4)+
        return y=>center+wobble*(
                .10*Math.sin(yn*51.1+seconds*.58);
            17*Math.sin(y*.023+seconds*5.5)+
            const local=dark
            9*Math.sin(y*.063-seconds*8.4)+
                ?Math.max(0,Math.min(1,t+.285*wave*lobes)):t;
            4*Math.sin(y*.13+seconds*12)
             const c=dark?w+110-(w+220)*smooth(local)
        );
                :-110+(w+220)*smooth(local);
            const turbulent=dark
                ?22*Math.sin(y*.019+seconds*1.8)+
                  13*Math.sin(y*.061-seconds*3.6)+
                  6*Math.sin(y*.135+seconds*7.9)+
                  17*(Math.abs(Math.sin(y*.133+seconds*3.7+
                      .8*Math.sin(y*.043-seconds*1.1)))-.5)+
                  5*Math.sin(y*.325-seconds*10.8)+
                  2.5*Math.sin(y*.74+seconds*16.2)+
                  3.4*Math.sin(y*.51-seconds*13.3)+
                  1.8*Math.sin(y*.94+seconds*21.1)
                :28*Math.sin(y*.012+seconds*3.1)+
                  17*Math.sin(y*.041-seconds*5.7)+
                  7*Math.sin(y*.10+seconds*10);
            return c+wave*turbulent;
        };
     }
     }


第1,590行: 第1,531行:
     function clipFrame(edge) {
     function clipFrame(edge) {
         const w=innerWidth,h=innerHeight;
         const w=innerWidth,h=innerHeight;
         const rows=Math.max(128,Math.ceil(h/6));
         const rows=Math.max(24,Math.ceil(h/30));
         const pts=[];
         const pts=[];
         for(let i=rows;i>=0;i--) {
         for(let i=rows;i>=0;i--) {
第1,601行: 第1,542行:
     }
     }


    // ================================================================
     function canvasFire(canvas,dark,duration,drawAtmosphere,done) {
    // V28: the original V13/P3 combustion shader on the V27 WebGL2
         const ctx=dark&&canvas
    // navigation system, with the original smoke/embers ABOVE the shader.
            ? canvas.getContext('2d',{alpha:true,desynchronized:true})
    // ================================================================
             : null;
     function createWebGLCurtain(dark) {
         if(dark&&!ctx){done();return;}
        if(!window.WebGL2RenderingContext)return null;
         const art=dark?makeSprites():null;
         const surface=document.createElement('canvas');
         const w=innerWidth,h=innerHeight;
        surface.id='kkpt-webgl';
         if(ctx){
        surface.setAttribute('aria-hidden','true');
             const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80);
        surface.style.cssText='position:fixed;inset:0;width:100vw;'+
             canvas.width=Math.max(1,Math.round(w*scale));
             'height:100vh;z-index:2147482100;pointer-events:none;'+
             canvas.height=Math.max(1,Math.round(h*scale));
            'display:block;background:transparent';
             ctx.setTransform(scale,0,0,scale,0,0);
         document.body.appendChild(surface);
        let gl=null;
        let curtain=null,sparks=null;
        let attachedAtmosphere=null;
        let raf=0,disposed=false,contextLost=false;
         const mobile=innerWidth<700||
            matchMedia('(pointer:coarse)').matches;
         const view=[innerWidth,innerHeight];
         const sharedFns=`
            precision highp float;
            float hash(vec2 p) {
                p=fract(p*vec2(.3183099,.3678794)+.13787);
                p*=17.0;
                return fract(p.x*p.y*(p.x+p.y));
             }
            float noise2(vec2 p) {
                vec2 i=floor(p),f=fract(p);
                f=f*f*(3.0-2.0*f);
                return mix(mix(hash(i),hash(i+vec2(1.0,0.0)),f.x),
                          mix(hash(i+vec2(0.0,1.0)),hash(i+1.0),f.x),f.y);
             }
            float fbm(vec2 p) {
                float n=0.0,a=.56;
                for(int j=0;j<3;j++){
                    n+=a*noise2(p);
                    p=mat2(1.61,-1.13,1.13,1.61)*p+13.31;
                    a*=.50;
                }
                return n;
            }
            float smooth01(float p){return p*p*(3.0-2.0*p);}
            float stormEdge(float y,float t,float clock,float w,float dark) {
                float s=smooth01(t);
                float center=mix(-105.0+(w+210.0)*s,
                                  w+105.0-(w+210.0)*s,dark);
                float wobble=sin(3.14159265*t);
                return center+wobble*(
                    17.0*sin(y*.023+clock*5.5)+
                    9.0*sin(y*.063-clock*8.4)+
                    4.0*sin(y*.13+clock*12.0));
             }
        `;
        const backgroundVert=`#version 300 es
            out vec2 vUV;
            void main(){
                vec2 p=vec2(gl_VertexID==1?3.0:-1.0,
                            gl_VertexID==2?3.0:-1.0);
                vUV=(p+1.0)*.5;
                gl_Position=vec4(p,0.0,1.0);
            }`;
        const backgroundFrag=`#version 300 es
        precision highp float;
            in vec2 vUV;
            out vec4 outColor;
 
        uniform vec2 uView;
 
        uniform float uClock;
        uniform float uProgress;
        uniform float uDark;
 
        float random2(vec2 p) {
            return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123);
        }
        float noise(vec2 p) {
            vec2 i = floor(p), f = fract(p);
             vec2 u = f*f*(3.0-2.0*f);
            return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x),
                      mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y);
         }
         }
        float fbm(vec2 p) {
            float result = 0.0, amp = 0.5;
            for (int i = 0; i < 5; i++) {
                result += amp * noise(p);
                p = mat2(1.6,-1.2,1.2,1.6) * p;
                amp *= 0.5;
            }
            return result;
        }
        float smoothProgress(float t) { return t*t*(3.0-2.0*t); }
        float edge(float y) {
            float w = uView.x;
            float wave=sin(3.14159265*uProgress);
            float yn=y/uView.y;
            float lobes=.63*sin(yn*9.3+uClock*.38+.4)+
                .27*sin(yn*22.7-uClock*.28+1.4)+
                .10*sin(yn*51.1+uClock*.58);
            float localProgress=uDark>.5
                ? clamp(uProgress+.285*wave*lobes,0.0,1.0)
                : uProgress;
            float t = smoothProgress(localProgress);
            float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t;
            float turbulent=uDark>.5
                ? 22.0*sin(y*.019+uClock*1.8)+
                  13.0*sin(y*.061-uClock*3.6)+
                  6.0*sin(y*.135+uClock*7.9)+
                  17.0*(abs(sin(y*.133+uClock*3.7+
                      .8*sin(y*.043-uClock*1.1)))-.5)+
                  5.0*sin(y*.325-uClock*10.8)+
                  2.5*sin(y*.74+uClock*16.2)+
                  3.4*sin(y*.51-uClock*13.3)+
                  1.8*sin(y*.94+uClock*21.1)
                : 28.0*sin(y*.012+uClock*3.1)+
                  17.0*sin(y*.041-uClock*5.7)+
                  7.0*sin(y*.10+uClock*10.0);
            return c+wave*turbulent;
        }
        void main() {
            vec2 p = vec2(vUV.x*uView.x,(1.0-vUV.y)*uView.y);
            float d = p.x-edge(p.y);
            // Entire sky/background pixels outside the active burn wake
            // need no fbm work at all; this saves most pixels early/late.
            if ((uDark > .5 && (d < -138.0 || d > 970.0)) ||
                (uDark < .5 && (d > 148.0 || d < -1050.0))) {
                outColor = vec4(0.0);
                return;
            }
            float oldSide = uDark > .5 ? -d : d;
            float t = uClock;
            float impact = sin(3.14159265*uProgress);
            vec3 col = vec3(0.0);
            float alpha = 0.0;


            if (uDark > .5) {
        const rand=(a,b)=>a+Math.random()*(b-a);
                // V7: intact sky remains clean on the OLD side.
        const mobile=w<650;
                // Incandescent wake + char exist ONLY on the newly
        const maxCount=mobile?160:250;
                // revealed Kuronata side, trailing the traveling tear.
        const p=[];
                float wakeSide = -oldSide;
        const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0};
                vec2 sq = vec2(p.y*.007-t*.29,wakeSide*.006+t*.12);
        const rates=dark
                vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)),
            ? {flame:mobile?80:105,ember:mobile?72:96,
                                fbm(sq*1.9+vec2(0.0,-t*.21)));
              shard:mobile?26:42}
                float smokeCloud = fbm(sq*2.0+warp*2.2);
            : {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50};
                float smokeDetail = fbm(sq*5.1-warp*.8);
        const started=performance.now();
                float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34);
        let previous=started;
                float soot = smoothstep(-42.0,24.0,wakeSide)*
        let lastPaint=0;
                    (1.0-smoothstep(390.0,850.0,wakeSide))*
        let lastClip=0;
                    (.47+.53*billow);
        function spawn(kind,edge,seconds) {
                vec3 sootColor = mix(vec3(.010,.009,.012),vec3(.066,.019,.018),smokeCloud);
            if(p.length>=maxCount)return;
 
            const y=rand(-25,h+25);
                // Flame remains close to the paper boundary; the far wake
            const x=edge(y);
                // retains soot and charcoal, not wandering bright blobs.
            const type=kind;
                vec2 q = vec2(p.y*.015-t*.66,wakeSide*.012-t*.49);
            const smoke=type==='smoke';
                float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25));
            const shard=type==='shard';
                float shredded = fbm(q*3.9+vec2(t*.25,-t*.32));
            const ember=type==='ember'||type==='pollen';
                float lengthOfTongue = 57.0+65.0*turbulence+33.0*shredded;
            const blue=type==='sky'||type==='mist'||type==='pollen';
                float raggedEdge = 1.0-smoothstep(lengthOfTongue-35.0,lengthOfTongue+18.0,wakeSide);
             let radius,life,vx,vy;
                float flame = raggedEdge*smoothstep(-65.0,23.0,wakeSide)*
             if(shard){
                    smoothstep(.17,.65,turbulence+.38*shredded);
                 radius=rand(4,18);life=rand(.6,1.65);
                float ignitionNoise=fbm(vec2(p.y*.057+t*.19,
                 vx=rand(-130,65);vy=rand(-125,30);
                    t*.84+sin(p.y*.029)*.44));
            }else if(smoke){
                float ignite=.33+.67*smoothstep(.25,.59,
                 radius=rand(32,85);life=rand(1.1,2.5);
                    ignitionNoise+.13*sin(p.y*.092-t*7.8));
                 vx=rand(-20,65);vy=rand(-88,-22);
                // Burning paper has three distinct temperatures: dark red
             }else if(type==='mist'){
                // carbon, yellow incandescent fibers and discontinuous
                 radius=rand(45,100);life=rand(.9,1.8);
                // white-hot ignition at the freshly eroded edge.
                 vx=rand(-35,45);vy=rand(-75,-15);
                float crackle = noise(vec2(p.y*.19-t*1.7,
             }else if(type==='flame'){
                    sin(p.y*.12+t*2.4)*1.4+t*2.3));
                radius=rand(20,57);life=rand(.3,.82);
                float brightGaps = smoothstep(.24,.65,
                vx=rand(-100,30);vy=rand(-180,-45);
                    ignitionNoise*.78+crackle*.54);
             }else{
                float core = exp(-abs(d)/6.8)*(.44+.56*brightGaps);
                 radius=ember?rand(2.5,9):rand(18,47);
                float whiteCore = exp(-pow(d/4.2,2.0)) *
                life=ember?rand(.45,1.1):rand(.35,.85);
                    (.18+.82*brightGaps);
                vx=rand(-95,75);vy=rand(-150,-25);
                float outer = exp(-abs(d)/28.0)*(.32+.68*ignite);
                float heat = clamp(flame*.98+core*.97,0.0,1.0);
                float hotPockets = smoothstep(.43,.73,turbulence*.78+shredded*.55)*
                    exp(-max(0.0,wakeSide)/62.0);
                vec3 fireColor = mix(vec3(.40,.012,.005),vec3(1.0,.18,.007),
                    smoothstep(.07,.51,heat));
                fireColor = mix(fireColor,vec3(1.0,.72,.075),
                    clamp(flame*.42+core*.82+hotPockets*.55,0.0,.97));
                fireColor = mix(fireColor,vec3(1.0,.995,.86),
                    clamp(whiteCore*1.10+hotPockets*.22,0.0,.96));
                col = mix(sootColor,fireColor,
                    clamp(flame*.93+core*1.22+whiteCore*.58,0.0,1.0));
                alpha = clamp(soot*.67+flame*.92+outer*.28+
                    core*.79+whiteCore*.38,0.0,.98);
 
                // Sharp micron-scale cinders; the real ember trails live
                // on the separate high-resolution atmosphere canvas.
                vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0);
                vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5;
                float sparks = (1.0-smoothstep(.015,.12,length(uv)))*
                    step(.991,random2(cell))*exp(-max(0.0,wakeSide)/380.0);
                col = mix(col,vec3(1.0,.78,.23),sparks);
                alpha = max(alpha,sparks*.97);
            } else {
                // Dawn is a living, spreading atmosphere: diffuse cloud,
                // breathing alabaster light and soft shafts. NO electric
                // arcs, ignition lines, flame-shaped sparks or embers.
                float newlyBlue = -oldSide;
                vec2 q = vec2(p.y*.007-t*.16,newlyBlue*.008+t*.10);
                float cloud = fbm(q+vec2(fbm(q*1.6)*1.7,-t*.13));
                float billow = fbm(q*2.9+vec2(t*.08,-t*.11));
                float erosion = smoothstep(.22,.72,cloud*.76+billow*.37);
                float settling = smoothstep(-68.0,35.0,newlyBlue)*
                    exp(-max(0.0,newlyBlue)/440.0);
                float pearlyMist = settling*(.33+.64*erosion);
                float halo = exp(-pow(d/78.0,2.0));
                float innerLight = exp(-pow(d/21.0,2.0));
                float shaftDrift=20.0*sin(p.y*.013-t*1.0)+
                    9.0*sin(p.y*.039+t*.61);
                float softRay=exp(-pow((d+shaftDrift+88.0)/81.0,2.0))*
                    (.32+.68*smoothstep(.28,.81,cloud));
                col=mix(vec3(.075,.30,.57),vec3(.72,.94,1.0),clamp(cloud*.76+.38,0.0,1.0));
                col=mix(col,vec3(.94,.995,1.0),
                    clamp(innerLight*.49+softRay*.20,0.0,.6));
                alpha=clamp(pearlyMist*.42+halo*.30+innerLight*.22+
                    softRay*.14,0.0,.59);
            }
 
            if(uDark < .5) {
                col = mix(vec3(.065,.21,.35),col,.45);
                alpha = min(alpha*.52,.35);
            }
 
            // No long-lasting screen tint after the burn has finished.
            alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) *
                (1.0-smoothstep(.94,1.0,uProgress));
            outColor = vec4(col,alpha);
        }
    `;
        const sparkVert=`#version 300 es
            ${sharedFns}
            uniform vec2 uView;
             uniform float uClock,uProgress,uDark,uPhase,uTail,uScale;
             out float vEnergy,vKind;
            float rnd(float x){return hash(vec2(x,x*.713));}
            void main(){
                 float id=float(gl_VertexID);
                float t=clamp(uProgress,0.0,1.0);
                float age=fract(rnd(id*1.07+7.3)+uClock*
                                mix(.16,.46,uDark)*(1.0+rnd(id+11.0)*.9));
                 float y=rnd(id*17.7+3.0)*(uView.y+155.0)-68.0-
                    age*mix(71.0,195.0,uDark);
                float dir=mix(-1.0,1.0,uDark);
                float flight=(8.0+age*(170.0+rnd(id*4.2)*280.0));
                 float x=stormEdge(y,t,uClock,uView.x,uDark)+
                    dir*flight+sin(id*6.7+uClock*5.0)*age*22.0;
                float tailMix=uPhase;
                float xTail=rnd(id*41.2+13.0)*uView.x+
                    dir*(1.0-uTail)*89.0;
                float yTail=rnd(id*19.1+17.0)*uView.y-
                    (1.0-uTail)*105.0;
                x=mix(x,xTail,tailMix);
                y=mix(y,yTail,tailMix);
                float envelope=smoothstep(.01,.13,t)*
                    (1.0-smoothstep(.89,1.0,t));
                vEnergy=mix(envelope*(1.0-age)*(.64+.62*rnd(id+1.0)),
                            uTail*(.22+.64*(1.0-age)),tailMix);
                vKind=rnd(id*5.9+31.0);
                 float size=mix(1.2,6.2,rnd(id+8.0))*
                    mix(.65,1.27,uDark);
                gl_PointSize=max(1.0,size*uScale);
                gl_Position=vec4(x/uView.x*2.0-1.0,
                                1.0-y/uView.y*2.0,0.0,1.0);
             }`;
        const sparkFrag=`#version 300 es
            precision highp float;
            uniform float uDark,uRenderMode;
            in float vEnergy,vKind;
            out vec4 outColor;
            void main(){
                vec2 p=gl_PointCoord*2.0-1.0;
                p.x*=1.12;
                float radius=dot(p,p);
                 if(radius>1.0)discard;
                // Draw real charcoal flecks with normal alpha blending,
                // then hot embers in their own additive pass. Dark shapes
                // would disappear if mixed into a single additive pass.
                if(uRenderMode<.5){
                    if(uDark<.5 || vKind>.23)discard;
                    float irregular=.78+.17*sin(atan(p.y,p.x)*7.0+
                                            vKind*80.0);
                    if(radius>irregular)discard;
                    outColor=vec4(vec3(.023,.017,.019),
                                  (1.0-radius)*vEnergy*.69);
                    return;
                }
                 if(uDark>.5 && vKind<.23)discard;
                float a=pow(1.0-radius,2.8)*vEnergy;
                vec3 hot=mix(vec3(.93,.095,.014),vec3(1.0,.85,.36),
                            smoothstep(.18,.90,vKind));
                vec3 blue=mix(vec3(.17,.38,.57),vec3(.45,.68,.75),
                              smoothstep(.2,.8,vKind));
                outColor=vec4(mix(blue,hot,uDark),
                              a*mix(.20,.85,uDark));
             }`;
 
        function compile(type,source){
            const shader=gl.createShader(type);
            gl.shaderSource(shader,source);
            gl.compileShader(shader);
            if(!gl.getShaderParameter(shader,gl.COMPILE_STATUS)){
                const log=gl.getShaderInfoLog(shader);
                gl.deleteShader(shader);
                throw new Error('WebGL shader compilation: '+log);
            }
            return shader;
        }
        function program(v,f){
            const vert=compile(gl.VERTEX_SHADER,v);
            const frag=compile(gl.FRAGMENT_SHADER,f);
            const result=gl.createProgram();
            gl.attachShader(result,vert);gl.attachShader(result,frag);
            gl.linkProgram(result);
            gl.deleteShader(vert);gl.deleteShader(frag);
            if(!gl.getProgramParameter(result,gl.LINK_STATUS)){
                const log=gl.getProgramInfoLog(result);
                gl.deleteProgram(result);
                throw new Error('WebGL program link: '+log);
             }
            const names=['uView','uClock','uProgress','uDark',
                'uPhase','uTail','uScale','uRenderMode'];
            return {program:result,locations:Object.fromEntries(
                 names.map(name=>[name,gl.getUniformLocation(result,name)]))};
        }
        function setUniforms(p,clock,progress,phase,tail,scale){
            gl.useProgram(p.program);
            const u=p.locations;
            if(u.uView)gl.uniform2f(u.uView,view[0],view[1]);
            if(u.uClock)gl.uniform1f(u.uClock,clock);
            if(u.uProgress)gl.uniform1f(u.uProgress,progress);
            if(u.uDark)gl.uniform1f(u.uDark,dark?1:0);
            if(u.uPhase)gl.uniform1f(u.uPhase,phase);
            if(u.uTail)gl.uniform1f(u.uTail,tail);
            if(u.uScale)gl.uniform1f(u.uScale,scale);
        }
        function resize(){
            view[0]=innerWidth;view[1]=innerHeight;
            // A denser shader but a smaller framebuffer; smoke is naturally
            // soft, so sub-native resolution is barely visible and avoids
            // excessive GPU load on phones or high-DPI displays.
            const desired=mobile?.47:.61;
            const maxPixels=mobile?210000:600000;
            const bound=Math.sqrt(maxPixels/Math.max(1,view[0]*view[1]));
            const scale=Math.min(desired,bound,window.devicePixelRatio||1);
            const w=Math.max(1,Math.round(view[0]*scale));
            const h=Math.max(1,Math.round(view[1]*scale));
            if(surface.width!==w||surface.height!==h){
                surface.width=w;surface.height=h;
                gl.viewport(0,0,w,h);
             }
             }
             return w/Math.max(1,view[0]);
             p.push({
        }
                 type,life,left:life,
        function render(progress,phase,tail,clock){
                 x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64),
            if(contextLost||disposed)return false;
                 y:y+rand(-14,14),vx,vy,radius,
            try{
                 angle:rand(-Math.PI,Math.PI),spin:rand(-5,5),
                const scale=resize();
                 drift:rand(-.65,.65),blue
                gl.disable(gl.BLEND);
             });
                 gl.clearColor(0,0,0,0);
                 gl.clear(gl.COLOR_BUFFER_BIT);
                 setUniforms(curtain,clock,progress,phase,tail,scale);
                 gl.drawArrays(gl.TRIANGLES,0,3);
                // P3's real embers, glowing flame sprites and banked smoke
                 // are drawn on the overlying original atmosphere canvas.
                return true;
            }catch(error){
                console.warn('[KuranokaPageTransition] GPU frame error:',error);
                contextLost=true;
                return false;
             }
        }
        function onContextLost(event){
            event.preventDefault();
            contextLost=true;
            console.warn('[KuranokaPageTransition] GPU context lost; '+
                'continuing with native navigation.');
         }
         }
         function dispose(){
         function spawnAtRate(dt,edge,seconds,strength){
             if(disposed)return;
             for(const type in rates){
            disposed=true;
                accum[type]+=rates[type]*dt*strength;
            cancelAnimationFrame(raf);
                const n=Math.min(11,Math.floor(accum[type]));
            surface.removeEventListener('webglcontextlost',onContextLost);
                accum[type]-=n;
            window.removeEventListener('pagehide',dispose);
                 for(let i=0;i<n;i++)spawn(type,edge,seconds);
            if(!contextLost){
                 try{
                    if(curtain)gl.deleteProgram(curtain.program);
                    if(sparks)gl.deleteProgram(sparks.program);
                }catch(_){}
             }
             }
            surface.remove();
         }
         }
         try{
         function advance(dt){
             gl=surface.getContext('webgl2',{
             for(let i=p.length-1;i>=0;i--) {
                 alpha:true,antialias:false,depth:false,stencil:false,
                 const q=p[i];
                // We intentionally render at ~30fps. Retain the last
                 q.left-=dt;
                 // frame between GPU submissions to avoid blank flashes.
                if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;}
                premultipliedAlpha:false,preserveDrawingBuffer:true,
                q.x+=q.vx*dt;
                powerPreference:mobile?'low-power':'high-performance'
                q.y+=q.vy*dt;
            });
                q.vx+=q.drift*dt*35;
            if(!gl){surface.remove();return null;}
                q.vy-=10*dt;
            curtain=program(backgroundVert,backgroundFrag);
                q.angle+=q.spin*dt;
            // No abstract, additive GPU spark pass: P3's fire requires
             }
            // textured smoke and incandescent streaks, not neon points.
            gl.disable(gl.DEPTH_TEST);
            gl.disable(gl.SCISSOR_TEST);
            gl.disable(gl.CULL_FACE);
            surface.addEventListener('webglcontextlost',onContextLost);
            window.addEventListener('pagehide',dispose,{once:true});
            window.__kkptRenderer='WebGL2';
        }catch(error){
            console.warn('[KuranokaPageTransition] WebGL unavailable. '+
                'Using Canvas 2D fallback.',error);
             dispose();
            return null;
         }
         }
         return {
         function spriteDraw(image,x,y,sx,sy,alpha){
             surface,
             ctx.globalAlpha=alpha;
             dispose,
             ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2);
            attachAtmosphere(draw){attachedAtmosphere=draw;},
        }
            play(duration){
        function drawSmoke(fade){
                return new Promise(resolve=>{
            ctx.globalCompositeOperation='source-over';
                    let started=null,lastDraw=-Infinity,lastClip=-Infinity;
            for(const q of p){
                    function tick(now){
                if(q.type!=='smoke'&&q.type!=='mist')continue;
                        if(started===null)started=now;
                const age=1-q.left/q.life;
                        if(disposed||contextLost){resolve();return;}
                const radius=q.radius*(1+age*.98);
                        const t=Math.min(1,(now-started)/duration);
                const alpha=(q.type==='smoke'?.76:.32)*
                        const clock=now/1000;
                    Math.min(1,age*5)*Math.min(1,q.left/.55)*fade;
                        const edge=makeEdge(t,clock,dark,innerWidth);
                spriteDraw(q.type==='smoke'?art.smoke:art.mist,
                        atmosphereEdge=edge;
                     q.x,q.y,radius*1.7,radius*1.3,alpha);
                        window.__kkptVisualDark=dark;
                        if(now-lastClip>=(mobile?35:25)||t>=1){
                            clipFrame(edge);
                            lastClip=now;
                        }
                        if(now-lastDraw>=(mobile?43:34)||t>=1){
                            if(!render(t,0,0,clock)){resolve();return;}
                            if(attachedAtmosphere){
                                attachedAtmosphere(clock,Math.min(t,.985),dark,
                                    (now-started)/1000,[]);
                            }
                            lastDraw=now;
                        }
                        if(t<1)raf=requestAnimationFrame(tick);
                        else resolve();
                    }
                    raf=requestAnimationFrame(tick);
                });
            },
            dissipate(duration){
                return new Promise(resolve=>{
                    let started=null,lastDraw=-Infinity;
                     function tick(now){
                        if(started===null)started=now;
                        if(disposed||contextLost){resolve();return;}
                        const t=Math.min(1,(now-started)/duration);
                        if(now-lastDraw>=(mobile?50:40)||t>=1){
                            if(!render(1,1,Math.pow(1-t,1.25),now/1000)){
                                resolve();return;
                            }
                            lastDraw=now;
                        }
                        if(t<1)raf=requestAnimationFrame(tick);
                        else resolve();
                    }
                    raf=requestAnimationFrame(tick);
                });
             }
             }
        };
            ctx.globalAlpha=1;
    }
        }
 
        function drawShards(fade) {
    // Canvas 2D fallback: deliberately retained for WebGL-disabled
            if(!dark)return;
    // browsers, software rendering, or shader compilation failures.
             ctx.save();
    function canvasFire(canvas,dark,duration,drawAtmosphere,done) {
             ctx.globalCompositeOperation='source-over';
        const ctx=dark&&canvas
            for(const q of p){
             ? canvas.getContext('2d',{alpha:true,desynchronized:true})
                if(q.type!=='shard')continue;
             : null;
                const age=1-q.left/q.life;
        if(dark&&!ctx){done();return;}
                const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade;
        const art=dark?makeSprites():null;
                ctx.save();
        const w=innerWidth,h=innerHeight;
                ctx.translate(q.x,q.y);
        if(ctx){
                ctx.rotate(q.angle);
            const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80);
                ctx.globalAlpha=a;
            canvas.width=Math.max(1,Math.round(w*scale));
                const r=q.radius;
            canvas.height=Math.max(1,Math.round(h*scale));
                ctx.beginPath();
             ctx.setTransform(scale,0,0,scale,0,0);
                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 rand=(a,b)=>a+Math.random()*(b-a);
            const waviness=Math.sin(Math.PI*t);
        const mobile=w<650;
            ctx.save();
        const maxCount=mobile?160:250;
            ctx.beginPath();
        const p=[];
            const count=Math.max(35,Math.ceil(h/19));
        const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0};
            for(let i=0;i<=count;i++){
        const rates=dark
                const y=h*i/count;
            ? {flame:mobile?80:105,ember:mobile?72:96,
                if(i===0)ctx.moveTo(edge(y),y);
              shard:mobile?26:42}
                else ctx.lineTo(edge(y),y);
            : {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50};
             }
        const started=performance.now();
             ctx.lineCap='round';
        let previous=started;
             ctx.lineJoin='round';
        let lastPaint=0;
             if(dark){
        let lastClip=0;
                ctx.globalCompositeOperation='lighter';
        function spawn(kind,edge,seconds) {
                ctx.globalAlpha=fade;
            if(p.length>=maxCount)return;
                ctx.shadowColor='#ff3903';
            const y=rand(-25,h+25);
                ctx.shadowBlur=25;
            const x=edge(y);
                 ctx.strokeStyle='rgba(255,55,5,.82)';
             const type=kind;
                 ctx.lineWidth=43;
             const smoke=type==='smoke';
                ctx.stroke();
             const shard=type==='shard';
                 ctx.shadowBlur=16;
             const ember=type==='ember'||type==='pollen';
                ctx.strokeStyle='#ff9b17';
            const blue=type==='sky'||type==='mist'||type==='pollen';
                 ctx.lineWidth=20;
            let radius,life,vx,vy;
                ctx.stroke();
            if(shard){
                ctx.shadowBlur=7;
                 radius=rand(4,18);life=rand(.6,1.65);
                ctx.strokeStyle='#ffe45e';
                 vx=rand(-130,65);vy=rand(-125,30);
                 ctx.lineWidth=9;
            }else if(smoke){
                 ctx.stroke();
                 radius=rand(32,85);life=rand(1.1,2.5);
                ctx.shadowBlur=4;
                 vx=rand(-20,65);vy=rand(-88,-22);
                ctx.strokeStyle='#fffcde';
            }else if(type==='mist'){
                 ctx.lineWidth=3.8;
                 radius=rand(45,100);life=rand(.9,1.8);
                 ctx.stroke();
                 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{
             }else{
                 radius=ember?rand(2.5,9):rand(18,47);
                 ctx.globalAlpha=fade*.64;
                 life=ember?rand(.45,1.1):rand(.35,.85);
                ctx.shadowColor='#8ccfff';
                 vx=rand(-95,75);vy=rand(-150,-25);
                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();
             }
             }
             p.push({
             ctx.restore();
                type,life,left:life,
 
                 x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64),
            // Dense GOLD / WHITE rolling fire, including on both sides
                 y:y+rand(-14,14),vx,vy,radius,
            // of the border. These are the luminous shapes from V2.
                angle:rand(-Math.PI,Math.PI),spin:rand(-5,5),
            if(dark){
                drift:rand(-.65,.65),blue
                 ctx.save();
            });
                ctx.globalCompositeOperation='lighter';
        }
                 for(let y=-70;y<h+80;y+=48){
        function spawnAtRate(dt,edge,seconds,strength){
                    const x=edge(y);
            for(const type in rates){
                    const pulse=.65+.35*Math.sin(seconds*8.5+y*.083);
                accum[type]+=rates[type]*dt*strength;
                    spriteDraw(art.flame,x+rand(-8,30),y,77,70,
                const n=Math.min(11,Math.floor(accum[type]));
                        fade*(.26+.16*pulse));
                accum[type]-=n;
                }
                for(let i=0;i<n;i++)spawn(type,edge,seconds);
                for(let y=-20;y<h+52;y+=36){
            }
                    const x=edge(y);
        }
                    const flicker=Math.sin(seconds*13+y*.19);
        function advance(dt){
                    const length=52+30*flicker+17*Math.sin(seconds*6-y*.11);
            for(let i=p.length-1;i>=0;i--) {
                    const lean=15+18*Math.sin(seconds*9+y*.069);
                const q=p[i];
                    ctx.globalAlpha=.62*fade;
                q.left-=dt;
                    ctx.fillStyle='#ff5705';
                if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;}
                    ctx.beginPath();
                q.x+=q.vx*dt;
                    ctx.moveTo(x+18,y+19);
                q.y+=q.vy*dt;
                    ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57,
                q.vx+=q.drift*dt*35;
                        x-lean,y-length);
                q.vy-=10*dt;
                    ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19);
                q.angle+=q.spin*dt;
                    ctx.fill();
            }
                    ctx.globalAlpha=.58*fade;
        }
                    ctx.fillStyle='#ffdb58';
        function spriteDraw(image,x,y,sx,sy,alpha){
                    ctx.beginPath();
            ctx.globalAlpha=alpha;
                    ctx.moveTo(x+7,y+8);
            ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2);
                    ctx.quadraticCurveTo(x-10,y-length*.33,
         }
                        x-lean*.8,y-length*.72);
         function drawSmoke(fade){
                    ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8);
             ctx.globalCompositeOperation='source-over';
                    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){
             for(const q of p){
                 if(q.type!=='smoke'&&q.type!=='mist')continue;
                 if(q.type==='smoke'||q.type==='mist'||q.type==='shard')continue;
                 const age=1-q.left/q.life;
                 const age=1-q.left/q.life;
                 const radius=q.radius*(1+age*.98);
                 const brightness=Math.min(1,q.left/.25)*(1-age*.25)*fade;
                 const alpha=(q.type==='smoke'?.76:.32)*
                 let artName='flame',sx=q.radius,sy=q.radius;
                    Math.min(1,age*5)*Math.min(1,q.left/.55)*fade;
                if(q.type==='ember'){artName='ember';sx*=1.2;sy*=1.2;}
                 spriteDraw(q.type==='smoke'?art.smoke:art.mist,
                else if(q.type==='pollen'){artName='sky';sx*=1.15;sy*=1.15;}
                    q.x,q.y,radius*1.7,radius*1.3,alpha);
                 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.globalAlpha=1;
            ctx.globalCompositeOperation='source-over';
         }
         }
         function drawShards(fade) {
         function paint(now) {
             if(!dark)return;
             const t=Math.min(1,(now-started)/duration);
            ctx.save();
            const seconds=now/1000;
            ctx.globalCompositeOperation='source-over';
            const edge=makeEdge(t,seconds,dark,w);
            for(const q of p){
            // Clip-path polygon reconstruction can cause expensive iframe
                if(q.type!=='shard')continue;
            // compositor work. It runs at a bounded rate independent of rAF.
                const age=1-q.left/q.life;
            atmosphereEdge=edge;
                const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade;
            window.__kkptVisualDark=dark;
                ctx.save();
            if(now-lastClip>=(mobile?33:23)||t>=1){
                ctx.translate(q.x,q.y);
                 clipFrame(edge);
                ctx.rotate(q.angle);
                 lastClip=now;
                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();
             if(now-lastPaint<(mobile?35:32) && t<1){
        }
                 requestAnimationFrame(paint);return;
        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';
             const dt=Math.min(.070,(now-previous)/1000);
             ctx.lineJoin='round';
            previous=now;lastPaint=now;
             const fade=Math.min(1,t/.055,(1-t)/.045);
            // Restore V14's broad smoke banks, long-lived char, pressure
            // waves and delicate blue leaves. V2 only paints the FIRE.
            drawAtmosphere(seconds,Math.min(t,.925),dark,(now-started)/1000,[]);
             if(dark){
             if(dark){
                 ctx.globalCompositeOperation='lighter';
                 advance(dt);
                 ctx.globalAlpha=fade;
                 if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade));
                ctx.shadowColor='#ff3903';
                 ctx.clearRect(0,0,w,h);
                ctx.shadowBlur=25;
                 drawShards(fade);
                ctx.strokeStyle='rgba(255,55,5,.82)';
                 drawCore(edge,seconds,t,fade);
                ctx.lineWidth=43;
                 drawBrightParticles(fade);
                 ctx.stroke();
             }
                 ctx.shadowBlur=16;
            if(t<1)requestAnimationFrame(paint);
                ctx.strokeStyle='#ff9b17';
            else done();
                ctx.lineWidth=20;
        }
                ctx.stroke();
        requestAnimationFrame(paint);
                 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
    function setDisplayed(nextActive) {
            // of the border. These are the luminous shapes from V2.
        active=nextActive;
            if(dark){
        frame.classList.toggle('kkpt-active',active);
                ctx.save();
        document.body.classList.toggle('kkpt-other-active',active);
                ctx.globalCompositeOperation='lighter';
        frame.style.clipPath=active?'inset(0)':hideClip;
                for(let y=-70;y<h+80;y+=48){
        frame.style.visibility=active?'visible':'hidden';
                    const x=edge(y);
        frame.style.pointerEvents='';
                    const pulse=.65+.35*Math.sin(seconds*8.5+y*.083);
        frame.tabIndex=active?0:-1;
                    spriteDraw(art.flame,x+rand(-8,30),y,77,70,
        frame.setAttribute('aria-hidden',active?'false':'true');
                        fade*(.26+.16*pulse));
        document.title=active?otherTitle:baseTitle;
                }
        showTheme((active?otherPage:basePage)==='Kuronata');
                for(let y=-20;y<h+52;y+=36){
        // Stop the invisible Kuronata ambience in either document.
                    const x=edge(y);
        root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active);
                    const flicker=Math.sin(seconds*13+y*.19);
        try{
                    const length=52+30*flicker+17*Math.sin(seconds*6-y*.11);
            frame.contentDocument?.documentElement.classList.toggle(
                    const lean=15+18*Math.sin(seconds*9+y*.069);
                'kkpt-frame-inactive',!active);
                    ctx.globalAlpha=.62*fade;
        }catch(_){}
                    ctx.fillStyle='#ff5705';
        updateButton();
                    ctx.beginPath();
    }
                    ctx.moveTo(x+18,y+19);
 
                    ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57,
    async function animateSwap(nextActive) {
                        x-lean,y-length);
        if(animating)return;
                    ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19);
        animating=true;
                    ctx.fill();
        button.classList.add('kkpt-busy');
                    ctx.globalAlpha=.58*fade;
        labelSub.textContent='PREPARING THE OTHER SIDE...';
                    ctx.fillStyle='#ffdb58';
        if(!loading)warmFrame();
                    ctx.beginPath();
        const ok=await Promise.race([loading,sleep(11000).then(()=>false)]);
                    ctx.moveTo(x+7,y+8);
        if(!ok||!ready){
                    ctx.quadraticCurveTo(x-10,y-length*.33,
            navigateRealPage(nextActive?otherUrl.href:baseUrl);return;
                        x-lean*.8,y-length*.72);
        }
                    ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8);
        // Finish the incremental prewarm before opening the curtain.
                    ctx.fill();
        await prewarmAtmosphere();
                    ctx.globalAlpha=.38*fade;
        frame.style.visibility='visible';
                    ctx.fillStyle='#fffced';
         frame.classList.add('kkpt-animating');
                    ctx.beginPath();
        // Give the now-visible destination its own first composited frame.
                    ctx.moveTo(x+3,y+5);
        await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
                    ctx.quadraticCurveTo(x-5,y-length*.2,
 
                        x-lean*.5,y-length*.48);
        const dark=(nextActive?otherPage:basePage)==='Kuronata';
                    ctx.quadraticCurveTo(x+4,y-length*.19,x+5,y+5);
        labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
                    ctx.fill();
        // When returning to the blue page, force the Day palette
                }
        // BEFORE unmasking it. The dark iframe still fully covers it.
                ctx.restore();
        if(!dark)showTheme(false);
            }else{
         frame.classList.remove('kkpt-active');
                ctx.save();
         frame.style.pointerEvents='none';
                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);
            // Clip-path polygon reconstruction can cause expensive iframe
            // compositor work. It runs at a bounded rate independent of rAF.
            atmosphereEdge=edge;
            window.__kkptVisualDark=dark;
            if(now-lastClip>=(mobile?33:23)||t>=1){
                clipFrame(edge);
                lastClip=now;
            }
            if(now-lastPaint<(mobile?35:32) && t<1){
                requestAnimationFrame(paint);return;
            }
            const dt=Math.min(.070,(now-previous)/1000);
            previous=now;lastPaint=now;
            const fade=Math.min(1,t/.055,(1-t)/.045);
            // Restore V14's broad smoke banks, long-lived char, pressure
            // waves and delicate blue leaves. V2 only paints the FIRE.
            drawAtmosphere(seconds,Math.min(t,.925),dark,(now-started)/1000,[]);
            if(dark){
                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.clipPath=active?'inset(0)':hideClip;
        frame.style.visibility=active?'visible':'hidden';
        frame.style.pointerEvents='';
        frame.tabIndex=active?0:-1;
        frame.setAttribute('aria-hidden',active?'false':'true');
        document.title=active?otherTitle:baseTitle;
        showTheme((active?otherPage:basePage)==='Kuronata');
        // Stop the invisible Kuronata ambience in either document.
        root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active);
        try{
            frame.contentDocument?.documentElement.classList.toggle(
                'kkpt-frame-inactive',!active);
        }catch(_){}
        updateButton();
    }


    async function animateSwap(nextActive) {
         const atmosphere=document.createElement('canvas');
         if(animating)return;
         atmosphere.id='kkpt-atmosphere';
        animating=true;
         atmosphere.setAttribute('aria-hidden','true');
        button.classList.add('kkpt-busy');
         document.body.append(atmosphere);
         labelSub.textContent='PREPARING THE OTHER SIDE...';
         const drawAtmosphere=createAtmosphere(atmosphere);
         if(!loading)warmFrame();
         const canvas=dark?document.createElement('canvas'):null;
         const ok=await Promise.race([loading,sleep(11000).then(()=>false)]);
         if(canvas){
        if(!ok||!ready){
             canvas.id='kkpt-vfx';
            navigateRealPage(nextActive?otherUrl.href:baseUrl);return;
             canvas.setAttribute('aria-hidden','true');
        }
             document.body.append(canvas);
         const dark=(nextActive?otherPage:basePage)==='Kuronata';
         }
        // GPU renderer is compiled while the destination is still hidden.
         const duration=dark?3450:3100;
        // Only the actual fallback needs 17 CPU sprite textures.
         transitionSound(dark,duration);
         const gpu=createWebGLCurtain(dark);
         await new Promise(resolve=>canvasFire(
        if(!gpu || dark){
            canvas,dark,duration,drawAtmosphere,resolve));
            if(!gpu)window.__kkptRenderer='Canvas2D';
            await prewarmAtmosphere();
        }
        let gpuAtmosphere=null;
         if(gpu&&dark){
             gpuAtmosphere=document.createElement('canvas');
            gpuAtmosphere.id='kkpt-atmosphere';
             gpuAtmosphere.setAttribute('aria-hidden','true');
             document.body.append(gpuAtmosphere);
            gpu.attachAtmosphere(createAtmosphere(gpuAtmosphere));
         }
         frame.style.visibility='visible';
         frame.classList.add('kkpt-animating');
        // Give the now-visible destination its own first composited frame.
         await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));


         labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
         setDisplayed(nextActive);
        // When returning to the blue page, force the Day palette
         if(canvas){
        // BEFORE unmasking it. The dark iframe still fully covers it.
             canvas.style.opacity='0';
        if(!dark)showTheme(false);
            setTimeout(()=>canvas.remove(),210);
        frame.classList.remove('kkpt-active');
        frame.style.pointerEvents='none';
        frame.style.clipPath=active?'inset(0)':hideClip;
 
        const duration=dark?3950:3150;
        transitionSound(dark,duration);
        const tail=dark?2150:1450;
        if(gpu){
            try{
                await gpu.play(duration);
                setDisplayed(nextActive);
                if(gpuAtmosphere){
                    gpuAtmosphere.style.transition='opacity '+tail+
                        'ms ease-out,transform '+tail+'ms ease-out';
                    void gpuAtmosphere.offsetWidth;
                    gpuAtmosphere.style.opacity='0';
                    gpuAtmosphere.style.transform=
                        'translate3d(0,-22px,0) scale(1.035)';
                }
                await gpu.dissipate(tail);
            }finally{
                gpu.dispose();
                gpuAtmosphere?.remove();
            }
         }else{
            // Fully working older Canvas renderer if WebGL2 is unavailable.
            const atmosphere=document.createElement('canvas');
            atmosphere.id='kkpt-atmosphere';
            atmosphere.setAttribute('aria-hidden','true');
            document.body.append(atmosphere);
            const drawAtmosphere=createAtmosphere(atmosphere);
            const canvas=dark?document.createElement('canvas'):null;
            if(canvas){
                canvas.id='kkpt-vfx';
                canvas.setAttribute('aria-hidden','true');
                document.body.append(canvas);
             }
            await new Promise(resolve=>canvasFire(
                canvas,dark,duration,drawAtmosphere,resolve));
            setDisplayed(nextActive);
            if(canvas){
                canvas.style.opacity='0';
                setTimeout(()=>canvas.remove(),210);
            }
            atmosphere.style.transition='opacity '+tail+
                'ms ease-out, transform '+tail+'ms ease-out';
            await new Promise(resolve=>requestAnimationFrame(resolve));
            atmosphere.style.opacity='0';
            atmosphere.style.transform='translate3d(0,-22px,0) scale(1.035)';
            await sleep(tail+70);
            atmosphere.remove();
         }
         }
        // The last frame holds living smoke and drifting fragments.
        // Let it disperse with a lightweight compositor fade AFTER the
        // destination is revealed; no costly extra canvas render loop.
        const tail=dark?2000:1320;
        atmosphere.style.transition='opacity '+tail+
            'ms ease-out, transform '+tail+'ms ease-out';
        await new Promise(resolve=>requestAnimationFrame(resolve));
        atmosphere.style.opacity='0';
        atmosphere.style.transform='translate3d(0,-22px,0) scale(1.035)';
        await sleep(tail+70);
        atmosphere.remove();
         animating=false;
         animating=false;
         button.classList.remove('kkpt-busy','kkpt-quiet');
         button.classList.remove('kkpt-busy','kkpt-quiet');
第2,480行: 第1,917行:
         }
         }
         animateSwap(nextActive).catch(err => {
         animateSwap(nextActive).catch(err => {
             console.error('[KuranokaPageTransition] V28:',err);
             console.error('[KuranokaPageTransition] V12:',err);
             document.getElementById('kkpt-vfx')?.remove();
             document.getElementById('kkpt-vfx')?.remove();
             document.getElementById('kkpt-atmosphere')?.remove();
             document.getElementById('kkpt-atmosphere')?.remove();
            document.getElementById('kkpt-webgl')?.remove();
             if (frame) frame.style.filter = '';
             if (frame) frame.style.filter = '';
             navigateRealPage(nextActive ? otherUrl.href : baseUrl);
             navigateRealPage(nextActive ? otherUrl.href : baseUrl);
第2,509行: 第1,945行:
         mountFateGear(document,otherPage);
         mountFateGear(document,otherPage);
         warmFrame();
         warmFrame();
         // WebGL2 has no CPU smoke atlas: defer the 2D fallback until needed.
         prewarmAtmosphere();
         preloadRebirthPad();
         preloadRebirthPad();


第2,537行: 第1,973行:
         document.getElementById('kkpt-vfx')?.remove();
         document.getElementById('kkpt-vfx')?.remove();
         document.getElementById('kkpt-atmosphere')?.remove();
         document.getElementById('kkpt-atmosphere')?.remove();
        document.getElementById('kkpt-webgl')?.remove();
         if(frame){
         if(frame){
             frame.style.filter='';
             frame.style.filter='';

2026年10月24日 (六) 11:10的版本

/* ============================================================
   Kuranoka / Kuronata — Page Transition V25
   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;

    // V24: the two article worlds have deliberate, deterministic palettes.
    // "os" is NOT a light theme: it can resolve to dark on the reader's
    // computer. Session storage may also be unavailable. Therefore, never
    // restore a stale "os" token when landing back on Kuranoka.
    const root=document.documentElement;
    const themeNames=['skin-theme-clientpref-day','skin-theme-clientpref-os',
        'skin-theme-clientpref-night','skin-theme-clientpref-black',
        'skin-citizen-light'];
    let wantsNight=basePage==='Kuronata';
    let changingTheme=false;
    function syncTheme(){
        if(changingTheme)return;
        const wanted=wantsNight?'skin-theme-clientpref-night':
            'skin-theme-clientpref-day';
        const shadow=wantsNight?'kkpt-kuronata-dark':null;
        const unexpected=themeNames.filter(c=>c!==wanted)
            .concat(wantsNight?[]:['kkpt-kuronata-dark']);
        if(root.classList.contains(wanted)&&
            (!shadow||root.classList.contains(shadow))&&
            unexpected.every(c=>!root.classList.contains(c))){
            return;
        }
        changingTheme=true;
        root.classList.remove(...themeNames,'kkpt-kuronata-dark');
        root.classList.add(wanted);
        if(shadow)root.classList.add(shadow);
        changingTheme=false;
    }
    function showTheme(dark){wantsNight=dark;syncTheme();}
    syncTheme();
    // Citizen's later initialization may reinstate the OS-preference class.
    // Keep the intended world palette without touching the user's global
    // account preference or other MediaWiki pages.
    new MutationObserver(syncTheme).observe(root,{
        attributes:true,attributeFilter:['class']
    });
    function navigateRealPage(href){
        window.location.assign(href);
    }

    // V17: the dark-world extras run in *both* the visible document and
    // its same-origin preview iframe. They never alter other Wiki pages.
    function installKuronataAfterglow() {
        if(basePage!=='Kuronata'||window.__kkptDarkExtrasReady)return;
        window.__kkptDarkExtrasReady=true;
        const prefersReduced=window.matchMedia('(prefers-reduced-motion: reduce)');
        // Multiline .heimu cannot use a single nowrap pseudo-element:
        // it piles all copied text onto line one. Project each rendered
        // line into two SHORT clipped chromatic slices only on hover.
        // Range measurements leave the original article DOM and wrapping
        // entirely untouched, including italic/link children.
        const spoilerOverlays=new WeakMap();
        const renderedSpoilerLines=(node)=>{
            const lines=[];
            const walk=document.createTreeWalker(node,NodeFilter.SHOW_TEXT);
            const range=document.createRange();
            let text,budget=0;
            while((text=walk.nextNode())&&budget<340){
                for(let i=0;i<text.length&&budget<340;i++,budget++){
                    const ch=text.nodeValue[i];
                    if(ch==='\n'||ch==='\r')continue;
                    range.setStart(text,i);
                    range.setEnd(text,i+1);
                    const r=range.getBoundingClientRect();
                    if(!r.width||!r.height)continue;
                    let line=lines.find(row=>Math.abs(row.top-r.top)<3);
                    if(!line){
                        line={top:r.top,left:r.left,right:r.right,
                            height:r.height,text:''};
                        lines.push(line);
                    }
                    line.left=Math.min(line.left,r.left);
                    line.right=Math.max(line.right,r.right);
                    line.height=Math.max(line.height,r.height);
                    line.text+=ch;
                }
            }
            range.detach?.();
            return lines.sort((a,b)=>a.top-b.top);
        };
        const spoilerFault=(node)=>{
            if(prefersReduced.matches)return;
            const rects=node.getClientRects();
            if(rects.length<2)return;
            const lines=renderedSpoilerLines(node);
            if(lines.length<2)return;
            node.setAttribute('data-kkpt-multiline','1');
            spoilerOverlays.get(node)?.remove();
            const overlay=document.createElement('div');
            overlay.className='kkpt-heimu-fault';
            overlay.setAttribute('aria-hidden','true');
            const font=getComputedStyle(node);
            for(const row of lines){
                const fragment=document.createElement('span');
                fragment.className='kkpt-heimu-fault-line';
                fragment.dataset.copy=row.text;
                fragment.style.left=row.left+'px';
                fragment.style.top=row.top+'px';
                fragment.style.width=(row.right-row.left+6)+'px';
                fragment.style.height=(row.height+4)+'px';
                fragment.style.fontFamily=font.fontFamily;
                fragment.style.fontSize=font.fontSize;
                fragment.style.fontWeight=font.fontWeight;
                fragment.style.fontStyle=font.fontStyle;
                fragment.style.lineHeight=font.lineHeight;
                fragment.style.letterSpacing=font.letterSpacing;
                overlay.append(fragment);
            }
            document.body.append(overlay);
            spoilerOverlays.set(node,overlay);
        };
        const clearSpoilerFault=(node)=>{
            const overlay=spoilerOverlays.get(node);
            if(!overlay)return;
            spoilerOverlays.delete(node);
            overlay.classList.add('kkpt-fault-away');
            setTimeout(()=>overlay.remove(),210);
        };
        const decorateSpoilers=()=>{
            document.querySelectorAll('span.heimu').forEach(node=>{
                if(node.dataset.kkptBound)return;
                node.dataset.kkptBound='1';
                const label=(node.textContent||'').replace(/\s+/g,' ').trim();
                if(label&&label.length<=240&&!node.children.length)
                    node.setAttribute('data-kkpt-glitch',label);
                node.addEventListener('mouseenter',()=>spoilerFault(node));
                node.addEventListener('mouseleave',()=>clearSpoilerFault(node));
            });
        };
        const decorateLinks=()=>{
            const article=document.querySelector('#mw-content-text,.mw-parser-output');
            if(!article)return;
            article.querySelectorAll('a[href]').forEach(link=>{
                if(link.dataset.kkptLinkReady ||
                    link.classList.contains('kkpt-fate-gear') ||
                    link.querySelector('img,svg,video'))return;
                link.dataset.kkptLinkReady='1';
                link.classList.add('kkpt-link-glitch');
                try{
                    const url=new URL(link.href,document.baseURI);
                    const path=url.pathname.match(/\/wiki\/([^/?#]+)/);
                    const name=url.searchParams.get('title')||
                        (path?decodeURIComponent(path[1]):'');
                    if(url.origin===location.origin &&
                        name.replace(/_/g,' ')==='Kuranoka'){
                        link.classList.add('kkpt-link-origin');
                        // Copy a link only if it truly occupies one
                        // rendered line. Multiline links keep real text.
                        const label=(link.textContent||'').trim();
                        if(!link.children.length &&
                           link.getClientRects().length===1 &&
                           label.length>0&&label.length<100)
                            link.dataset.kkptLinkCopy=label;
                    }
                }catch(_){}
            });
        };
        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='25.0';

    // Easter eggs + Infobox gear are the default. No floating pill.
    const SHOW_FLOATING_BUTTON=false;
    const otherPage=basePage==='Kuranoka'?'Kuronata':'Kuranoka';
    const baseUrl=location.href;
    const baseTitle=document.title;
    const otherUrl=new URL('/wiki/'+encodeURIComponent(otherPage),location.href);
    const clipSide=basePage==='Kuranoka'?'right':'left';
    const hideClip=clipSide==='right'?'inset(0 0 0 100%)':'inset(0 100% 0 0)';
    const sleep=ms=>new Promise(r=>setTimeout(r,ms));
    const smooth=t=>t*t*(3-2*t);
    const imagePool=new Map();
    // V24: preload both original background photographs. The target
    // image is decoded while its real-document iframe is preparing,
    // reducing flashes of the bare background-color at navigation.
    const worldBackdrops={
        Kuranoka:'https://blockland.wiki/images/0/05/%E7%A7%AF%E9%9B%A8%E4%BA%911.png',
        Kuronata:'https://blockland.wiki/images/a/ab/%E9%BB%84%E6%98%8F%EF%BC%8C%E9%BB%91%E4%BA%91.png'
    };
    function primeWorldBackdrops(){
        const current=worldBackdrops[basePage];
        if(current&&document.head){
            const link=document.createElement('link');
            link.rel='preload';
            link.as='image';
            link.href=current;
            link.setAttribute('fetchpriority','high');
            link.dataset.kkptBackgroundPreload='1';
            document.head.appendChild(link);
            warmImage(current).catch(()=>{});
        }
        // Give the current photo first priority; then prepare the next
        // world so the iframe and the eventual real page can reuse it.
        setTimeout(()=>{
            const next=worldBackdrops[otherPage];
            if(next)warmImage(next).catch(()=>{});
        },300);
    }
    primeWorldBackdrops();

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

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

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

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

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

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

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

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

    function wireFrame() {
        try {
            const doc = frame.contentDocument;
            if (!doc) return;
            otherTitle = doc.title || otherTitle;
            // The preview exists only for the animated reveal. Pausing
            // its hidden ambience avoids doubling ongoing render work.
            doc.documentElement.classList.add('kkpt-frame-inactive');
            watchPortalLinks(doc, basePage);
            mountFateGear(doc, basePage);
            if (doc.__kkptFrameBridge) return;
            doc.__kkptFrameBridge = true;

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

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

    function warmFrame() {
        if (loading) return loading;
        frame = document.createElement('iframe');
        frame.id = 'kkpt-preview';
        frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop
        frame.title = otherPage + ' 预载页面';
        frame.tabIndex = -1;
        frame.setAttribute('aria-hidden', 'true');
        frame.setAttribute('loading', 'eager');
        frame.style.clipPath = hideClip;
        // Load all resources in advance, but don't composite the hidden
        // full-screen Wiki until its first transition frame.
        frame.style.visibility = 'hidden';

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

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


    // Shared, low-resolution atmospherics: return of V14's dark smoke,
    // long-lived torn paper, shockwaves, blue-white petals and pollen.
    // Only the combustion CURTAIN is replaced by the bright V2 engine.
    let atmosphereAssetCache=null;
    const atmosphereNoiseCache=new Map();
    function createAtmosphere(canvas,buildOneOnly=false) {
        const ctx = canvas.getContext('2d', {alpha:true});
        const rand = n => {
            const cached=atmosphereNoiseCache.get(n);
            if(cached!==undefined)return cached;
            const x = Math.sin(n*127.1+311.7)*43758.5453123;
            const value=x-Math.floor(x);
            atmosphereNoiseCache.set(n,value);
            return value;
        };
        const clamp01 = n => Math.max(0,Math.min(1,n));
        function valueNoise(x,y,seed) {
            const ix=Math.floor(x), iy=Math.floor(y);
            let fx=x-ix,fy=y-iy;
            fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
            const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
            const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
            const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
            return a*(1-fy)+b*fy;
        }
        function smokeTexture(seed,dark) {
            const tex=document.createElement('canvas');
            tex.width=tex.height=112;
            const g=tex.getContext('2d');
            const data=g.createImageData(112,112);
            for(let y=0;y<112;y++) for(let x=0;x<112;x++){
                const u=(x-56)/56,v=(y-56)/56;
                const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
                const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
                const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
                const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
                const envelope=clamp01((1.05-radius)/.56);
                const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
                const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
                const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
                const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
                    envelope*envelope;
                const i=(y*112+x)*4;
                const variation=low*28+high*15;
                if(dark){
                    data.data[i]=5+variation*.37;
                    data.data[i+1]=5+variation*.33;
                    data.data[i+2]=8+variation*.38;
                }else{
                    data.data[i]=44+variation;
                    data.data[i+1]=91+variation;
                    data.data[i+2]=125+variation;
                }
                data.data[i+3]=Math.round(240*density);
            }
            g.putImageData(data,0,0);
            return tex;
        }
        // V2-inspired white-hot additive sprites, but with noise-eroded
        // silhouettes instead of repetitive smooth, elliptical balls.
        function glowTexture(seed,mode) {
            const tex=document.createElement('canvas');
            tex.width=96;tex.height=128;
            const g=tex.getContext('2d');
            const gradient=g.createRadialGradient(48,68,0,48,68,64);
            const colors=mode==='fire'
                ? [[0,'#fffef1'],[.14,'#fff4bb'],[.34,'#ffd04d'],
                   [.55,'#ff841bcc'],[.80,'#bc290749'],[1,'#6e030000']]
                : mode==='blue'
                ? [[0,'#ffffff'],[.19,'#f1ffff'],[.40,'#a2efff'],
                   [.68,'#5298ff99'],[1,'#345bf000']]
                : [[0,'#ffffff'],[.19,'#fff8cd'],[.49,'#ff9b28cc'],
                   [1,'#ff380000']];
            colors.forEach(([p,c])=>gradient.addColorStop(p,c));
            g.fillStyle=gradient;g.fillRect(0,0,96,128);
            const img=g.getImageData(0,0,96,128);
            for(let y=0;y<128;y++)for(let x=0;x<96;x++){
                const u=(x-48)/48,v=(y-64)/64;
                const cloud=valueNoise(u*4+seed*1.9,v*4+seed*.7,seed+18);
                const fiber=valueNoise(u*11+3,v*8+17,seed+41);
                const shifted=u+.18*(cloud-.5);
                const irregular=clamp01((1.15-Math.abs(shifted))/.52)*
                    clamp01((1.05-Math.abs(v+.12*(fiber-.5)))/.42);
                const n=.59+.77*cloud+.28*fiber;
                const alphaIndex=(y*96+x)*4+3;
                img.data[alphaIndex]=Math.round(img.data[alphaIndex]*
                    irregular*clamp01(n));
            }
            g.putImageData(img,0,0);
            return tex;
        }
        // V25: the profiler found one 270ms long task here. Build the
        // EXACT SAME 17 textures in separate tasks, never all at once.
        // The stable noise cache avoids repeating the costly sin hash.
        const cache=atmosphereAssetCache ||= {
            sootSprites:[],mistSprites:[],FIRE_GLOW:[],BLUE_GLOW:[],
            EMBER_GLOW:null
        };
        function buildNextAsset(){
            let n=cache.sootSprites.length;
            if(n<4){cache.sootSprites.push(smokeTexture(n+17,true));return true;}
            n=cache.mistSprites.length;
            if(n<4){cache.mistSprites.push(smokeTexture(n+51,false));return true;}
            n=cache.FIRE_GLOW.length;
            if(n<4){cache.FIRE_GLOW.push(glowTexture(n+1,'fire'));return true;}
            n=cache.BLUE_GLOW.length;
            if(n<4){cache.BLUE_GLOW.push(glowTexture(n+8,'blue'));return true;}
            if(!cache.EMBER_GLOW){
                cache.EMBER_GLOW=glowTexture(21,'ember');return true;
            }
            return false;
        }
        if(buildOneOnly)return buildNextAsset();
        // Normally prewarming is already complete before a user clicks.
        // The fallback also works if an animation is started immediately.
        while(buildNextAsset()){}
        const {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW}=cache;
        const combustion=[];
        const residualSoot=[];
        const tornPaper=[];
        let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0;
        function advanceCombustion(seconds,progress,dark,elapsed,w,h) {
            const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed));
            lastElapsed=elapsed;
            const live=progress>.01&&progress<.985;
            if(live){
                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();}
        };
    }

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

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

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

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

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

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

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

    async function animateSwap(nextActive) {
        if(animating)return;
        animating=true;
        button.classList.add('kkpt-busy');
        labelSub.textContent='PREPARING THE OTHER SIDE...';
        if(!loading)warmFrame();
        const ok=await Promise.race([loading,sleep(11000).then(()=>false)]);
        if(!ok||!ready){
            navigateRealPage(nextActive?otherUrl.href:baseUrl);return;
        }
        // Finish the incremental prewarm before opening the curtain.
        await prewarmAtmosphere();
        frame.style.visibility='visible';
        frame.classList.add('kkpt-animating');
        // Give the now-visible destination its own first composited frame.
        await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));

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

        const atmosphere=document.createElement('canvas');
        atmosphere.id='kkpt-atmosphere';
        atmosphere.setAttribute('aria-hidden','true');
        document.body.append(atmosphere);
        const drawAtmosphere=createAtmosphere(atmosphere);
        const canvas=dark?document.createElement('canvas'):null;
        if(canvas){
            canvas.id='kkpt-vfx';
            canvas.setAttribute('aria-hidden','true');
            document.body.append(canvas);
        }
        const 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.
        navigateRealPage(otherUrl.href);
    }

    function requestSwap(nextActive) {
        if (animating || nextActive === active) return;
        if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
                navigateRealPage(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 = '';
            navigateRealPage(nextActive ? otherUrl.href : baseUrl);
        });
    }

    function createButton() {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
        button=document.createElement('a');
        button.id='kkpt-toggle';
        labelIcon=document.createElement('span');
        labelIcon.className='kkpt-icon';
        labelIcon.setAttribute('aria-hidden','true');
        const text=document.createElement('span');
        text.className='kkpt-text';
        labelTitle=document.createElement('span');
        labelSub=document.createElement('span');
        labelSub.className='kkpt-sub';
        text.append(labelTitle,labelSub);
        button.append(labelIcon,text);
        document.body.append(button);
        button.hidden=!SHOW_FLOATING_BUTTON;
        updateButton();
        watchPortalLinks(document, otherPage);
        mountFateGear(document,otherPage);
        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);
            frame.classList.remove('kkpt-animating');
        }
        button?.classList.remove('kkpt-busy','kkpt-quiet');
    });

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