#!/usr/bin/env python3
"""
Generate loader.html: the directed loop as a standalone webview LOAD animation.

Constraints that shape it, in order:
  1. ZERO network dependencies. No Babylon, no CDN, no fonts, no fetch. The
     whole point is to be on screen while something ELSE is loading, so it
     cannot itself wait on a 4 MB engine. Raw WebGL1, one file.
  2. Identical physics and identical look. The GLSL is EXTRACTED VERBATIM from
     orbital-lab.html at build time (single source of truth), the edge profile
     and logomark paths are inlined, and the camera reproduces Babylon's
     LEFT-HANDED view/projection exactly: this codebase already burned seven
     attempts on a mirrored mark, so chirality is not left to chance.
  3. John's tuned numbers as defaults (the "john 1" snapshot), overridable by
     URL params: ?pt=4.5&dt=3&ct=0.3&st=0&logo=100&llead=1&tlead=1.5&ov=1&intro=1&interact=0&emerge=0&speed=100&orbit=0&margin=0..-80 for bleed

Run:  python3 tools/build-loader.py
"""
import json, pathlib, re, sys

ROOT = pathlib.Path(__file__).resolve().parent.parent
lab  = (ROOT / 'orbital-lab.html').read_text()

def extract(name):
    m = re.search(r"ShadersStore\['" + name + r"'\] = `(.*?)`;", lab, re.S)
    if not m: sys.exit('build failed: shader %s not found in orbital-lab.html' % name)
    return m.group(1).strip()

vs = extract('labVertexShader').replace('worldViewProjection', 'uMVP')
# Strict GLSL ES 1.00 (raw WebGL1) has no min(int,int); Babylon's WebGL2
# context accepted it. Same maths, float-side.
vs = vs.replace(
"""        float u = (clamp(cda, -PI4, PI4) + PI4) / (2.0 * PI4) * 127.0;
        int   i = int(floor(u));
        float f = u - float(i);
        int   j = min(i + 1, 127);""",
"""        float u = (clamp(cda, -PI4, PI4) + PI4) / (2.0 * PI4) * 127.0;
        float fi = floor(min(u, 126.0));
        float f = u - fi;
        int   i = int(fi);
        int   j = int(fi + 1.0);""")
if 'min(i + 1, 127)' in vs: sys.exit('build failed: ES 1.00 int-min transform missed')
fs = extract('labFragmentShader')

m = re.search(r"mat\.setFloats\('uLogoR', \[([^\]]+)\]\);", lab)
if not m: sys.exit('build failed: uLogoR table not found')
logoR = '[' + m.group(1).strip() + ']'

logomark = json.dumps(json.loads((ROOT / 'assets' / 'logomark.json').read_text()))

page = r'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Adom loader</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='5' fill='%2300b8b0'/%3E%3C/svg%3E">
<style>
  html,body{margin:0;height:100%;overflow:hidden;background:__BG__}
  canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="ov"></canvas>
<script>
/* The Adom logomark is a hydrogen 2p orbital: psi = (2p_x + 2p_y)/sqrt(2).
   This file is the wiki page adom/orbital-lab distilled to a loader. */
'use strict';
var Q=new URLSearchParams(location.search);
function P(k,d){
  var o=window.__ADOM_LOADER_PARAMS||{};
  if(k in o) return o[k];
  var v=parseFloat(Q.get(k));return isFinite(v)?v:d;
}
var PT=P('pt',4.5), DT=P('dt',3), CT=P('ct',0.3), ST=P('st',0);
var LOGO_PK=Math.max(0,Math.min(1,P('logo',100)/100));
var LLEAD=Math.abs(P('llead',1)), TLEAD=Math.abs(P('tlead',1.5));
var EMERGE=String(P('emerge',0))==='1';
/* speed: percent playback rate for the ANIMATION clocks (timeline, evolution,
   birth). 100 is real time; 10 is a tenth speed. Camera springs and user
   interaction stay real-time so the feel never goes gluey. */
