#!/usr/bin/env python3
"""
Generate readme.html (the wiki page's Overview) from orbital-lab.html.

The wiki embeds readme.html in <iframe sandbox="allow-scripts allow-downloads">,
which imposes three constraints the standalone app does not have:

  1. No allow-same-origin. The frame runs as an opaque origin, so localStorage
     throws and fetch cannot be relied on. Probed live: localStorage raises
     SecurityError. So every data file the app loads has to be INLINED.
  2. The iframe's height is controlled by the page, not the content. A
     height:100% / overflow:hidden layout collapses or clips there, so the
     document is rebuilt around a fixed-height stage that flows naturally.
  3. Nothing cascades in from the wiki and a bare document renders on white,
     so the dark background has to be set explicitly.

CDN scripts DO load in the sandbox (probed: Babylon 9.18.0 and earcut both
fine), so those stay as script tags rather than bloating the file by 8 MB.

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

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

logomark = json.loads((ROOT / 'assets' / 'logomark.json').read_text())
profile  = json.loads((ROOT / 'assets' / 'logo-profile.json').read_text())

# ---- 1. inline the data, drop the fetches ----------------------------------
inline_data = (
    "\n/* Inlined for the wiki readme sandbox: the frame is an opaque origin,\n"
    "   so fetch cannot be relied on. These are the same bytes as\n"
    "   assets/logomark.json and assets/logo-profile.json. */\n"
    f"const LOGO_PATHS = {json.dumps(logomark)};\n"
)
src = src.replace("<script>\n/* =====", inline_data.join(["<script>", "\n/* ====="]), 1)

src = src.replace(
    "fetch('assets/logomark.json').then(r=>r.json()).then(d=>{\n"
    "  logoPaths=d; buildLogoLines(); drawGhost();\n"
    "}).catch(()=>{});",
    # Deferred, not inline: the original ran inside a .then() callback, so it
    # executed AFTER the whole script body. Calling it inline instead hits the
    # temporal dead zone on `let logoLines` and aborts the rest of the script,
    # which left the panel rendered but the canvas blank.
    "logoPaths = LOGO_PATHS;\n"
    "queueMicrotask(() => { buildLogoLines(); drawGhost(); });"
)
if "fetch('assets/logomark.json')" in src:
    sys.exit("build failed: the logomark fetch was not replaced")

# ---- 2. fixed-height stage instead of a viewport-height layout -------------
src = src.replace(
    "  html,body{height:100%;overflow:hidden}\n"
    "  body{background:var(--bg);color:var(--text);font-family:'Satoshi',sans-serif;display:flex}",
    "  /* Fluid, not fixed. 100vh resolves against the IFRAME's own viewport, so\n"
    "     this fills whatever height the wiki gives the frame AND fills the window\n"
    "     when the file is opened directly. min-height keeps it usable if a frame\n"
    "     comes up short. A height:100% would need html to have a height and does\n"
    "     collapse here, which is why that form is not used. */\n"
    "  html,body{margin:0;background:var(--bg);height:100%}\n"
    "  body{background:var(--bg);color:var(--text);font-family:'Satoshi',sans-serif;\n"
    "       display:flex;height:100vh;min-height:620px;overflow:hidden}"
)

# ---- 2b. the webview-loader docs section, appended below the lab -----------
loader_path = ROOT / 'loader.html'
if not loader_path.exists():
    sys.exit("build failed: loader.html missing; run tools/build-loader.py first")
loader = loader_path.read_text()
loader_kb = round(loader_path.stat().st_size / 1024)
import html as _html
# the inline preview arms the loader's click-to-zoom; standalone stays inert
# the inline preview runs john fav and arms click-to-zoom
loader_armed = loader.replace(
    "'use strict';",
    "'use strict';window.__ADOM_LOADER_INTERACT=1;"
    "window.__ADOM_LOADER_PARAMS={pt:5,dt:0.7,ct:0.2,st:1,logo:100,llead:0.6,tlead:0.2,bg:'transparent'};", 1)
loader_srcdoc = _html.escape(loader_armed, quote=True)

edocs = """
<style>
  /* readme variant: the lab fills the first viewport, the docs flow below */
  html,body{height:auto!important;overflow:auto!important}
  body{flex-wrap:wrap;min-height:0!important}
  /* Fixed, not viewport-relative: the wiki parent grows this iframe to fit
     our reported height, and any vh unit would chase that growth forever. */
  #panel{height:780px}
  #stageWrap{height:780px;min-width:0}
  .edocs{flex-basis:100%;background:var(--bg);border-top:1px solid var(--border);
         padding:26px 30px 44px;max-width:100%}
  .edocs h2{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:22px;margin:0 0 8px;color:var(--text)}
  .edocs h3{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:16px;margin:22px 0 7px;color:var(--text-2)}
  .edocs p{font-size:14.5px;color:var(--text-2);line-height:1.7;max-width:760px}
  .edocs b{color:var(--text)}
  .edocs pre{font-family:'JetBrains Mono','DM Mono',monospace;font-size:12.5px;background:var(--surface);
             border:1px solid var(--border);border-radius:8px;padding:13px 15px;overflow-x:auto;
             max-width:760px;color:var(--text);line-height:1.65}
  .edocs table{border-collapse:collapse;font-size:13px;color:var(--text-2);margin:10px 0}
  .edocs td,.edocs th{border:1px solid var(--border);padding:7px 12px;text-align:left}
  .edocs th{color:var(--text);font-weight:500}
  .edocs .prev{width:min(760px,100%);aspect-ratio:16/10;border:1px solid var(--border);
               border-radius:10px;background:#0d1117;display:block;margin:6px 0}
  .edocs .rig-acts{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:10px 0 10px}
  .edocs .rig-acts span{font-family:'JetBrains Mono','DM Mono',monospace;font-size:11px;
                        color:var(--text-3);letter-spacing:.05em;margin-right:2px}
  .edocs .rig-acts button{font-family:'Satoshi',sans-serif;font-weight:500;font-size:12.5px;
     padding:7px 13px;border-radius:999px;cursor:pointer;background:var(--elevated);
     border:1px solid var(--border);color:var(--text)}
  .edocs .rig-acts button:hover{border-color:var(--accent)}
  #pbox{position:relative;overflow:hidden;width:min(760px,100%);height:475px;min-width:140px;
        min-height:90px;border:1px dashed var(--border);border-radius:10px;background:#0d1117;
        transition:width .35s ease,height .35s ease}
  #pbox.notrans{transition:none}
  /* the iframe ELEMENT must not paint: the base .prev rule gives it the dark
     background, which sat between the loader's transparent buffer and the
     container's colour and swallowed the swatches */
  #pbox .prev{position:absolute;inset:0;width:100%;height:100%;aspect-ratio:auto;border:0;margin:0;background:transparent}
  #pgrip{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;cursor:nwse-resize;z-index:5;
         border-right:3px solid var(--accent);border-bottom:3px solid var(--accent);border-radius:0 0 8px 0}
  #prd{font-family:'JetBrains Mono','DM Mono',monospace;font-size:11.5px;color:var(--accent);margin-top:8px}
