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
|
A multi-page static technical learning site: scroll-driven teardowns of infrastructure the
|
||||||
Bat-Computer visual world (void `#070b14`, cyan `#3fbaf5` → magenta `#e879f9`).
|
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.
|
## Pages
|
||||||
- **v3** (in progress) — ships PUBLIC at **learn.bztmon.com**.
|
| Route | Page | Source |
|
||||||
- **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`.
|
| `/` | 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
|
## Layout
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `exploded-cluster.html` | v2 assembled single file (174KB) |
|
| `pilot/` | page templates, page scripts, scene manifests, the assembler |
|
||||||
| `ec2-part1.html` / `ec2-part2.html` | v2 source split — markup+CSS / app JS |
|
| `assets/dist/` | production heroes and diagrams for `/cluster` |
|
||||||
| `gsap.min.js` · `st.min.js` | GSAP 3.13 + ScrollTrigger, inlined at assemble time |
|
| `assets/mirror/` | plates for `/mirror` |
|
||||||
| `prompts/` | NB2 prompt contract — master style block + per-scene prompts |
|
| `content/` | course source notes |
|
||||||
| `tools/slicer.html` | slice editor: load a hero, tune cut lines, export the manifest |
|
| `nginx/` | runtime config baked into the image |
|
||||||
| `assets/raw/` | curated NB2 heroes, straight from the generator |
|
| `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 arc engine)
|
||||||
|
Parts occlude each other in 2D, so every slice carries a hidden notch where the part in
|
||||||
## The slicing rule (load-bearing — read before touching the engine)
|
front covers it. Animating **collapsed -> hero** only ever increases occlusion, so notches
|
||||||
Parts occlude each other in 2D, so every slice carries a hidden notch where the part in front
|
stay buried. **Never overshoot past the hero layout and never fan parts sideways** - either
|
||||||
covers it. Animating **collapsed → hero** only ever increases occlusion, so notches stay buried.
|
exposes the tears. Region boundaries overlap upward by `EPS`; expanding downward would ghost.
|
||||||
**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;
|
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");
|
let tpl = readFileSync(join(ROOT, "pilot/arc.tpl.html"), "utf8");
|
||||||
|
|
||||||
// split the app script (the last <script> block) out of the template
|
// 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:description" content="${desc}">
|
||||||
<meta name="twitter:image" content="${ogImg}">
|
<meta name="twitter:image" content="${ogImg}">
|
||||||
<link rel="icon" href="${favicon}">
|
<link rel="icon" href="${favicon}">
|
||||||
|
${SKIP_CSS}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip" href="#main-content">Skip to content</a>
|
<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");
|
let mirror = readFileSync(join(ROOT, "pilot/mirror.tpl.html"), "utf8");
|
||||||
const mirrorPlates = {};
|
const mirrorPlates = {};
|
||||||
for (const n of ["20", "21", "22", "23", "24", "25"]) {
|
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;
|
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 mirrorJs = readFileSync(join(ROOT, "pilot/mirror.js"));
|
||||||
const mirrorJsName = `mirror.${fp(mirrorJs)}.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]);
|
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)
|
// banned / obsolete strings (user-visible copy)
|
||||||
const banned = [
|
const banned = [
|
||||||
[/ArgoCD/, "user-visible 'ArgoCD' (use 'Argo CD')"],
|
[/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);
|
footer{border-top:1px solid var(--line);padding:1.4rem 0 3rem;color:var(--dim);
|
||||||
font-family:var(--mono);font-size:.82rem}
|
font-family:var(--mono);font-size:.82rem}
|
||||||
footer a{color:var(--cyan)}
|
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}
|
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
|
||||||
@media (max-width:640px){.card{grid-template-columns:1fr}.card .thumb{max-height:170px}}
|
@media (max-width:640px){.card{grid-template-columns:1fr}.card .thumb{max-height:170px}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+92
-27
@@ -1,39 +1,104 @@
|
|||||||
|
// 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 () {
|
(function () {
|
||||||
var rm = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
"use strict";
|
||||||
var bar = document.getElementById('bar');
|
var motionQuery = matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
var s20 = document.querySelector('#s20 img');
|
var bar = document.getElementById("bar");
|
||||||
var ring = document.getElementById('ring');
|
var s20 = document.querySelector("#s20 img");
|
||||||
var glows = [].slice.call(document.querySelectorAll('.s22-glow'));
|
var ring = document.getElementById("ring");
|
||||||
var s24 = document.querySelector('#s24 img');
|
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;
|
||||||
|
});
|
||||||
|
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) {
|
function prog(el) {
|
||||||
var r = el.getBoundingClientRect(), vh = innerHeight;
|
var r = el.getBoundingClientRect();
|
||||||
return Math.max(0, Math.min(1, (vh - r.top) / (vh + r.height)));
|
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() {
|
function onScroll() {
|
||||||
var d = document.documentElement;
|
if (!ticking) { ticking = true; requestAnimationFrame(frame); }
|
||||||
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));
|
|
||||||
});
|
|
||||||
var p24 = prog(s24); /* two-plane parallax */
|
|
||||||
s24.style.transform = 'translateY(' + ((p24 - 0.5) * -26) + 'px) scale(1.03)';
|
|
||||||
}
|
}
|
||||||
addEventListener('scroll', onScroll, {passive: true});
|
addEventListener("scroll", onScroll, { passive: true });
|
||||||
addEventListener('resize', onScroll);
|
addEventListener("resize", onScroll);
|
||||||
onScroll();
|
|
||||||
if (!rm){ /* the jam breathes; nothing else does */
|
// the jam's breathing glow: runs only while worth running, stops itself otherwise
|
||||||
var pulse = document.getElementById('pulse'), t0 = null;
|
var breathing = false;
|
||||||
|
function startPulse() {
|
||||||
|
if (breathing || !pulse || motionQuery.matches || document.hidden || !visible.s23) return;
|
||||||
|
breathing = true;
|
||||||
|
var t0 = null;
|
||||||
requestAnimationFrame(function breathe(t) {
|
requestAnimationFrame(function breathe(t) {
|
||||||
|
if (motionQuery.matches || document.hidden || !visible.s23) {
|
||||||
|
breathing = false;
|
||||||
|
pulse.style.opacity = 0.25;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (t0 === null) t0 = t;
|
if (t0 === null) t0 = t;
|
||||||
pulse.style.opacity = 0.18 + 0.2 * (0.5 + 0.5 * Math.sin((t - t0) / 520));
|
pulse.style.opacity = 0.18 + 0.2 * (0.5 + 0.5 * Math.sin((t - t0) / 520));
|
||||||
requestAnimationFrame(breathe);
|
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