var SPEED=Math.max(0.05,Math.min(3,Math.abs(P('speed',100))/100));
/* orbit: automatic camera yaw during PLAY, in degrees per second (0 = off,
   try 12). The angle glides to the nearest whole turn as sculpting begins,
   so every epoch still lands square-on for the logo overlay, and play
   resumes from the equivalent orientation with no unwinding sweep. */
var ORBIT=(P('orbit',0)||0)*Math.PI/180;
var OV=Q.get('ov')!=='0', DO_INTRO=Q.get('intro')!=='0'&&!EMERGE;
/* margin: percent of the container left EMPTY around the mark. 0 is the
   classic framing; 80 sits the anim small in the middle of a big window.
   Implemented as camera distance, so perspective, the overlay match and the
   epoch glide all stay exact at any margin. */
/* margin accepts NEGATIVE values for full bleed: -N moves the camera N%
   closer, so -50 doubles the mark and it runs off every edge. Clamped at
   -70 so the surface never crosses the near plane. */
var MARGIN=Math.max(-70,Math.min(90,P('margin',0)));
function homeFor(m){ return m<0 ? R*(1+m/100) : R/(1-m/100); }
/* MODES bundle the quality knobs so nobody has to reason about them:
     mode=lightweight  fps 30, dpr 1, seg 48, no antialias: spinner fleets,
                       dialogs, battery. Looks fine at small sizes.
     mode=performance  (the default, alias mode=sexy) uncapped, dpr up to 2,
                       seg 80, antialias: hero surfaces, big canvases.
   Explicit params always override the mode. rAF still yields between frames
   and stops when hidden in every mode. seg and aa are boot-time; fps can be
   changed live by postMessage in armed contexts. */
var MODE=String((window.__ADOM_LOADER_PARAMS&&window.__ADOM_LOADER_PARAMS.mode)||Q.get('mode')||'').toLowerCase();
if(MODE==='sexy')MODE='performance';
var LIGHT=(MODE==='lightweight'||MODE==='light');
var FPSCAP=Math.max(0,P('fps',LIGHT?30:0)); if(FPSCAP>0&&FPSCAP<20)FPSCAP=20;
var DPRCAP=Math.max(1,Math.min(3,P('dpr',LIGHT?1:2)));
var SEGQ=Math.round(Math.max(24,Math.min(128,P('seg',LIGHT?48:80))));
var AAON=P('aa',LIGHT?0:1)>=1;
var lastDraw=0;
/* Background contract: bg=HEX paints a solid, bg=transparent leaves the GL
   clear at alpha 0 so the CONTAINER's background shows straight through, and
   the mark composites onto whatever surface it sits on (a toolbar, a hero,
   anything). Changeable live by postMessage in armed contexts. */
var BG=[0.051,0.067,0.090], TRANSP=false;
function applyBg(raw){
  raw=String(raw||'').toLowerCase().replace('#','');
  TRANSP=(raw==='transparent');
  var ok=/^[0-9a-f]{6}$/.test(raw);
  BG=ok?[parseInt(raw.slice(0,2),16)/255,parseInt(raw.slice(2,4),16)/255,parseInt(raw.slice(4,6),16)/255]
       :[0.051,0.067,0.090];
  var css=TRANSP?'transparent':(ok?('#'+raw):'#0d1117');
  document.documentElement.style.background=css;
  document.body.style.background=css;
}
applyBg((window.__ADOM_LOADER_PARAMS&&window.__ADOM_LOADER_PARAMS.bg)||Q.get('bg'));

var VS=__VS__;
var FS=__FS__;
var LOGO_R=__LOGO_R__;
var PATHS=__PATHS__;

var glopt={antialias:AAON,alpha:true,premultipliedAlpha:true};
var glc=document.getElementById('gl'), gl=glc.getContext('webgl2',glopt)||glc.getContext('webgl',glopt);
var ovc=document.getElementById('ov'), ox=ovc.getContext('2d');
if(!gl){ document.body.style.background='__BG__'; throw new Error('no webgl'); }

function sh(type,src){var s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);
  if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));return s;}