</style>
<div class="edocs">
  <h2>The live viewer</h2>
  <div class="rig-acts">
    <span>size</span>
    <button data-s="300,620">Tall</button>
    <button data-s="1100,300">Wide</button>
    <button data-s="420,420">Square</button>
    <button data-s="180,140">Tiny</button>
    <button data-s="760,475">Reset</button>
    <span style="margin-left:10px">margin</span>
    <button data-m="-50">-50% bleed</button>
    <button data-m="-30">-30%</button>
    <button data-m="0">0%</button>
    <button data-m="40">40%</button>
    <button data-m="80">80%</button>
    <span style="margin-left:10px">speed</span>
    <button data-sp="10">10%</button>
    <button data-sp="25">25%</button>
    <button data-sp="50">50%</button>
    <button data-sp="100">100%</button>
    <span style="margin-left:10px">orbit</span>
    <button data-o="0">off</button>
    <button data-o="12">12&deg;/s</button>
    <button data-o="45">45&deg;/s</button>
    <span style="margin-left:10px">logo fade</span>
    <button data-lg="10">10%</button>
    <button data-lg="50">50%</button>
    <button data-lg="100">100%</button>
    <span style="margin-left:10px">hint</span>
    <button data-h="1">on</button>
    <button data-h="0">off</button>
    <span style="margin-left:10px">start</span>
    <button data-e="1">emerge</button>
    <button data-e="0">logo</button>
    <span style="margin-left:10px">container bg</span>
    <button data-b="#0d1117">dark</button>
    <button data-b="#20262e">bar gray</button>
    <button data-b="#8a93a0">mid gray</button>
    <button data-b="#f2f4f7">white</button>
  </div>
  <div id="pbox"><iframe class="prev" srcdoc="__SRC__"></iframe><div id="pgrip" title="drag to resize"></div></div>
  <div id="prd">container: &hellip;</div>
  <p>This IS loader.html, inlined, running the <b>john fav</b> cut: the final output,
     before any of the explanation. Click it once and your wheel zooms toward the cursor
     with the lab's exact springy feel; click anywhere else to give the wheel back to the
     page. Drag the teal corner or use the presets, the loader fills whatever you give it;
     the margin buttons retarget the framing live and the spring carries the camera to its
     new home. The loader here runs <b>bg=transparent</b>: tap the container bg swatches and
     the box's own colour shows straight through the animation, which is how you sit it on a
     toolbar, next to a wordmark, or on any surface. Everything below explains how to ship
     it.</p>
  <script>
  (function(){
    var box=document.getElementById('pbox'), grip=document.getElementById('pgrip'),
        rd=document.getElementById('prd'), ifr=box.querySelector('iframe'), drag=null;
    document.querySelectorAll('.rig-acts button[data-s]').forEach(function(b){
      b.addEventListener('click',function(){
        var v=b.getAttribute('data-s').split(',');
        box.style.width=v[0]+'px'; box.style.height=v[1]+'px';
      });
    });
    document.querySelectorAll('.rig-acts button[data-m]').forEach(function(b){
      b.addEventListener('click',function(){
        ifr.contentWindow.postMessage({adomLoader:{margin:parseFloat(b.getAttribute('data-m'))}},'*');
      });
    });
    document.querySelectorAll('.rig-acts button[data-sp]').forEach(function(b){
      b.addEventListener('click',function(){
        ifr.contentWindow.postMessage({adomLoader:{speed:parseFloat(b.getAttribute('data-sp'))}},'*');
      });
    });
    document.querySelectorAll('.rig-acts button[data-o]').forEach(function(b){
      b.addEventListener('click',function(){
        ifr.contentWindow.postMessage({adomLoader:{orbit:parseFloat(b.getAttribute('data-o'))}},'*');
      });
    });
    document.querySelectorAll('.rig-acts button[data-lg]').forEach(function(b){
      b.addEventListener('click',function(){
        ifr.contentWindow.postMessage({adomLoader:{logo:parseFloat(b.getAttribute('data-lg'))}},'*');
      });
    });
    document.querySelectorAll('.rig-acts button[data-h]').forEach(function(b){
      b.addEventListener('click',function(){
        ifr.contentWindow.postMessage({adomLoader:{hint:b.getAttribute('data-h')==='1'}},'*');
      });
    });
    document.querySelectorAll('.rig-acts button[data-e]').forEach(function(b){
      b.addEventListener('click',function(){
        /* emerge is boot-time state, so toggle it by rewriting the srcdoc's
           params and letting the iframe reboot into the chosen opening. */
        var sd=ifr.getAttribute('srcdoc').replace(/emerge:1,?/,'');
        if(b.getAttribute('data-e')==='1')
          sd=sd.replace('__ADOM_LOADER_PARAMS={','__ADOM_LOADER_PARAMS={emerge:1,');
        ifr.setAttribute('srcdoc',sd);
      });
    });
    document.querySelectorAll('.rig-acts button[data-b]').forEach(function(b){
      b.addEventListener('click',function(){
        box.style.background=b.getAttribute('data-b');
      });
    });
    grip.addEventListener('pointerdown',function(e){
      e.preventDefault(); grip.setPointerCapture(e.pointerId);
      box.classList.add('notrans');
      drag={x:e.clientX,y:e.clientY,w:box.clientWidth,h:box.clientHeight};
    });
    grip.addEventListener('pointermove',function(e){
      if(!drag) return;
      box.style.width=Math.max(140,drag.w+e.clientX-drag.x)+'px';
      box.style.height=Math.max(90,drag.h+e.clientY-drag.y)+'px';
    });
    grip.addEventListener('pointerup',function(){ drag=null; box.classList.remove('notrans'); });
    setInterval(function(){
      rd.textContent='container: '+box.clientWidth+'x'+box.clientHeight;
    },300);
  })();
  </script>
  <h2 style="margin-top:30px">Ship it as a webview loader</h2>
  <p><b>loader.html</b> on this page is the animation above distilled to ONE dependency-free file:
     raw WebGL, no Babylon, no CDN, no fonts, no fetch, about __KB__ KB. It boots instantly, which
     is the point: it plays while your webview's real content is still loading. Under 10 KB
     gzipped, zero requests, one draw call over 12,800 vertex-shader-displaced triangles, and
     about 0.06 ms of JS per frame, so the CPU stays asleep. The wheel is tuned against the
     lab's measured response (same burst, same dip, same spring home), zooming toward the
     cursor with the springy feel that always breathes the framing back to center. Defaults are the tuned numbers; every knob is a URL parameter, and a
     fresh random state is rolled at every epoch so it never repeats.</p>
  <h3>Why raw WebGL, and not Babylon or three.js</h3>
  <p>A load screen exists to be on screen while your real app is still fetching. An animation
     that first downloads and parses a 3D engine defeats its own purpose, so the loader ships
     as ONE hand-rolled WebGL file and nothing else. The main viewer at the top of this page IS
     Babylon.js 9.18, where a full engine earns its weight (ArcRotate camera, GPU pixel
     readbacks for the fit measurement, tooling); the loader is that lab distilled.</p>
  <table>
    <tr><th>approach</th><th>payload, gzipped</th><th>requests</th><th>first frame</th></tr>
    <tr><td>Babylon.js + app</td><td>~1.4 MB engine + your page</td><td>CDN + page</td><td>after download and parse</td></tr>
    <tr><td>three.js + app</td><td>~170 KB engine + your page</td><td>CDN + page</td><td>after download and parse</td></tr>
    <tr><td><b>loader.html</b></td><td><b>9,912 bytes, everything included</b></td><td><b>zero</b></td><td><b>tens of milliseconds</b></td></tr>
  </table>
  <h3>The measured numbers</h3>
  <p>Measured on this build, not estimated: <b>24,379 bytes</b> on disk and <b>9,912 bytes
     gzipped</b>, including the shaders, the 286-point logomark vectors, the 128-sample
     measured edge profile, and the whole timeline. Runtime: <b>0.057 ms of JavaScript per
     frame</b> (16 coefficient cosines and uniform uploads, about 1.4% of a 240 Hz frame
     budget), then <b>one draw call</b> over <b>12,800 triangles / 6,561 vertices</b> with all
     displacement in the vertex shader. requestAnimationFrame pauses it completely when the
     tab is hidden, so a backgrounded webview costs nothing. Spinner duty, measured at
     32x32 on a 240 Hz display: one instance costs about 1 to 3% of a single core, eight
     concurrent instances about 9%, and eight instances capped with <code>fps=30</code>
     about 2% total, all while the host page holds a locked 240 fps. The mode presets,
     same eight-spinner fleet: <b>performance</b> 8.2% of a core, <b>lightweight</b> 1.7%,
     about five times cheaper. On a 60 Hz machine divide by four. Hidden, the cost is
     exactly zero.</p>
  <h3>Fidelity guarantees</h3>
  <p>The loader is not a re-implementation of the look. <code>tools/build-loader.py</code>
     extracts the GLSL <b>verbatim</b> from the Babylon lab at build time (one source of
     truth), reproduces Babylon's left-handed view and projection matrices exactly, and the
     chirality is pixel-verified against the lab: teal lobe upper-right, navy lower-left.
     Same equation, same cheat, same profile data, pixel-identical rendering. The camera feel
     is tuned against the lab's <b>measured</b> response, not copied constants: the same
     12-notch wheel burst dips both viewers to the same radius (3.99 vs 3.995) and both
     spring home in the same beat, via a Babylon-style decaying inertia integral, the lab's
     6% radius and 5% centering springs at its 240 fps cadence made frame-rate independent,
     zoom toward the cursor, and a glide home before every logo so the mark always lands
     undistorted.</p>
  <h3>The mouse: arming, zoom to cursor, and the springiness algorithm</h3>
  <p><b>Arming.</b> A load screen must never eat input, so the loader is inert by default:
     clicks and wheel pass straight through to your page. <code>?interact=1</code> arms it
     (the preview above is armed): one click captures the wheel, a teal ring with a glow
     says so, and clicking anywhere else or leaving the frame releases it.</p>
  <p><b>Zoom to cursor.</b> Every notch records the cursor in normalized device coordinates.
     The world point under it is solved from the camera, <code>w = n&middot;r/m + pan</code>,
     the radius step is applied, and the pan is re-solved so that exact world point stays
     pinned under the cursor: <code>pan' = w &minus; n&middot;r'/m</code>. Measured drift of
     the pinned point across a six-notch corner zoom: 0.002 world units.</p>
  <p><b>The springiness.</b> Three exponential systems, all frame-rate independent via
     <code>pow(k, dt&middot;240)</code> so a 60 Hz laptop feels identical to a 240 Hz monitor:</p>
  <table>
    <tr><th>system</th><th>rule, per frame</th><th>feel</th></tr>
    <tr><td>inertia integral</td><td>each notch deposits <code>vel += r&middot;0.085</code>;
        consumed by <code>step = vel&middot;(1&minus;0.8^(dt&middot;240))</code></td>
        <td>notches stack and glide instead of stepping</td></tr>
    <tr><td>radius spring</td><td><code>r += (4.4&minus;r)&middot;(1&minus;0.94^(dt&middot;240))</code></td>
        <td>zoom always breathes back home</td></tr>
    <tr><td>centering pull</td><td><code>pan &minus;= pan&middot;(1&minus;0.95^(dt&middot;240))</code></td>
        <td>the mark drifts back to center</td></tr>
  </table>
  <p><b>Tuned by measurement, not by copying constants.</b> The obvious approach, lifting
     Babylon's <code>wheelDeltaPercentage</code>, nets to zero against the spring. So both
     viewers were driven with the identical 12-notch wheel burst in instrumented pup windows
     and the loader was tuned until the response curves matched: the lab dips to radius
     3.995 and springs home in about 200 ms; the loader dips to 3.990 and springs home in
     about 250 ms, one sample bucket apart.</p>
  <p><b>The epoch override.</b> Whatever you have zoomed or panned, a glide window of
     <code>max(0.6s, llead+0.2s, min(1.2s, dt))</code> eases the framing to the calibrated
     home pose, arriving exactly at epoch-start, the wheel is locked through the sit so the
     mark always lands undistorted, and home becomes the new state afterwards: the wheel
     frees the instant the sit ends and play resumes from the calibrated framing.</p>
  <h3>Resizing: it fills whatever it is given</h3>
  <p>Webviews get resized constantly, so the loader treats size as live input: it listens to
     window resize AND a ResizeObserver on the document, re-backs both canvases at
     devicePixelRatio (capped 2), and picks its FOV axis by the LIMITING dimension, so
     portrait, landscape, square, tiny or huge, the mark keeps its fraction of the container
     with no letterboxing and no stretching. Proven across 300x620, 1100x300, 420x420,
     180x140, 1300x700 and 900x560: the GL buffer tracked the container 1:1 at every step,
     including mid-animation continuous resizes, and re-backing is coalesced into the render loop (events only mark dirty, the new size is applied and DRAWN in the same frame), so a continuous drag repaints clean every frame with no blank flashes. The repo ships
     <code>resize-proof.html</code>, a drag-to-resize torture rig around the real loader,
     so you can prove it in your own container shape.</p>
  <h3>Embed</h3>
  <pre>&lt;iframe src="loader.html"
        style="position:absolute;inset:0;width:100%;height:100%;border:0;background:#0d1117"&gt;
