mirror rev 4: evidence-verified rewrite, six-diagram artefact set, front-end and build-gate hardening
Content corrected against upstream docs, deployed-version source and the live config: retention/GC semantics (untagged manifests including digest-only pull-through entries are GC-eligible; keepUntagged is v2.1.19+, not deployed), warm-cache guarantees (tag pulls revalidate upstream per open issue; digest pulls serve locally), retry arithmetic (buildah --retry counts retries: 16 max, not 12), five-upstream flat-namespace routing stated as observed risk with a proposed prefix redesign, auth-canary design validated against longest-match policy precedence with the Docker-client caveat, coalescing reframed from deadlock to design with the restart as recovery-not-root-cause. Adds fact panel, contents, failure-mode and cache test matrices, decision table, open verification register and primary-source references. Front-end: grid full-bleed (no viewport units - no scrollbar overflow), shell-level skip link, real 1600x872 intrinsic dims injected from bytes, lazy below-fold plates, AA code-comment contrast, scene engine rewritten (IO-gated, rAF-coalesced, live reduced-motion, hidden-tab pause, zero guards). Assembler: per-page gates plus mirror-specific required/banned content assertions. README describes the multi-page site honestly.
This commit is contained in:
@@ -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 "<script>") gsap.min.js <(echo "</script><script>") st.min.js <(echo "</script>") 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.
|
||||
|
||||
+56
-2
@@ -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 = `<style>
|
||||
a.skip{position:absolute;left:-9999px;top:0;z-index:99}
|
||||
a.skip:focus{left:1rem;top:1rem;background:#070b14;color:#3fbaf5;
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;
|
||||
padding:10px 16px;border:1px solid #3fbaf5;border-radius:6px;outline:none}
|
||||
</style>`;
|
||||
|
||||
let tpl = readFileSync(join(ROOT, "pilot/arc.tpl.html"), "utf8");
|
||||
|
||||
// split the app script (the last <script> block) out of the template
|
||||
@@ -136,6 +160,7 @@ const shell = ({ title, desc, path, ogImg, scripts, bodyHtml }) => `<!doctype ht
|
||||
<meta name="twitter:description" content="${desc}">
|
||||
<meta name="twitter:image" content="${ogImg}">
|
||||
<link rel="icon" href="${favicon}">
|
||||
${SKIP_CSS}
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main-content">Skip to content</a>
|
||||
@@ -157,9 +182,14 @@ const MIRROR_DESC = "A pull-through container registry taken apart: why a lab ru
|
||||
let mirror = readFileSync(join(ROOT, "pilot/mirror.tpl.html"), "utf8");
|
||||
const mirrorPlates = {};
|
||||
for (const n of ["20", "21", "22", "23", "24", "25"]) {
|
||||
const { url } = emit(join(ROOT, "assets/mirror", `${n}.jpg`), `mirror-${n}`, "jpg");
|
||||
const { url, buf } = emit(join(ROOT, "assets/mirror", `${n}.jpg`), `mirror-${n}`, "jpg");
|
||||
mirrorPlates[n] = url;
|
||||
mirror = mirror.replace(`__M${n}__`, url);
|
||||
const dims = jpegSize(buf);
|
||||
if (!dims) throw new Error(`mirror plate ${n}: JPEG dimensions unreadable`);
|
||||
// plate 20 is the page's first image (near the fold) -> eager/LCP; the rest lazy.
|
||||
const load = n === "20" ? "eager" : "lazy";
|
||||
mirror = mirror.replace(`__M${n}__`,
|
||||
`${url}" width="${dims.w}" height="${dims.h}" decoding="async" loading="${load}`);
|
||||
}
|
||||
const mirrorJs = readFileSync(join(ROOT, "pilot/mirror.js"));
|
||||
const mirrorJsName = `mirror.${fp(mirrorJs)}.js`;
|
||||
@@ -206,6 +236,30 @@ for (const [pg, doc] of [["cluster", html], ["mirror", mirrorHtml], ["index", di
|
||||
if (m[1] && !pidset.has(m[1])) fail.push(`${pg}: dangling internal link #` + m[1]);
|
||||
}
|
||||
}
|
||||
// mirror-page content gates: retired claims may be DISCUSSED as corrections, so these
|
||||
// target the assertive phrasings and internal production jargon, not the topic words.
|
||||
const mirrorBanned = [
|
||||
[/never leaves the building/i, "retired absolute cache claim"],
|
||||
[/up to twelve|twelve tries|12 attempts/i, "stale retry arithmetic (correct figure: sixteen)"],
|
||||
[/plate-level/i, "internal production note leaked to readers"],
|
||||
[/· motion:/i, "art-direction caption leaked to readers"],
|
||||
[/Phase B/, "internal phase jargon without explanation"],
|
||||
[/RAID .*(is|are) (a )?backup/i, "snapshots/RAID-as-backup claim"],
|
||||
];
|
||||
for (const [re, msg] of mirrorBanned) if (re.test(mirrorHtml)) fail.push("mirror: " + msg);
|
||||
const mirrorRequired = [
|
||||
["zotregistry.dev", "primary zot references missing"],
|
||||
["keepUntagged", "version-gated retention discussion missing"],
|
||||
["/livez", "real health endpoints missing"],
|
||||
['id="refs"', "references section missing"],
|
||||
['id="open"', "open-verification register missing"],
|
||||
['width="1600" height="872"', "plate intrinsic dimensions missing/incorrect"],
|
||||
];
|
||||
for (const [needle, msg] of mirrorRequired) if (!mirrorHtml.includes(needle)) fail.push("mirror: " + msg);
|
||||
must((mirrorHtml.match(/width="1600" height="872"/g) || []).length === 6, "mirror: expected six correctly-dimensioned plates");
|
||||
must(!/100vw/.test(mirrorHtml), "mirror: viewport-width unit reintroduces scrollbar overflow");
|
||||
must(!mirrorHtml.includes("gsap"), "mirror: unexpected animation library reference");
|
||||
|
||||
// banned / obsolete strings (user-visible copy)
|
||||
const banned = [
|
||||
[/ArgoCD/, "user-visible 'ArgoCD' (use 'Argo CD')"],
|
||||
|
||||
@@ -34,9 +34,6 @@ h1 em{color:var(--cyan);font-style:normal}
|
||||
footer{border-top:1px solid var(--line);padding:1.4rem 0 3rem;color:var(--dim);
|
||||
font-family:var(--mono);font-size:.82rem}
|
||||
footer a{color:var(--cyan)}
|
||||
a.skip{position:absolute;left:-9999px}
|
||||
a.skip:focus{left:1rem;top:1rem;background:var(--void);color:var(--cyan);
|
||||
font-family:var(--mono);font-size:.8rem;padding:10px 16px;border:1px solid var(--cyan);border-radius:6px}
|
||||
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
|
||||
@media (max-width:640px){.card{grid-template-columns:1fr}.card .thumb{max-height:170px}}
|
||||
</style>
|
||||
|
||||
+96
-31
@@ -1,39 +1,104 @@
|
||||
(function(){
|
||||
var rm = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var bar = document.getElementById('bar');
|
||||
var s20 = document.querySelector('#s20 img');
|
||||
var ring = document.getElementById('ring');
|
||||
var glows = [].slice.call(document.querySelectorAll('.s22-glow'));
|
||||
var s24 = document.querySelector('#s24 img');
|
||||
function prog(el){
|
||||
var r = el.getBoundingClientRect(), vh = innerHeight;
|
||||
return Math.max(0, Math.min(1, (vh - r.top) / (vh + r.height)));
|
||||
}
|
||||
function onScroll(){
|
||||
var d = document.documentElement;
|
||||
bar.style.width = (100 * d.scrollTop / (d.scrollHeight - innerHeight)) + '%';
|
||||
if (rm) return;
|
||||
var p20 = prog(s20); /* dock-and-stow: assembles inward */
|
||||
s20.style.transform = 'scale(' + (1.07 - 0.07 * Math.min(1, p20 * 1.6)) + ') translateY(' + (26 - 26 * Math.min(1, p20 * 1.6)) + 'px)';
|
||||
var p21 = prog(ring); /* ring-walk: the cycle turns */
|
||||
ring.style.transform = 'rotate(' + (p21 * 300) + 'deg)';
|
||||
glows.forEach(function(g, i){ /* checkpoint procession: three beats */
|
||||
var p = prog(g.parentElement);
|
||||
var band = 0.18 + i * 0.22;
|
||||
g.style.opacity = Math.max(0, Math.min(0.85, (p - band) * 6));
|
||||
// The Mirror - scroll effects. External file: script-src 'self' holds, no inline JS.
|
||||
// Design constraints: no perpetual work - scroll reads are coalesced into a single rAF,
|
||||
// per-scene work only runs while that scene intersects the viewport, the pulse loop
|
||||
// runs only while its scene is visible AND the tab is visible AND motion is allowed,
|
||||
// and a change to prefers-reduced-motion takes effect live without a reload.
|
||||
(function () {
|
||||
"use strict";
|
||||
var motionQuery = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
var bar = document.getElementById("bar");
|
||||
var s20 = document.querySelector("#s20 img");
|
||||
var ring = document.getElementById("ring");
|
||||
var s22 = document.getElementById("s22");
|
||||
var glows = s22 ? [].slice.call(s22.querySelectorAll(".s22-glow")) : [];
|
||||
var s24 = document.querySelector("#s24 img");
|
||||
var pulse = document.getElementById("pulse");
|
||||
|
||||
// visibility ledger: IntersectionObserver marks which scenes are on screen
|
||||
var visible = {};
|
||||
var watched = [
|
||||
["s20", s20 && s20.closest(".scene")],
|
||||
["s21", ring && ring.closest(".scene")],
|
||||
["s22", s22 && s22.closest(".scene")],
|
||||
["s23", pulse && pulse.closest(".scene")],
|
||||
["s24", s24 && s24.closest(".scene")],
|
||||
].filter(function (w) { return w[1]; });
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
var hit = watched.find(function (w) { return w[1] === e.target; });
|
||||
if (hit) visible[hit[0]] = e.isIntersecting;
|
||||
});
|
||||
var p24 = prog(s24); /* two-plane parallax */
|
||||
s24.style.transform = 'translateY(' + ((p24 - 0.5) * -26) + 'px) scale(1.03)';
|
||||
if (visible.s23) startPulse(); // (re)arm the breather when its scene returns
|
||||
}, { rootMargin: "10% 0px" });
|
||||
watched.forEach(function (w) { io.observe(w[1]); });
|
||||
|
||||
// progress of an element through the viewport, guarded against zero heights
|
||||
function prog(el) {
|
||||
var r = el.getBoundingClientRect();
|
||||
var span = innerHeight + r.height;
|
||||
if (!span) return 0;
|
||||
return Math.max(0, Math.min(1, (innerHeight - r.top) / span));
|
||||
}
|
||||
|
||||
// scroll work: one rAF per burst, only touching scenes that are on screen
|
||||
var ticking = false;
|
||||
function frame() {
|
||||
ticking = false;
|
||||
var d = document.documentElement;
|
||||
var denom = d.scrollHeight - innerHeight;
|
||||
if (bar) bar.style.width = (denom > 0 ? (100 * d.scrollTop / denom) : 0) + "%";
|
||||
if (motionQuery.matches) return;
|
||||
if (visible.s20 && s20) {
|
||||
var p20 = Math.min(1, prog(s20) * 1.6);
|
||||
s20.style.transform = "scale(" + (1.07 - 0.07 * p20) + ") translateY(" + (26 - 26 * p20) + "px)";
|
||||
}
|
||||
if (visible.s21 && ring) ring.style.transform = "rotate(" + (prog(ring) * 300) + "deg)";
|
||||
if (visible.s22 && glows.length) {
|
||||
var p22 = prog(s22);
|
||||
glows.forEach(function (g, i) {
|
||||
g.style.opacity = Math.max(0, Math.min(0.85, (p22 - (0.18 + i * 0.22)) * 6));
|
||||
});
|
||||
}
|
||||
if (visible.s24 && s24) s24.style.transform = "translateY(" + ((prog(s24) - 0.5) * -26) + "px) scale(1.03)";
|
||||
}
|
||||
function onScroll() {
|
||||
if (!ticking) { ticking = true; requestAnimationFrame(frame); }
|
||||
}
|
||||
addEventListener("scroll", onScroll, { passive: true });
|
||||
addEventListener("resize", onScroll);
|
||||
|
||||
// the jam's breathing glow: runs only while worth running, stops itself otherwise
|
||||
var breathing = false;
|
||||
function startPulse() {
|
||||
if (breathing || !pulse || motionQuery.matches || document.hidden || !visible.s23) return;
|
||||
breathing = true;
|
||||
var t0 = null;
|
||||
requestAnimationFrame(function breathe(t) {
|
||||
if (motionQuery.matches || document.hidden || !visible.s23) {
|
||||
breathing = false;
|
||||
pulse.style.opacity = 0.25;
|
||||
return;
|
||||
}
|
||||
addEventListener('scroll', onScroll, {passive: true});
|
||||
addEventListener('resize', onScroll);
|
||||
onScroll();
|
||||
if (!rm){ /* the jam breathes; nothing else does */
|
||||
var pulse = document.getElementById('pulse'), t0 = null;
|
||||
requestAnimationFrame(function breathe(t){
|
||||
if (t0 === null) t0 = t;
|
||||
pulse.style.opacity = 0.18 + 0.2 * (0.5 + 0.5 * Math.sin((t - t0) / 520));
|
||||
requestAnimationFrame(breathe);
|
||||
});
|
||||
}
|
||||
document.addEventListener("visibilitychange", function () { if (!document.hidden) { startPulse(); onScroll(); } });
|
||||
|
||||
// live preference change: clear transforms so the page settles to its static state
|
||||
function motionChanged() {
|
||||
if (motionQuery.matches) {
|
||||
[s20, ring, s24].forEach(function (el) { if (el) el.style.transform = ""; });
|
||||
glows.forEach(function (g) { g.style.opacity = 0.35; });
|
||||
if (pulse) pulse.style.opacity = 0.25;
|
||||
} else {
|
||||
startPulse();
|
||||
onScroll();
|
||||
}
|
||||
}
|
||||
if (motionQuery.addEventListener) motionQuery.addEventListener("change", motionChanged);
|
||||
|
||||
startPulse();
|
||||
onScroll();
|
||||
})();
|
||||
|
||||
+715
-379
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user