diff --git a/README.md b/README.md
index fe042c8..4d74547 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,52 @@
-# The Exploded Cluster
+# The Teaching Lab (learn.bztmon.com)
-Interactive scroll-driven teardown of Kubernetes / OpenShift / Docker, in the estate's
-Bat-Computer visual world (void `#070b14`, cyan `#3fbaf5` → magenta `#e879f9`).
+A multi-page static technical learning site: scroll-driven teardowns of infrastructure the
+author actually operates. Dark engineering-dossier aesthetic (void `#070b14`, cyan `#3fbaf5`,
+magenta `#e879f9`), dependency-light by design, strict security posture.
-- **v2** (tag `v2`) — CSS `preserve-3d` diagrams. Parked: eyeballed px positions, misaligned at some widths.
-- **v3** (in progress) — ships PUBLIC at **learn.bztmon.com**.
-- **v3 build** — Nano Banana 2 exploded hero shots, hand-sliced into `clip-path` parts and
- scrubbed collapsed → hero with GSAP. Spec: `homelab/specs/SPEC-exploded-cluster-v3.md`.
+## Pages
+| Route | Page | Source |
+|---|---|---|
+| `/` | The Teaching Lab - chapter directory | `pilot/index.tpl.html` |
+| `/cluster` | Chapter 01: The Exploded Cluster - eleven-course container-platform teardown | `pilot/arc.tpl.html` + `pilot/manifest-*.json` |
+| `/mirror` | Chapter 02: The Mirror - a zot pull-through registry, its failure modes and operations | `pilot/mirror.tpl.html` + `pilot/mirror.js` |
+
+## Build
+`bash build.sh` -> `dist/`. The assembler (`pilot/assemble-arc.mjs`, plain node, no
+dependencies) wraps each page in a shared document shell (canonical/OG metadata, build
+revision, skip link), content-fingerprints every file under `/assets/` (immutable-cached
+by nginx), splits page scripts into external fingerprinted files, injects intrinsic image
+dimensions (WebP and JPEG parsed from the actual bytes) with deliberate lazy/eager loading,
+and runs per-page preflight gates: no inline scripts (CSP `script-src 'self'` with no
+exceptions), exactly one `h1`, unique ids, no dangling anchors, no unresolved placeholders,
+and page-specific content assertions. A failed gate fails the build.
+
+Ship: `podman build` (multi-stage: node builds `dist/`, `nginx-unprivileged` serves it,
+both digest-pinned) -> push to the estate registry -> digest-pinned deploy via GitOps.
+`nginx/` carries the runtime config: strict CSP, HSTS, no-cache HTML with immutable
+fingerprinted assets, security headers on every location.
+
+## Accessibility and performance
+Skip link on every page (hidden until keyboard focus), one `h1` per page, stable section
+anchors, reduced-motion honoured live (effects also pause when the tab is hidden or the
+scene is off screen), intrinsic image dimensions to prevent layout shift, below-fold images
+lazy-loaded, system font stacks (CSP permits no third-party fonts).
## Layout
| Path | What |
|---|---|
-| `exploded-cluster.html` | v2 assembled single file (174KB) |
-| `ec2-part1.html` / `ec2-part2.html` | v2 source split — markup+CSS / app JS |
-| `gsap.min.js` · `st.min.js` | GSAP 3.13 + ScrollTrigger, inlined at assemble time |
-| `prompts/` | NB2 prompt contract — master style block + per-scene prompts |
-| `tools/slicer.html` | slice editor: load a hero, tune cut lines, export the manifest |
-| `assets/raw/` | curated NB2 heroes, straight from the generator |
+| `pilot/` | page templates, page scripts, scene manifests, the assembler |
+| `assets/dist/` | production heroes and diagrams for `/cluster` |
+| `assets/mirror/` | plates for `/mirror` |
+| `content/` | course source notes |
+| `nginx/` | runtime config baked into the image |
+| `tools/slicer.html` | slice editor: load a hero, tune cut lines, export a scene manifest |
+| `prompts/` | image prompt contract for the house visual style |
+| `exploded-cluster.html`, `ec2-part*.html` | v2 (parked, superseded by `/cluster`) |
+| `gsap.min.js` / `st.min.js` | GSAP 3.13 + ScrollTrigger, fingerprinted at assemble time |
-Rebuild v2: `cat ec2-part1.html <(echo "") ec2-part2.html > exploded-cluster.html`
-
-## The slicing rule (load-bearing — read before touching the engine)
-Parts occlude each other in 2D, so every slice carries a hidden notch where the part in front
-covers it. Animating **collapsed → hero** only ever increases occlusion, so notches stay buried.
-**Never overshoot past the hero layout and never fan parts sideways** — either exposes the tears.
-Region boundaries overlap upward by `EPS`; expanding downward would ghost.
+## The slicing rule (load-bearing - read before touching the arc engine)
+Parts occlude each other in 2D, so every slice carries a hidden notch where the part in
+front covers it. Animating **collapsed -> hero** only ever increases occlusion, so notches
+stay buried. **Never overshoot past the hero layout and never fan parts sideways** - either
+exposes the tears. Region boundaries overlap upward by `EPS`; expanding downward would ghost.
diff --git a/pilot/assemble-arc.mjs b/pilot/assemble-arc.mjs
index 7504565..8d0e619 100644
--- a/pilot/assemble-arc.mjs
+++ b/pilot/assemble-arc.mjs
@@ -40,6 +40,30 @@ function webpSize(buf) {
return null;
}
+// --- intrinsic JPEG dimensions (SOF0/SOF2 markers) for the mirror plates ---
+function jpegSize(buf) {
+ if (buf[0] !== 0xff || buf[1] !== 0xd8) return null;
+ let o = 2;
+ while (o + 9 < buf.length) {
+ if (buf[o] !== 0xff) { o++; continue; }
+ const marker = buf[o + 1];
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
+ return { h: buf.readUInt16BE(o + 5), w: buf.readUInt16BE(o + 7) };
+ }
+ o += 2 + buf.readUInt16BE(o + 2);
+ }
+ return null;
+}
+
+// skip link: emitted by the shared shell, so its accessibility CSS lives in the shell
+// too - visually hidden until keyboard focus, visible and prominent when focused.
+const SKIP_CSS = ``;
+
let tpl = readFileSync(join(ROOT, "pilot/arc.tpl.html"), "utf8");
// split the app script (the last