&lt;/iframe&gt;
&lt;!-- when your app is ready, fade the iframe out and remove it --&gt;</pre>
  <p>Grab <b>loader.html</b> from this page's Files tab (or clone the repo) and serve it beside
     your app. It sizes to the limiting axis of any container, portrait or landscape.</p>
  <h3>Parameters</h3>
  <table>
    <tr><th>param</th><th>default</th><th>meaning</th></tr>
    <tr><td>pt</td><td>4.5</td><td>playground seconds of free evolution between epochs</td></tr>
    <tr><td>dt</td><td>3</td><td>seconds to steer onto the mark, and back out</td></tr>
    <tr><td>ct</td><td>0.3</td><td>seconds before the epoch the outline cheat fades in</td></tr>
    <tr><td>st</td><td>0</td><td>seconds the mark is held dead still</td></tr>
    <tr><td>logo</td><td>100</td><td>peak opacity of the flat teal mark, in percent</td></tr>
    <tr><td>llead</td><td>1</td><td>seconds the logo fade starts before the sit and ends after it</td></tr>
    <tr><td>tlead</td><td>1.5</td><td>seconds before the epoch the dark lobe fades to teal</td></tr>
    <tr><td>ov</td><td>1</td><td>0 makes the cheat its own act after the steer</td></tr>
    <tr><td>intro</td><td>1</td><td>0 skips the teal logo intro</td></tr>
    <tr><td>interact</td><td>0</td><td>1 arms click-to-zoom (for previews and demos, never load screens)</td></tr>
    <tr><td>mode</td><td>performance</td><td><b>lightweight</b> bundles fps 30, dpr 1, seg 48, no antialias for spinner fleets and dialogs; <b>performance</b> (alias <b>sexy</b>) is the full-quality default. Explicit params override the mode</td></tr>
    <tr><td>seg</td><td>80</td><td>sphere tessellation, 24 to 128 (80 is 12,800 triangles, 48 is 4,608)</td></tr>
    <tr><td>aa</td><td>1</td><td>0 disables antialiasing</td></tr>
    <tr><td>dpr</td><td>2</td><td>devicePixelRatio cap for the backing store, 1 to 3</td></tr>
    <tr><td>fps</td><td>off</td><td>cap the render rate for spinner duty (floor 20). rAF already yields between frames and stops when hidden; fps=30 renders one frame in eight on a 240 Hz display</td></tr>
    <tr><td>bg</td><td>0d1117</td><td>hex background, or <b>transparent</b> to composite over the container's own background (GL context runs with alpha). Also settable live by postMessage</td></tr>
    <tr><td>hint</td><td>0</td><td>1 shows the small "click to zoom" pill in armed contexts. Off by default: production stays pure. Armed contexts can also flip it live by postMessage</td></tr>
    <tr><td>emerge</td><td>0</td><td>1 replaces the logo intro: the equation runs from t=0 and the surface condenses out of nothing (2.6 s scale and fade) straight into play, then the normal timeline. Keep pt above ~3 s so the first epoch never lands mid-birth. A taste toggle: try the start buttons in the rig above</td></tr>
    <tr><td>margin</td><td>0</td><td>percent of the container left empty around the mark: 80 sits it small in the middle of a big window, 20 nearly fills. NEGATIVE margins bleed: -50 moves the camera 50% closer so the animation runs off every edge (clamped at -70 to protect the near plane). Zoom-in still brings it to the screen; zoom-out caps at 40% past the margined framing. Live-settable by postMessage</td></tr>
    <tr><td>speed</td><td>100</td><td>percent playback rate for the animation clocks (timeline, evolution, birth): 10 runs it at a tenth speed. Camera springs and user interaction stay real time so the feel never goes gluey. Live-settable by postMessage</td></tr>
    <tr><td>logo</td><td>100</td><td>peak opacity of the logo fill at the epoch, in percent: 100 is the full solid mark, 50 a translucent ghost, 10 barely a whisper, 0 disables the fill entirely (the sculpted surface still forms the mark shape). Live-settable by postMessage</td></tr>
    <tr><td>orbit</td><td>0</td><td>automatic camera yaw during PLAY in degrees per second (try 12). As sculpting starts the angle glides to the nearest whole turn, so every epoch still lands square-on for the logo and play resumes with no unwinding sweep. Live-settable by postMessage</td></tr>
  </table>
  <h3>The three tuned cuts</h3>
  <p>These are the settings the loop was actually tuned to, saved live while dialing it in.
     All three ship in the Final tab's snapshot pulldown (marked *), and each maps to loader
     URL params. What the knobs mean: <b>pt</b> is free play between epochs, <b>dt</b> is how
     long the dive onto the mark takes, <b>ct</b> is how late the outline cheat is held back
     (smaller = the sculpting is nearly invisible), <b>st</b> is dead-still hold time,
     <b>logo</b> is the flat mark's peak opacity, <b>llead</b> starts the logo fade that many
     seconds before the epoch and ends it that many after, <b>tlead</b> turns the dark lobe
     teal that many seconds early so the mark reads as one colour by arrival.</p>
  <table>
    <tr><th>cut</th><th>numbers</th><th>why it feels the way it does</th></tr>
    <tr><td><b>john fav</b><br>(the preview below)</td>
        <td>pt 5 &middot; dt 0.7 &middot; ct 0.2 &middot; st 1<br>logo 100% &middot; llead 0.6 &middot; tlead 0.2</td>
        <td>The keeper. A 0.7s dive into a real 1s sit, and the teal unifies only 0.2s
            before arrival, so the dark lobe survives almost to the mark and the colour
            snap lands WITH the logo. Loader:
            <code>?pt=5&amp;dt=0.7&amp;ct=0.2&amp;st=1&amp;llead=0.6&amp;tlead=0.2</code></td></tr>
    <tr><td><b>john 1</b><br>(the default)</td>
        <td>pt 4.5 &middot; dt 3 &middot; ct 0.3 &middot; st 0<br>logo 100% &middot; llead 1 &middot; tlead 1.5</td>
        <td>The stately cut. A long 3s dinner call lets you watch the physics converge; the
            cheat waits until the last 0.3s so you never catch it working; there is NO sit,
            the whole logo moment lives on its 1s lead, swelling to full teal exactly across
            the epoch and letting go. Loader: no params needed.</td></tr>
    <tr><td><b>snap 2</b></td>
        <td>pt 5 &middot; dt 1 &middot; ct 0.2 &middot; st 0<br>logo 100% &middot; llead 0.3 &middot; tlead 1.5</td>
        <td>The middle cut. A 1s dive and a tight 0.3s logo flash: punchy, still readable.
            Loader: <code>?pt=5&amp;dt=1&amp;ct=0.2&amp;llead=0.3</code></td></tr>
    <tr><td><b>snap 3</b></td>
        <td>pt 5 &middot; dt 0.7 &middot; ct 0.2 &middot; st 0<br>logo 80% &middot; llead 0.5 &middot; tlead 1.5</td>
        <td>The heartbeat cut. A 0.7s dive with the logo capped at 80% so the 3D still shows
            through the flash: the mark reads as a pulse, not a poster.
            Loader: <code>?pt=5&amp;dt=0.7&amp;ct=0.2&amp;logo=80&amp;llead=0.5</code></td></tr>
  </table>