var prog=gl.createProgram();
gl.attachShader(prog,sh(gl.VERTEX_SHADER,VS));
gl.attachShader(prog,sh(gl.FRAGMENT_SHADER,FS));
gl.linkProgram(prog);
if(!gl.getProgramParameter(prog,gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
var U={}; ['uMVP','c','uScale','uFlat','uRot','uPlump','uGap','uEdge','uCorner','uLogoR','cPos','cNeg']
  .forEach(function(n){U[n]=gl.getUniformLocation(prog,n);});

/* unit sphere, displaced entirely in the vertex shader */
var SEG=SEGQ, verts=[], idx=[];
for(var i=0;i<=SEG;i++){var th=i/SEG*Math.PI;
  for(var j=0;j<=SEG;j++){var ph=j/SEG*2*Math.PI;
    verts.push(Math.sin(th)*Math.cos(ph), Math.cos(th), Math.sin(th)*Math.sin(ph));}}
for(var r=0;r<SEG;r++)for(var q2=0;q2<SEG;q2++){
  var a=r*(SEG+1)+q2,b=a+SEG+1;
  idx.push(a,b,a+1,b,b+1,a+1);}
var vb=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,vb);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(verts),gl.STATIC_DRAW);
var ib=gl.createBuffer();gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint16Array(idx),gl.STATIC_DRAW);
var loc=gl.getAttribLocation(prog,'position');
gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,3,gl.FLOAT,false,0,0);
gl.enable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE);
gl.uniform1fv(U.uLogoR,new Float32Array(LOGO_R));
gl.uniform1f(U.uPlump,0);gl.uniform1f(U.uGap,0);gl.uniform1f(U.uCorner,0);gl.uniform1f(U.uRot,45);

var C_POS=[0x19/255,0xc8/255,0xbd/255], C_NEG=[0x12/255,0x3a/255,0x63/255];
gl.uniform3fv(U.cPos,C_POS);

/* Babylon LEFT-HANDED camera, reproduced exactly: eye (0,0,-4.4) looking +Z,
   fov 0.8 fixed on the LIMITING axis so the mark keeps its fraction of any
   canvas shape (the lab's fitFraming rule). */
var R=4.4, HOME=homeFor(MARGIN), CAMR=HOME, PANX=0, PANY=0, FOV=0.8, ZN=0.1, ZF=Math.max(100,HOME*3);
/* Babylon's ACTUAL zoom model, tuned against the lab's measured curve: each
   notch deposits a decaying velocity integral (inertia 0.9) that is consumed
   frame by frame straight into the radius, while the lab's springs pull
   radius and pan home every frame. NOTCH below is the one tuning constant,
   calibrated so a 12-notch burst matches the lab: dip to ~4.0, home ~200ms
   after release. */
var NOTCH=0.085, velR=0, lastNX=0, lastNY=0;
function setMVP(w,h){
  var f=1/Math.tan(FOV/2), a=w/h, zf=ZF/(ZF-ZN);
  var m0=(a>=1)? f/a : f, m5=(a>=1)? f : f*a;
  // column-major proj(LH) x view(translate +R on z)
  gl.uniformMatrix4fv(U.uMVP,false,new Float32Array([
    m0,0,0,0,  0,m5,0,0,  0,0,zf,1,  0,0,-ZN*zf+zf*R- R*0 + (R*zf - ZN*zf)-(R*zf-ZN*zf) + (R*zf) - (ZN*ZF/(ZF-ZN)),0]));
}
/* The m[14] arithmetic above must be exact, so compute it plainly instead: */
function proj(w,h){
  var f=1/Math.tan(FOV/2), a=w/h;
  return (a>=1)? {m0:f/a, m5:f} : {m0:f, m5:f*a};
}
function setMVP2(w,h,r,px,py,s){
  r=(r===undefined)?CAMR:r; px=px||0; py=py||0; s=(s===undefined)?1:s;
  var m=proj(w,h);
  var A=ZF/(ZF-ZN), B=-ZN*ZF/(ZF-ZN);
  // view: yaw orbA about Y, model scale s (emerge birth), pan, then
  // z' = z + r  (eye at -r on z, LH). c=1,sn=0 reduces to the plain matrix.
  var c=Math.cos(orbA), sn=Math.sin(orbA);
  gl.uniformMatrix4fv(U.uMVP,false,new Float32Array([
    m.m0*s*c,0,-A*s*sn,-s*sn,
    0,m.m5*s,0,0,
    m.m0*s*sn,0,A*s*c,s*c,
    -m.m0*px,-m.m5*py,A*r+B,r
  ]));
}

