Files
exploded-cluster/pilot/course-1.tpl.html
T

230 lines
9.9 KiB
HTML

<header class="masthead">
<p class="eyebrow">The Exploded Cluster · Course I</p>
<h1>An image is not a box.<br><em>It is a stack of frozen diffs.</em></h1>
<p class="lede">Scroll, and the thing you keep calling "a container image" comes apart in your hands.
Four layers. Each one only stores what changed from the layer under it.</p>
<p class="scrollcue" aria-hidden="true">scroll<span></span></p>
</header>
<section class="course" id="course-1" aria-labelledby="c1h">
<h2 id="c1h" class="sr-only">Docker image layers, exploded</h2>
<div class="pin">
<div class="viewport">
<figure class="scene" id="scene">
<img class="flat" src="__HERO__"
alt="An exploded isometric view of a container image: four stacked slabs floating apart —
a heavy metal base, a circuit-etched dependency layer, a magenta-traced code layer,
and a thin frosted-glass writable layer on top.">
</figure>
<ol class="legend" id="legend"></ol>
</div>
</div>
</section>
<section class="after">
<p class="fieldnote">
<b>Field note.</b> The order of your Dockerfile is a caching decision, not a style choice.
Put <code>COPY . .</code> above your dependency install and you have told the builder to
throw away every cached layer every time you change one line of code.
</p>
</section>
<style>
:root{
--void:#070b14; --ink:#c9d7ef; --dim:#7286a8; --line:#1b2740;
--cyan:#3fbaf5; --magenta:#e879f9;
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--void);color:var(--ink);font-family:var(--sans);
-webkit-font-smoothing:antialiased;overflow-x:hidden}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* ---- masthead ---- */
.masthead{max-width:64ch;margin:0 auto;padding:18vh 24px 10vh;text-align:center}
.eyebrow{font-family:var(--mono);font-size:.72rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--cyan);margin:0 0 1.6rem}
.masthead h1{font-size:clamp(2rem,5.5vw,3.6rem);line-height:1.08;margin:0 0 1.4rem;
font-weight:600;letter-spacing:-.02em;text-wrap:balance}
.masthead h1 em{font-style:normal;color:var(--magenta)}
.lede{font-size:clamp(1rem,1.7vw,1.16rem);line-height:1.65;color:var(--dim);margin:0 auto;max-width:52ch}
.scrollcue{font-family:var(--mono);font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;
color:var(--dim);margin-top:5rem;display:flex;flex-direction:column;align-items:center;gap:.7rem}
.scrollcue span{display:block;width:1px;height:46px;
background:linear-gradient(var(--cyan),transparent)}
/* ---- pinned scene ---- */
.course{position:relative}
.pin{min-height:100svh;display:grid;place-items:center;padding:3vh 20px}
.viewport{width:min(1180px,100%);display:grid;grid-template-columns:minmax(0,1fr) 300px;
gap:28px;align-items:center}
.scene{position:relative;margin:0;line-height:0}
.scene .flat{display:block;width:100%;height:auto}
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;
will-change:transform}
/* ---- legend ---- */
.legend{position:relative;list-style:none;margin:0;padding:0;height:100%;min-height:min(58vh,540px)}
.legend li{position:absolute;left:0;right:0;transform:translateY(-50%);
padding-left:26px;border-left:1px solid var(--line);
opacity:.34;transition:opacity .35s ease,border-color .35s ease}
.legend li.lit{opacity:1;border-left-color:var(--cyan)}
.legend .n{font-family:var(--mono);font-size:.68rem;letter-spacing:.16em;color:var(--cyan);
display:block;margin-bottom:.3rem}
.legend .t{font-size:.98rem;font-weight:600;margin:0 0 .3rem;letter-spacing:-.01em}
.legend .b{font-size:.8rem;line-height:1.55;color:var(--dim);margin:0}
.after{max-width:60ch;margin:0 auto;padding:12vh 24px 22vh}
.fieldnote{border-left:2px solid var(--magenta);padding:0 0 0 20px;margin:0;
font-size:.94rem;line-height:1.7;color:var(--dim)}
.fieldnote b{color:var(--magenta);font-family:var(--mono);font-size:.72rem;
letter-spacing:.16em;text-transform:uppercase;display:block;margin-bottom:.5rem}
.fieldnote code{font-family:var(--mono);font-size:.86em;color:var(--ink);
background:#101a2c;padding:.12em .4em;border-radius:3px}
/* ---- small screens: no pin, legend stacks under the scene ---- */
@media (max-width:860px){
.viewport{grid-template-columns:1fr;gap:20px}
.legend{height:auto;min-height:0;display:flex;flex-direction:column;gap:14px}
.legend li{position:static;transform:none;opacity:1;border-left-color:var(--line)}
.legend li.lit{border-left-color:var(--cyan)}
.pin{min-height:0;padding:4vh 18px 8vh}
.masthead{padding:10vh 22px 6vh}
}
/* ---- pilot bench (HUD + hand scrub) - strip before P3 wiring ---- */
.bench{width:min(720px,100%);margin:18px auto 0;text-align:center}
.bench input{width:100%;accent-color:var(--cyan)}
.bench .hud{font-family:var(--mono);font-size:.68rem;letter-spacing:.06em;
color:var(--dim);margin:.5rem 0 0}
/* ---- no JS: the flat hero and the full legend are the page ---- */
body:not(.fx) .slab{display:none}
body:not(.fx) .legend li{position:static;transform:none;opacity:1}
body:not(.fx) .legend{height:auto;min-height:0;display:flex;flex-direction:column;gap:14px}
</style>
<script>
(() => {
"use strict";
const MAN = __MANIFEST__;
const HERO = "__HERO__";
const scene = document.getElementById("scene");
const legend = document.getElementById("legend");
const RM = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---- legend (always built, JS or not the markup would be empty otherwise) ----
MAN.parts.forEach((p, i) => {
const li = document.createElement("li");
li.dataset.part = p.name;
li.style.top = p.anchor + "%";
li.innerHTML =
'<span class="n">' + String(i + 1).padStart(2, "0") + '</span>' +
'<p class="t">' + p.title + '</p>' +
'<p class="b">' + p.blurb + '</p>';
legend.appendChild(li);
});
if (!window.gsap || !window.ScrollTrigger) return; // progressive enhancement: leave the flat hero
document.body.classList.add("fx");
gsap.registerPlugin(ScrollTrigger);
// ---- slices: one layer per part, all cut from the SAME hero ----
const slabs = MAN.parts.map(p => {
const d = document.createElement("div");
d.className = "slab";
d.dataset.part = p.name;
d.style.backgroundImage = "url(" + HERO + ")";
d.style.clipPath = "polygon(" + p.points.map(q => q[0] + "% " + q[1] + "%").join(",") + ")";
scene.appendChild(d);
return d;
});
scene.querySelector(".flat").style.visibility = "hidden"; // slices replace it; alt text survives
const lis = [...legend.children];
const setLit = t => lis.forEach((li, i) => {
// a layer "arrives" as its own travel completes; base is home from the start
const share = MAN.parts[i].dy / (MAN.parts[MAN.parts.length - 1].dy || 1);
li.classList.toggle("lit", t >= share * 0.82);
});
// Travel is CLAMPED at the hero layout: collapsed -> hero, never past it and never
// sideways. Occlusion only increases on the way in, which is what keeps each slice's
// hidden notch buried. Overshoot or fan and the tears show.
const apply = t => {
slabs.forEach((el, i) => {
el.style.transform = "translate3d(0," + (MAN.parts[i].dy * (1 - t)).toFixed(3) + "%,0)";
});
scene.style.transform = "scale(" + (0.94 + 0.06 * t).toFixed(4) + ")";
setLit(t);
};
// ---- pilot bench: HUD + hand scrub - strip before P3 wiring ----
const bench = document.createElement("div");
bench.className = "bench";
bench.innerHTML =
'<input type="range" min="0" max="1000" value="0" aria-label="scrub the teardown">' +
'<p class="hud">boot</p>';
document.querySelector(".pin").appendChild(bench);
const hud = bench.querySelector(".hud");
const slider = bench.querySelector("input");
let mode = "boot";
const show = v => {
hud.textContent = mode + " \u00b7 " + innerWidth + "x" + innerHeight +
" \u00b7 scroll " + Math.round(scrollY) + " of " +
Math.max(0, document.documentElement.scrollHeight - innerHeight) +
" \u00b7 t=" + v.toFixed(2);
slider.value = String(Math.round(v * 1000));
};
const drive = v => { apply(v); show(v); };
slider.addEventListener("input", () => drive(+slider.value / 1000));
// The artifact viewer can size the iframe to the content and scroll the PARENT page:
// then this window never scrolls and a pinned scrub can never fire. Pick the engine
// by what the environment can actually do, not by viewport width alone.
const initPin = () => {
mode = "pin+scrub";
ScrollTrigger.create({
trigger: "#course-1",
start: "top top",
end: "+=2200",
pin: ".pin",
scrub: 0.6,
onUpdate: self => drive(self.progress),
onRefreshInit: () => apply(0)
});
ScrollTrigger.refresh();
drive(0);
};
const initAssemble = () => {
mode = "assemble";
document.querySelector(".pin").style.minHeight = "0"; // svh is degenerate when the frame is content-sized
drive(0);
const io = new IntersectionObserver(es => {
if (!es.some(e => e.isIntersecting)) return;
io.disconnect();
gsap.to({ v: 0 }, {
v: 1, duration: 1.6, ease: "power3.out",
onUpdate: function () { drive(this.targets()[0].v); }
});
}, { threshold: 0.15 });
io.observe(scene);
};
const boot = () => {
if (RM) { mode = "reduced-motion"; drive(1); return; }
const scrollable = document.documentElement.scrollHeight - innerHeight > 120;
if (scrollable && matchMedia("(min-width: 861px)").matches) initPin();
else initAssemble();
};
const flat = scene.querySelector(".flat");
(flat.decode ? flat.decode().catch(() => {}) : Promise.resolve())
.then(() => requestAnimationFrame(() => requestAnimationFrame(boot)));
})();
</script>