</div>
<script>
/* The wiki parent listens for {type:'readme-height'} and resizes this iframe
   to fit, so the lab and the loader docs render with no inner scrollbars. */
(function(){
  var last = 0;
  function report(){
    var h = Math.ceil(document.documentElement.scrollHeight) + 2;
    if (Math.abs(h - last) > 2) { last = h; parent.postMessage({ type: 'readme-height', height: h }, '*'); }
  }
  window.addEventListener('load', report);
  if (window.ResizeObserver) new ResizeObserver(report).observe(document.documentElement);
  setInterval(report, 1200);
  report();
})();
</script>
""".replace('__KB__', str(loader_kb)).replace('__SRC__', loader_srcdoc)

src = src.replace('</body>', edocs + '</body>')
if '.edocs' not in src:
    sys.exit('build failed: loader docs section was not injected')

# ---- 3. brand rules the linter cares about ---------------------------------
if re.search(r'[–—]', src):
    sys.exit("build failed: an em or en dash reached readme.html")
if re.search(r'[\U0001F300-\U0001FAFF☀-➿]', src):
    sys.exit("build failed: an emoji reached readme.html")

src = src.replace("<title>Orbital lab</title>", "<title>Orbital lab</title>")

out = ROOT / 'readme.html'
out.write_text(src)
print(f"wrote {out.relative_to(ROOT)}  {out.stat().st_size:,} bytes")
print(f"  logomark points inlined: {sum(len(p) for p in logomark)}")
print(f"  edge profile samples:    {len(profile)}")
print(f"  fetch calls remaining:   {src.count('fetch(')}")
print(f"  localStorage uses:       {src.count('localStorage')}")