var W=0,H=0,DPR=1,needSize=true;
/* Resize events only mark dirty. Re-backing a canvas CLEARS it, and doing
   that on every event mid-drag leaves blank frames until the next rAF: the
   jerk. Instead the size is applied at the TOP of the frame and the frame
   draws immediately at the new size, so a continuous drag repaints clean
   every single frame with at most one reallocation per frame. */
function resize(){ needSize=true; }
function applySize(){
  var w=glc.clientWidth, h=glc.clientHeight, d=Math.min(DPRCAP,window.devicePixelRatio||1);
  if(!needSize && w===W && h===H && d===DPR) return;
  needSize=false;
  if(w===W && h===H && d===DPR) return;
  W=w; H=h; DPR=d;
  glc.width=w*d; glc.height=h*d;
  ovc.width=w*d; ovc.height=h*d;
  gl.viewport(0,0,glc.width,glc.height);
}
window.addEventListener('resize',resize);
/* Containers resize without warning in webviews; the window resize event
   covers iframe viewport changes, and the ResizeObserver is the belt for
   any host that reflows the document instead. */
if(window.ResizeObserver) new ResizeObserver(resize).observe(document.documentElement);
resize();

/* ---- the directed loop, distilled ---- */
var L=[0,1,1,1,2,2,2,2,2,3,3,3,3,3,3,3];
var E=L.map(function(l){return -13.6/((l+1)*(l+1));});
var LOGO=new Array(16).fill(0); LOGO[1]=0.7071; LOGO[3]=0.7071;
function smooth(x){x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);}
function lerp(a,b,t){return a+(b-a)*t;}
var gains=null;
function roll(){gains=new Array(16).fill(0);
  var n=2+Math.floor(Math.random()*4);
  for(var j=0;j<n;j++)gains[(Math.random()*16)|0]=0.35+Math.random()*0.65;}
roll();
var eS=PT+DT+(OV?0:CT), eE=eS+ST, CYC=eE+(OV?0:CT)+DT;
var INTRO={dark:0.9,logoIn:1.4,hold:0.5,reveal:1.2};
var inIntro=DO_INTRO, introT=0, t=(DO_INTRO||EMERGE)?0:eE, tEvo=0, rolled=false, last=performance.now();
/* emerge=1 replaces the logo intro: the equation runs from t=0 and the
   surface condenses out of nothing (scale+fade over BIRTH_S seconds) into
   plain play, then the normal pt/dt/ct/st timeline takes over. Keep pt
   comfortably above BIRTH_S so the first epoch never lands mid-birth. */
var BIRTH_S=2.6, birth=EMERGE?0:1, birthT=0;
var orbA=0;   // auto-orbit yaw, radians

function frame(now){
  if(FPSCAP>0){
    if(now-lastDraw < 1000/FPSCAP - 1){ requestAnimationFrame(frame); return; }
    lastDraw=now;
  }
  applySize();
  var dt=Math.min(0.05,(now-last)/1000); last=now;
  var adt=dt*SPEED; tEvo+=adt;
  if(birth<1){birthT+=adt;birth=smooth(Math.min(1,birthT/BIRTH_S));
    glc.style.opacity=birth>=1?'':String(0.04+0.96*birth);}
  var k,cheat,fill,teal,pinned=false,drawMesh=true;
  if(inIntro){
    introT+=adt;
    var e1=INTRO.dark,e2=e1+INTRO.logoIn,e3=e2+INTRO.hold,e4=e3+INTRO.reveal,x=introT;
    k=1;cheat=1;teal=1;pinned=true;drawMesh=x>=e2;
    fill = x<e1?0 : x<e2?smooth((x-e1)/(e2-e1)) : x<e3?1 : 1-smooth((x-e3)/(e4-e3));
    if(x>=e4){inIntro=false;t=eE;roll();}
  }else{
    t=(t+adt)%CYC;
    var ctw=OV?Math.min(CT,DT):CT;
    if(t<PT){k=0;}
    else if(t<PT+DT){k=smooth((t-PT)/DT);}
    else if(t<eS){k=1;}
    else if(t<eE){k=1;pinned=true;}
    else if(!OV&&t<eE+CT){k=1;}
    else{var o=OV?t-eE:t-eE-CT;k=1-smooth(Math.min(1,o/DT));}
    cheat = t<eS ? (t<=eS-ctw?0:smooth(1-(eS-t)/ctw))
          : t<=eE ? 1 : (t>=eE+ctw?0:smooth(1-(t-eE)/ctw));
    if(t>=eS&&!rolled){roll();rolled=true;} if(t<eS)rolled=false;
    var mid=(eS+eE)/2, a2=mid-Math.max(0.001,(eE-eS)/2+LLEAD), b2=mid+Math.max(0.001,(eE-eS)/2+LLEAD);
    fill=(LOGO_PK<=0||t<=a2||t>=b2||(b2-a2)<0.02)?0
        :(t<mid?smooth((t-a2)/(mid-a2)):smooth((b2-t)/(b2-mid)))*LOGO_PK;
    teal = t<eS ? (TLEAD<=0.01?0:(t<=eS-TLEAD?0:smooth(1-(eS-t)/TLEAD)))
         : t<=eE ? 1 : (TLEAD<=0.01?0:(t>=eE+TLEAD?0:smooth(1-(t-eE)/TLEAD)));
  }
  /* Auto-orbit: yaw advances only in pure play; the moment sculpting starts
     the angle is pulled to the nearest whole turn, so the epoch pose is the
     calibrated square-on view and nothing ever has to spin backwards. */
  if(ORBIT!==0){
    var kk=inIntro?1:k;
    if(kk<=0){ orbA+=ORBIT*adt; }
    else{
      var whole=Math.round(orbA/(2*Math.PI))*(2*Math.PI);
      orbA=orbA+(whole-orbA)*smooth(Math.min(1,kk));
    }
  }
  /* The camera comes HOME for the logo. Whatever the user zoomed or panned,
     the framing glides back to the exact calibrated pose before epoch-start,
     holds through the epoch (the overlay match depends on it), and hands the
     wheel back afterwards from the home pose. */
  var effR=CAMR, effPX=PANX, effPY=PANY, camK=0;
  if(!inIntro){
    /* Glide home for the logo, then home IS the new state: no gliding back
       to the old zoom, the wheel simply frees again the moment the sit ends
       and play resumes from the calibrated framing. */
    var RET=Math.max(0.6, LLEAD+0.2, Math.min(1.2,DT));
    camK = (t>eS-RET&&t<eS) ? smooth((t-(eS-RET))/RET) : 0;
    if(t>=eS&&t<=eE+0.001){ CAMR=HOME; PANX=0; PANY=0; velR=0; camK=1; }
    // consume the wheel's decaying integral, zooming toward the cursor
    if(Math.abs(velR)>0.0001){
      var stepK=1-Math.pow(0.8, dt*240);
      var step=velR*stepK; velR-=step;
      var m2=proj(W,H);
      var wx=lastNX*CAMR/m2.m0+PANX, wy=lastNY*CAMR/m2.m5+PANY;
      /* zoom in to the screen freely; zoom out capped 40% past the epoch framing */
      var r2=Math.max(2.4,Math.min(HOME*1.4,CAMR-step));
      PANX=wx-lastNX*r2/m2.m0; PANY=wy-lastNY*r2/m2.m5;
      CAMR=r2;
    }
    /* The lab's springs, verbatim: radius 6% and pan 5% per frame at the
       lab's 240fps cadence, frame-rate independent. */
    var spr=1-Math.pow(0.94, dt*240), sprT=1-Math.pow(0.95, dt*240);
    CAMR+=(HOME-CAMR)*spr; PANX-=PANX*sprT; PANY-=PANY*sprT;
    window.__lock = (t>eS-RET && t<=eE);
    effR=CAMR+(HOME-CAMR)*camK; effPX=PANX*(1-camK); effPY=PANY*(1-camK);
  } else { effR=HOME; effPX=0; effPY=0; }
  setMVP2(W,H,effR,effPX,effPY,birth);
  window.__cam={r:+effR.toFixed(3),x:+effPX.toFixed(3),y:+effPY.toFixed(3),k:+camK.toFixed(2)};

  var c=new Array(16), nrm=0, i;
  if(pinned){ for(i=0;i<16;i++)c[i]=LOGO[i]; }
  else{
    for(i=0;i<16;i++){
      var env=0.55+0.45*Math.cos(tEvo*(0.13+i*0.017)+i*1.7);
      var live=gains[i]*env*Math.cos(E[i]*tEvo*0.5);
      c[i]=live*(1-k)+LOGO[i]*k;
    }
  }
  for(i=0;i<16;i++)nrm+=c[i]*c[i]; nrm=Math.sqrt(nrm)||1;
  for(i=0;i<16;i++)c[i]/=nrm;

  if(TRANSP) gl.clearColor(0,0,0,0); else gl.clearColor(BG[0],BG[1],BG[2],1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
  if(drawMesh){
    gl.uniform1fv(U.c,new Float32Array(c));
    gl.uniform1f(U.uScale,2.5*(0.9+0.25*k));
    gl.uniform1f(U.uFlat,cheat);gl.uniform1f(U.uEdge,cheat);
    gl.uniform3f(U.cNeg,lerp(C_NEG[0],C_POS[0],teal),lerp(C_NEG[1],C_POS[1],teal),lerp(C_NEG[2],C_POS[2],teal));
    gl.drawElements(gl.TRIANGLES,idx.length,gl.UNSIGNED_SHORT,0);
  }
  ox.clearRect(0,0,ovc.width,ovc.height);
  if(fill>0.004){
    var mm=proj(W,H), ppw=mm.m0*ovc.width/2/(window.__cam?window.__cam.r:CAMR);
    var er=window.__cam?window.__cam.r:CAMR;
    var s=Math.min(ovc.width,ovc.height)*0.34*(R/er);
    ox.save();ox.translate(ovc.width/2-(window.__cam?window.__cam.x:0)*ppw,
                           ovc.height/2+(window.__cam?window.__cam.y:0)*ppw);
    ox.globalAlpha=fill;ox.fillStyle='#00b8b0';
    for(var p2=0;p2<PATHS.length;p2++){var pts=PATHS[p2];ox.beginPath();
      for(var j2=0;j2<pts.length;j2++){var X=pts[j2][0]*s,Y=-pts[j2][1]*s;
        j2?ox.lineTo(X,Y):ox.moveTo(X,Y);}
      ox.closePath();ox.fill();}
    ox.restore();
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

/* Click-to-zoom, OFF unless armed: real load screens must never eat input.
   Armed via ?interact=1 (or window.__ADOM_LOADER_INTERACT, which the wiki
   readme's inline preview sets). Click the canvas to capture the wheel for
   zoom; click is released on window blur (clicking outside the iframe). */
if (Q.get('interact')==='1' || window.__ADOM_LOADER_INTERACT){
  var live=false;
  /* The hint pill is OFF by default even when armed: production wants purity.
     ?hint=1 shows it, and armed contexts can flip it live by postMessage. The
     teal edge glow always communicates the armed state either way. */
  var HINTON = P('hint',0)>=1;
  var edge=document.createElement('div');
  edge.style.cssText='position:fixed;inset:0;pointer-events:none;opacity:0;'+
    'transition:opacity .18s ease;border:1px solid #00b8b0;'+
    'box-shadow:inset 0 0 26px rgba(0,184,176,.16), inset 0 0 0 1px rgba(0,184,176,.35);';
  document.body.appendChild(edge);
  var hint=document.createElement('div');
  hint.style.cssText='position:absolute;left:50%;bottom:16px;transform:translateX(-50%);'+
    'padding:5px 12px;border-radius:999px;background:rgba(13,17,23,.78);'+
    'border:1px solid #30363d;font:10px monospace;letter-spacing:.05em;'+
    'color:#8b949e;pointer-events:none;opacity:0;transition:opacity .2s';
  hint.textContent='click to zoom';
  document.body.appendChild(hint);
  function hintShow(o){ hint.style.opacity=(HINTON&&o)?'1':'0'; }
  document.body.style.cursor='pointer';
  document.addEventListener('mouseenter',function(){ if(!live) hintShow(true); });
  document.addEventListener('mouseleave',function(){ hintShow(false); });
  document.addEventListener('pointerdown',function(){
    live=true; edge.style.opacity='1'; document.body.style.cursor='default';
    hint.textContent='scroll to zoom'; hint.style.color='#00b8b0'; hintShow(true);
    setTimeout(function(){ hintShow(false); },1400);
  });
  /* Armed contexts accept live control: {adomLoader:{margin:N}} retargets the
     framing and the spring CARRIES the camera to its new home, animated. Real
     load screens never install this listener. */
  window.addEventListener('message',function(e){
    var d=e.data&&e.data.adomLoader; if(!d) return;
    if(typeof d.hint!=='undefined'){ HINTON=!!d.hint; if(!HINTON) hint.style.opacity='0'; }
    if(typeof d.bg==='string'){ applyBg(d.bg); }
    if(typeof d.fps==='number'){ FPSCAP=Math.max(0,d.fps); if(FPSCAP>0&&FPSCAP<20)FPSCAP=20; }
    if(typeof d.margin==='number'){
      MARGIN=Math.max(-70,Math.min(90,d.margin));
      HOME=homeFor(MARGIN);
      ZF=Math.max(100,HOME*3);
    }
    if(typeof d.speed==='number'){ SPEED=Math.max(0.05,Math.min(3,Math.abs(d.speed)/100)); }
    if(typeof d.orbit==='number'){ ORBIT=d.orbit*Math.PI/180; }
    if(typeof d.logo==='number'){ LOGO_PK=Math.max(0,Math.min(1,d.logo/100)); }
  });
  window.addEventListener('blur',function(){ live=false; edge.style.opacity='0'; document.body.style.cursor='pointer';
    hint.textContent='click to zoom'; hint.style.color='#8b949e'; });
  document.addEventListener('wheel',function(e){
    if(!live) return;
    e.preventDefault();
    if(window.__lock) return;                      // the epoch owns the framing
    lastNX=2*e.clientX/W-1; lastNY=1-2*e.clientY/H;
    velR+=CAMR*NOTCH*(-e.deltaY/100);      // wheel up = zoom in = radius shrinks
    velR=Math.max(-HOME*0.75,Math.min(HOME*0.75,velR));
  },{passive:false});
}
</script>
</body>
</html>
'''

page = (page
  .replace('__VS__', '`' + vs + '`')
  .replace('__FS__', '`' + fs + '`')
  .replace('__LOGO_R__', logoR)
  .replace('__PATHS__', logomark)
  .replace('__BG__', '#0d1117'))

# drop the abandoned first setMVP draft entirely (kept the plain one)
page = re.sub(r"function setMVP\(w,h\)\{.*?\n\}\n/\* The m\[14\] arithmetic[^\n]*\n", '', page, flags=re.S)

if re.search(r'[–—]', page): sys.exit('build failed: em or en dash in loader')
out = ROOT / 'loader.html'
out.write_text(page)
print('wrote loader.html  %s bytes' % f'{out.stat().st_size:,}')
print('  external requests: 0')