v2 source, NB2 prompt contract, and the slice editor

This commit is contained in:
2026-07-27 10:59:22 +10:00
commit e45f5729fa
10 changed files with 2200 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
assets/work/
+26
View File
@@ -0,0 +1,26 @@
# The Exploded Cluster
Interactive scroll-driven teardown of Kubernetes / OpenShift / Docker, in the estate's
Bat-Computer visual world (void `#070b14`, cyan `#3fbaf5` → magenta `#e879f9`).
- **v2** (tag `v2`) — CSS `preserve-3d` diagrams. Parked: eyeballed px positions, misaligned at some widths.
- **v3** (in progress) — 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`.
## 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 |
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.
+634
View File
@@ -0,0 +1,634 @@
<title>The Exploded Cluster — a five-course teardown of Kubernetes, OpenShift & Docker</title>
<style>
/* ============ THE EXPLODED CLUSTER v2 — five-course 3D teardown ============
GSAP 3.13 + ScrollTrigger inlined (100% free incl. plugins since 2025).
Committed single dark world: control-room hologram. Progressive enhancement:
without JS everything renders assembled + readable; JS adds the 3D theatre. */
:root {
--void: #070b14; --panel: #0d1424; --panel-2: #111a2e; --line: #1d2a44;
--cyan: #3fbaf5; --cyan-dim: #1f6f9c; --magenta: #e879f9; --violet: #8b5cf6;
--amber: #fbbf24; --green: #34d399; --text: #dbe7f3; --muted: #93a9c2;
--mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
--sans: system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
}
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
background: var(--void); color: var(--text); font-family: var(--sans);
line-height: 1.6; overflow-x: hidden; margin: 0;
}
#stars { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
body::before {
content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(63,186,245,.09), transparent 60%),
linear-gradient(rgba(63,186,245,.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(63,186,245,.03) 1px, transparent 1px);
background-size: 100% 100%, 44px 44px, 44px 44px;
}
main { position: relative; z-index: 1; }
#bar {
position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 60;
background: linear-gradient(90deg, var(--cyan), var(--magenta));
transform-origin: 0 50%; transform: scaleX(0);
}
nav { position: fixed; left: 18px; top: 50%; transform: translateY(-50%); z-index: 50;
display: flex; flex-direction: column; gap: 10px; }
nav a { display: flex; align-items: center; gap: 9px; color: var(--muted); text-decoration: none;
font: 600 10px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; }
nav a::before { content: ""; width: 8px; height: 8px; border-radius: 50%;
border: 1px solid var(--cyan-dim); transition: background .3s, box-shadow .3s; }
nav a span { opacity: 0; transform: translateX(-4px); transition: opacity .25s, transform .25s; }
nav a:hover span, nav a:focus-visible span, nav a.on span { opacity: 1; transform: none; }
nav a.on { color: var(--cyan); }
nav a.on::before { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
@media (max-width: 900px) { nav { display: none; } }
/* ---------- courses ---------- */
.course-tag {
display: inline-flex; align-items: center; gap: 10px;
font: 700 11px/1 var(--mono); letter-spacing: .3em; text-transform: uppercase;
color: var(--magenta); border: 1px solid rgba(232,121,249,.35); border-radius: 999px;
padding: 7px 16px; background: rgba(232,121,249,.06); margin-bottom: 16px;
}
.eyebrow { font: 700 11px/1 var(--mono); letter-spacing: .3em; text-transform: uppercase;
color: var(--cyan); display: flex; align-items: center; gap: 14px; }
.eyebrow::after { content: ""; height: 1px; flex: 1;
background: linear-gradient(90deg, var(--cyan-dim), transparent); }
h2 { font-size: clamp(1.7rem, 4.4vw, 2.7rem); font-weight: 800; letter-spacing: -.015em;
margin: 14px 0 18px; text-wrap: balance; }
.copy { max-width: 68ch; color: var(--muted); font-size: 1.02rem; }
.copy b { color: var(--text); font-weight: 650; }
section { padding: 12vh 20px; max-width: 1060px; margin: 0 auto; position: relative; }
/* term chips + tooltip (unchanged machinery) */
.term { font: 600 .86em/1 var(--mono); letter-spacing: .02em; color: var(--cyan);
background: rgba(63,186,245,.09); border: 1px solid rgba(63,186,245,.28); border-radius: 6px;
padding: 2px 7px 3px; cursor: help; white-space: nowrap; transition: background .2s, box-shadow .2s;
-webkit-tap-highlight-color: transparent; }
button.term { font-family: var(--mono); }
.term:hover, .term:focus-visible { background: rgba(63,186,245,.18); box-shadow: 0 0 14px rgba(63,186,245,.25); outline: none; }
.term:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.term.m { color: var(--magenta); background: rgba(232,121,249,.09); border-color: rgba(232,121,249,.3); }
.term.m:hover, .term.m:focus-visible { background: rgba(232,121,249,.18); box-shadow: 0 0 14px rgba(232,121,249,.22); }
.term.v { color: #b39dfa; background: rgba(139,92,246,.12); border-color: rgba(139,92,246,.35); }
#tip { position: fixed; z-index: 70; max-width: 300px; pointer-events: none;
background: #0b1322; border: 1px solid var(--cyan-dim); border-radius: 10px;
padding: 10px 13px; font-size: .84rem; line-height: 1.5; color: var(--text);
box-shadow: 0 12px 40px rgba(0,0,0,.6), 0 0 22px rgba(63,186,245,.14);
opacity: 0; transform: translateY(5px); transition: opacity .18s, transform .18s; }
#tip.show { opacity: 1; transform: none; }
#tip b { color: var(--cyan); display: block; font: 700 11px/1 var(--mono);
letter-spacing: .12em; text-transform: uppercase; margin-bottom: 6px; }
.phrase { margin: 30px 0 0; padding: 18px 22px; border-radius: 12px;
border: 1px solid rgba(63,186,245,.3); max-width: 60ch;
background: linear-gradient(115deg, rgba(63,186,245,.10), rgba(232,121,249,.07)); }
.phrase small { display: block; font: 700 10px/1 var(--mono); letter-spacing: .26em;
text-transform: uppercase; color: var(--cyan); margin-bottom: 8px; }
.phrase q { font-size: 1.12rem; font-weight: 700; color: var(--text); quotes: "“" "”"; }
.fnote { margin: 14px 0 0; padding: 12px 16px; border-left: 3px solid var(--amber);
background: rgba(251,191,36,.06); border-radius: 0 10px 10px 0; color: var(--muted);
font-size: .92rem; max-width: 60ch; }
.fnote b { color: var(--amber); font: 700 10px/1 var(--mono); letter-spacing: .2em;
text-transform: uppercase; margin-right: 8px; }
/* ---------- COURSE 00: the terminal (pinned intro) ---------- */
#aperitif { max-width: none; padding: 0; }
.pinstage { height: 100vh; display: grid; place-items: center; overflow: hidden;
perspective: 1100px; position: relative; }
.crt {
width: min(720px, 92vw); border-radius: 14px; position: relative;
background: #050910; border: 1px solid #16324a;
box-shadow: 0 0 0 1px rgba(63,186,245,.12), 0 30px 90px rgba(0,0,0,.7), 0 0 60px rgba(63,186,245,.12);
transform-style: preserve-3d; will-change: transform;
}
.crt-head { display: flex; align-items: center; gap: 8px; padding: 11px 15px;
border-bottom: 1px solid #14293d; font: 600 11px/1 var(--mono); color: var(--cyan-dim);
letter-spacing: .12em; }
.crt-head i { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
.crt-head i:nth-child(1){ background:#ff5f57; } .crt-head i:nth-child(2){ background:#febc2e; }
.crt-head i:nth-child(3){ background:#28c840; }
.crt-head span { margin-left: 8px; }
.crt-body { padding: 18px 20px 22px; font: 500 13.5px/1.75 var(--mono); min-height: 340px;
color: #b7d7ee; white-space: pre-wrap; }
.crt-body .ln { display: block; min-height: 1.7em; }
.crt-body .p1 { color: var(--green); } /* prompt */
.crt-body .cy { color: var(--cyan); }
.crt-body .mg { color: var(--magenta); }
.crt-body .dm { color: #5e7a94; }
.crt-body .ok { color: var(--green); }
.caret { display: inline-block; width: 8px; height: 15px; background: var(--cyan);
vertical-align: -2px; animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.crt::after { /* scanlines */
content: ""; position: absolute; inset: 0; border-radius: 14px; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(63,186,245,.045) 0 1px, transparent 1px 3px);
mix-blend-mode: screen;
}
#skip { position: absolute; right: 22px; bottom: 20px; z-index: 5;
font: 600 10px/1 var(--mono); letter-spacing: .22em; text-transform: uppercase;
color: var(--muted); background: transparent; border: 1px solid var(--line);
border-radius: 999px; padding: 8px 14px; cursor: pointer; }
#skip:hover, #skip:focus-visible { color: var(--cyan); border-color: var(--cyan-dim); outline: none; }
.scrollcue { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%);
font: 600 11px/1.8 var(--mono); letter-spacing: .24em; text-transform: uppercase;
color: var(--cyan); opacity: 0; text-align: center; }
.scrollcue i { display: block; font-style: normal; animation: bob 2s ease-in-out infinite; }
@keyframes bob { 50% { transform: translateY(7px); } }
/* the big title revealed behind the exploding terminal */
.titlewrap { position: absolute; inset: 0; display: grid; place-content: center;
text-align: center; pointer-events: none; transform-style: preserve-3d; }
.bigtitle { font-weight: 800; letter-spacing: -.02em; line-height: .96; margin: 0;
font-size: clamp(2.6rem, 9vw, 6.6rem); transform-style: preserve-3d; }
.bigtitle .x1 { display: block; color: var(--text); }
.bigtitle .x2 { display: block;
background: linear-gradient(92deg, var(--cyan) 15%, var(--magenta) 85%);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 26px rgba(63,186,245,.35)); }
.bigtitle .ch { display: inline-block; transform-style: preserve-3d; will-change: transform; }
.menuline { margin-top: 26px; color: var(--muted); font: 600 12px/1.6 var(--mono);
letter-spacing: .18em; text-transform: uppercase; }
.menuline b { color: var(--magenta); }
/* ---------- shared stage machinery (v2: real depth) ---------- */
.stage { position: relative; margin: 44px auto 0; border: 1px solid var(--line);
border-radius: 16px; overflow: hidden; perspective: 1000px;
background: radial-gradient(60% 90% at 50% 0%, rgba(63,186,245,.05), transparent), var(--panel); }
.stage .scene { position: absolute; inset: 0; transform-style: preserve-3d; }
.stage svg.wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.stage svg.wires line { stroke: var(--cyan-dim); stroke-width: 1.5; fill: none;
vector-effect: non-scaling-stroke; opacity: 0; transition: opacity .8s .9s; }
.live .stage svg.wires line { opacity: .8; }
.stage svg.wires .flow { stroke: var(--cyan); stroke-dasharray: 6 10; }
.live .stage svg.wires .flow { animation: dashflow 1.4s linear infinite; }
@keyframes dashflow { to { stroke-dashoffset: -16; } }
.p { position: absolute; will-change: transform;
transform: translate(calc(-50% + var(--sx,0px)), calc(-50% + var(--sy,0px)))
translateZ(var(--sz, -140px)) scale(var(--sc,.82));
transition: transform 1s cubic-bezier(.22,.9,.3,1); }
.live .p { transform: translate(-50%,-50%) translateZ(0) scale(1); }
.live .p:nth-child(2){transition-delay:.06s} .live .p:nth-child(3){transition-delay:.12s}
.live .p:nth-child(4){transition-delay:.18s} .live .p:nth-child(5){transition-delay:.24s}
.live .p:nth-child(6){transition-delay:.30s} .live .p:nth-child(7){transition-delay:.36s}
.live .p:nth-child(8){transition-delay:.42s} .live .p:nth-child(9){transition-delay:.48s}
.card { border: 1px solid var(--line); border-radius: 12px; padding: 12px 15px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
box-shadow: 0 10px 30px rgba(0,0,0,.35); text-align: left; min-width: 118px;
transform-style: preserve-3d; position: relative;
transform: rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg)); transition: transform .18s ease-out; }
.card::after { /* glare that follows tilt */
content: ""; position: absolute; inset: 0; border-radius: 12px; pointer-events: none;
background: radial-gradient(300px 160px at var(--gx,50%) var(--gy,50%), rgba(63,186,245,.13), transparent 70%);
opacity: 0; transition: opacity .25s; }
.card:hover::after { opacity: 1; }
.card h4 { margin: 0; font: 700 12px/1.2 var(--mono); letter-spacing: .06em; color: var(--text);
transform: translateZ(22px); }
.card p { margin: 5px 0 0; font-size: .74rem; color: var(--muted); line-height: 1.45;
max-width: 21ch; transform: translateZ(14px); }
.card.c { border-color: rgba(63,186,245,.5); box-shadow: 0 0 26px rgba(63,186,245,.14), 0 10px 30px rgba(0,0,0,.35); }
.card.c h4 { color: var(--cyan); }
.card.m { border-color: rgba(232,121,249,.5); box-shadow: 0 0 26px rgba(232,121,249,.14), 0 10px 30px rgba(0,0,0,.35); }
.card.m h4 { color: var(--magenta); }
.card.v { border-color: rgba(139,92,246,.55); box-shadow: 0 0 26px rgba(139,92,246,.16), 0 10px 30px rgba(0,0,0,.35); }
.card.v h4 { color: #b39dfa; }
.card.a { border-color: rgba(251,191,36,.5); } .card.a h4 { color: var(--amber); }
.card.g { border-color: rgba(52,211,153,.5); } .card.g h4 { color: var(--green); }
.stage-cap { position: absolute; left: 16px; bottom: 12px; font: 600 10px/1 var(--mono);
letter-spacing: .2em; text-transform: uppercase; color: var(--cyan-dim); }
#st-cluster { height: 420px; } #st-cp { height: 430px; }
#st-node { height: 430px; } #st-pod { height: 470px; } #st-traffic { height: 380px; }
.capsule { width: 300px; height: 190px; border-radius: 100px;
border: 2px dashed rgba(63,186,245,.5); box-shadow: inset 0 0 40px rgba(63,186,245,.06); }
.ring { width: 340px; height: 230px; border-radius: 120px;
border: 1.5px solid rgba(232,121,249,.4); display: grid; place-content: end center;
padding-bottom: 6px; font: 600 10px/1 var(--mono); letter-spacing: .18em;
color: var(--magenta); text-transform: uppercase; }
/* ---------- COURSE 01: docker 3D layer rise (pinned) ---------- */
#lay-stage { height: 100vh; display: grid; place-items: center; perspective: 1200px;
overflow: hidden; position: relative; }
#laystack { position: relative; width: min(420px, 80vw); height: 380px;
transform-style: preserve-3d; transform: rotateX(14deg); }
.layer3d { position: absolute; left: 50%; width: 100%;
transform: translateX(-50%) translateZ(var(--z, 0px)) translateY(var(--y, 0px));
border-radius: 12px; padding: 15px 20px; font: 700 13px/1.3 var(--mono); letter-spacing: .05em;
border: 1px solid rgba(139,92,246,.5); color: #cfc3fb;
background: linear-gradient(180deg, rgba(139,92,246,.18), rgba(13,20,36,.94));
box-shadow: 0 18px 44px rgba(0,0,0,.5); will-change: transform, opacity;
transform-style: preserve-3d; }
.layer3d small { display: block; font: 400 .7rem/1.4 var(--sans); color: var(--muted);
letter-spacing: 0; margin-top: 4px; }
.layer3d.base { border-color: rgba(63,186,245,.55); color: #ace0fb;
background: linear-gradient(180deg, rgba(63,186,245,.15), rgba(13,20,36,.94)); }
.layer3d.top { border-color: rgba(52,211,153,.6); color: #a7ecd3;
background: linear-gradient(180deg, rgba(52,211,153,.16), rgba(13,20,36,.94)); }
.laycard { position: absolute; will-change: transform, opacity; }
#lay-cap { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
font: 600 11px/1 var(--mono); letter-spacing: .26em; text-transform: uppercase;
color: var(--cyan-dim); white-space: nowrap; }
/* ---------- COURSE 05: OCP orbit ring (pinned) ---------- */
#orbit-stage { height: 100vh; display: grid; place-items: center; perspective: 1300px;
overflow: hidden; position: relative; }
#orbit { position: relative; width: 300px; height: 300px; transform-style: preserve-3d;
will-change: transform; }
#orbit .sat { position: absolute; left: 50%; top: 50%; width: 178px; margin-left: -89px;
transform: translateY(-50%) rotateY(var(--a)) translateZ(300px);
backface-visibility: hidden; }
#core { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); z-index: 2; }
#orbit-cap { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
font: 600 11px/1 var(--mono); letter-spacing: .26em; text-transform: uppercase;
color: var(--cyan-dim); white-space: nowrap; }
.hop { padding: 12px 16px; border-radius: 999px; font: 700 12px/1 var(--mono);
letter-spacing: .05em; border: 1px solid var(--line); background: var(--panel-2);
color: var(--text); box-shadow: 0 8px 24px rgba(0,0,0,.4); }
.book { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 34px; }
.book .term { font-size: .86rem; padding: 8px 13px; border-radius: 9px; }
footer { text-align: center; padding: 12vh 20px 9vh; color: var(--muted); font-size: .9rem; }
footer .sig { font: 600 11px/1 var(--mono); letter-spacing: .24em; text-transform: uppercase;
color: var(--cyan-dim); margin-top: 10px; }
@media (max-width: 720px) {
section { padding: 10vh 14px; }
.card { min-width: 92px; padding: 9px 11px; }
.card p { display: none; }
#st-cluster, #st-cp, #st-node { height: 340px; }
#st-pod { height: 400px; } #st-traffic { height: 320px; }
.capsule { width: 220px; height: 150px; } .ring { width: 258px; height: 188px; }
#orbit .sat { width: 132px; margin-left: -66px; }
.crt-body { min-height: 300px; font-size: 12px; }
}
/* reduced motion: everything assembled, no pins (JS also respects this via matchMedia) */
@media (prefers-reduced-motion: reduce) {
.p, .live .p { transition: none; transform: translate(-50%,-50%) translateZ(0) scale(1); }
.stage svg.wires line { opacity: .8; transition: none; }
.live .stage svg.wires .flow, .scrollcue i, .caret { animation: none; }
.card { transition: none; }
}
/* no-JS / pre-JS safety: assembled by default; JS flips body.fx on to arm the theatre */
body:not(.fx) .p { transform: translate(-50%,-50%) translateZ(0) scale(1); transition: none; }
body:not(.fx) .stage svg.wires line { opacity: .8; }
body:not(.fx) .scrollcue { opacity: 1; }
/* …and the hero degrades to a static stack (title above terminal), nothing overlapping */
body:not(.fx) .pinstage { height: auto; padding: 14vh 20px 8vh; place-items: center;
grid-template-rows: auto auto auto; gap: 44px; }
body:not(.fx) .titlewrap { position: static; pointer-events: auto; }
body:not(.fx) .scrollcue { position: static; transform: none; margin-top: 6px; }
body:not(.fx) #skip { display: none; }
</style>
<div id="bar" role="presentation"></div>
<canvas id="stars" aria-hidden="true"></canvas>
<nav aria-label="Courses">
<a href="#aperitif"><span>Aperitif</span></a>
<a href="#docker"><span>I&nbsp;·&nbsp;Docker</span></a>
<a href="#machine"><span>II&nbsp;·&nbsp;Machine</span></a>
<a href="#runtime"><span>III&nbsp;·&nbsp;Runtime</span></a>
<a href="#traffic"><span>IV&nbsp;·&nbsp;Traffic</span></a>
<a href="#ocp"><span>V&nbsp;·&nbsp;OpenShift</span></a>
<a href="#book"><span>Phrasebook</span></a>
</nav>
<main>
<!-- ============ COURSE 00 — APERITIF: the terminal ============ -->
<section id="aperitif" aria-label="Intro: an animated terminal that explodes into the title">
<div class="pinstage" id="hero-pin">
<div class="titlewrap" aria-hidden="true">
<h1 class="bigtitle" id="bigtitle"><span class="x1">THE EXPLODED</span><span class="x2">CLUSTER</span></h1>
<div class="menuline">a <b>five-course</b> tasting menu · kubernetes · openshift · docker</div>
</div>
<div class="crt" id="crt" role="img" aria-label="Terminal: docker build, docker push, kubectl apply, pod becomes Running — but what actually just happened?">
<div class="crt-head"><i></i><i></i><i></i><span>jonny@edge — bash</span></div>
<div class="crt-body" id="crtbody"><span class="caret" id="caret"></span></div>
</div>
<button id="skip" type="button">skip intro ▸</button>
<div class="scrollcue" id="cue">scroll to tear it down<i></i></div>
</div>
</section>
<!-- ============ COURSE 01 — DOCKER ============ -->
<section id="docker">
<div class="course-tag">Course I — entrée</div>
<div class="eyebrow">Image &amp; Container · Docker</div>
<h2>A container is a lie the kernel tells a process.</h2>
<p class="copy">That terminal did more than it admitted. <code>docker build</code> turned each Dockerfile step into an immutable
<button class="term v" data-def="One filesystem diff per Dockerfile instruction. Layers are content-addressed, cached, and shared between images — order your Dockerfile so the volatile bits come last.">layer</button>,
stacked by an
<button class="term v" data-def="OverlayFS: read-only layers stacked, one thin writable layer on top. Files 'change' by being copied up; the lower layers never change.">overlay filesystem</button>
the running container adds one thin writable layer that dies with it. Shipping is a push to a
<button class="term v" data-def="Where images live: Docker Hub, quay.io, a private mirror. Push and pull move only the layers the other side doesn't have.">registry</button>.
The trap: a
<button class="term" data-def="A mutable pointer. :latest, :v2, :prod — all can silently move to a different image. Fine for humans, dangerous for production manifests.">tag</button>
is a pointer that can move under you; a
<button class="term" data-def="The SHA-256 of the image content itself. @sha256:… names the artefact, not a label on it. Pin production to digests.">digest</button>
is the artefact itself. At run time, isolation is just the kernel:
<button class="term v" data-def="Kernel namespaces limit what a process can SEE — its own PID tree, network stack, mounts, hostname.">namespaces</button>
decide what a process <em>sees</em>,
<button class="term v" data-def="Control groups limit what a process can USE — CPU, memory, IO. Your pod's resource limits become cgroup values.">cgroups</button>
decide what it <em>uses</em>. No VM — very confident bookkeeping. Scroll: the image rises out of the deep, layer by layer.</p>
</section>
<div id="lay-stage" aria-label="3D image layer stack rising as you scroll">
<div id="laystack">
<div class="layer3d base" style="--y: 150px;" data-i="0">FROM ubi9-minimal <small>base OS — shared, cached, content-addressed</small></div>
<div class="layer3d" style="--y: 75px;" data-i="1">RUN install deps <small>a filesystem diff — cache hit if unchanged</small></div>
<div class="layer3d" style="--y: 0px;" data-i="2">COPY src/ /app <small>your code — order it late, bust the cache less</small></div>
<div class="layer3d top" style="--y: -75px;" data-i="3">writable layer <small>the running container — ephemeral, dies with it</small></div>
<div class="laycard" style="right:-40px; top:-40px;"><div class="card v"><h4>registry</h4><p>push / pull — only missing layers move</p></div></div>
<div class="laycard" style="left:-56px; bottom:-30px;"><div class="card a"><h4>tag vs digest</h4><p>:latest can move · @sha256 cannot</p></div></div>
</div>
<div id="lay-cap">read-only layers below · one writable lie on top</div>
</div>
<section aria-label="Docker key phrase">
<div class="phrase"><small>Say it like you mean it</small><q>Build once, promote by digest.</q></div>
<div class="fnote"><b>Field note</b>If your deploy "changed" without a code change, a floating tag moved
under you. Production manifests pin digests; humans read tags.</div>
</section>
<!-- ============ COURSE 02 — THE MACHINE ============ -->
<section id="machine">
<div class="course-tag">Course II — the machine</div>
<div class="eyebrow">The Cluster</div>
<h2>A cluster is a promise, not a place.</h2>
<p class="copy">You never tell Kubernetes <em>how</em> to run your app. You describe what you want —
<button class="term" data-def="You declare the end state (3 replicas, this image, this port) and the system computes the steps. The opposite of imperative scripts.">declarative intent</button>
and the cluster works, forever, to make it true. That habit is the
<button class="term" data-def="Controllers continuously compare what you asked for with what exists, and act to close the gap. Not once — in a loop, for the life of the cluster.">reconciliation loop</button>:
compare <button class="term" data-def="The spec you submitted — what SHOULD exist. Stored in etcd via the API server.">desired state</button>
against <button class="term" data-def="What is actually running right now — pods, their health, their nodes.">actual state</button>, fix the difference, repeat.
Two halves: a <b>control plane</b> that decides, and <b>worker nodes</b> that run. That
<code class="term" data-def="The CLI. It doesn't start containers — it submits and reads objects through the API server.">kubectl apply</code>
in the terminal didn't launch anything; it filed paperwork. The machine took it from there.</p>
<div class="stage" id="st-cluster">
<div class="scene">
<svg class="wires" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line class="flow" x1="50" y1="30" x2="22" y2="66"/><line class="flow" x1="50" y1="30" x2="50" y2="66"/>
<line class="flow" x1="50" y1="30" x2="78" y2="66"/>
</svg>
<div class="p" style="left:50%; top:24%; --sx:0px; --sy:110px; --sz:-220px;">
<div class="card m"><h4>CONTROL PLANE</h4><p>decides — holds truth, schedules, reconciles</p></div></div>
<div class="p" style="left:22%; top:72%; --sx:180px; --sy:-120px; --sz:-160px;">
<div class="card c"><h4>NODE 1</h4><p>runs pods</p></div></div>
<div class="p" style="left:50%; top:72%; --sx:0px; --sy:-120px; --sz:-260px;">
<div class="card c"><h4>NODE 2</h4><p>runs pods</p></div></div>
<div class="p" style="left:78%; top:72%; --sx:-180px; --sy:-120px; --sz:-160px;">
<div class="card c"><h4>NODE 3</h4><p>runs pods</p></div></div>
</div>
<span class="stage-cap">decide ▲ / run ▼ — the split that makes everything else possible</span>
</div>
<div class="phrase"><small>Say it like you mean it</small><q>Kubernetes doesn't run your app — it reconciles it.</q></div>
<div class="fnote"><b>Field note</b>If you're SSHing into nodes to "fix" things, you're arm-wrestling the
reconciler — and it doesn't get tired. Change the desired state instead.</div>
</section>
<section aria-label="Control plane">
<div class="eyebrow">The Control Plane</div>
<h2>Four processes and a filing cabinet.</h2>
<p class="copy">The
<button class="term" data-def="The single front door. Authenticates, authorises, validates, persists. Every component — kubectl, kubelets, controllers — talks THROUGH it, never around it.">kube-apiserver</button>
is the only door in the building. Behind it,
<button class="term" data-def="A distributed key-value store using the Raft consensus algorithm. The cluster's only source of truth. Loses quorum → the cluster loses its memory.">etcd</button>
is the cluster's memory — only the API server may touch it. The
<button class="term" data-def="Watches for pods with no node assigned. Filters nodes (can this pod run here?), scores the survivors (where best?), then binds the pod. It never runs anything itself.">scheduler</button>
picks <em>where</em> each pod lands — filter, score,
<button class="term" data-def="The scheduler's only write: patching the pod object with a node name. The kubelet on that node sees it and takes over.">bind</button>
and never runs a thing. The
<button class="term" data-def="A bundle of control loops: Deployment controller, ReplicaSet controller, node lifecycle, endpoints… each one reconciles its own object type.">controller-manager</button>
hosts the loops: a Deployment begets a
<button class="term" data-def="The controller that keeps N identical pods alive. Deployments manage ReplicaSets to do rolling updates — you rarely touch one directly.">ReplicaSet</button>,
which begets pods.</p>
<div class="stage" id="st-cp">
<div class="scene">
<svg class="wires" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line class="flow" x1="50" y1="50" x2="50" y2="20"/><line x1="24" y1="72" x2="44" y2="55"/>
<line x1="76" y1="72" x2="56" y2="55"/>
</svg>
<div class="p" style="left:50%; top:50%; --sx:0px; --sy:0px; --sz:60px; --sc:.9;">
<div class="card c"><h4>kube-apiserver</h4><p>the only door — authn, authz, admission, persistence</p></div></div>
<div class="p" style="left:50%; top:16%; --sx:0px; --sy:120px; --sz:-200px;">
<div class="card m"><h4>etcd</h4><p>consensus truth — quorum or nothing</p></div></div>
<div class="p" style="left:22%; top:76%; --sx:190px; --sy:-100px; --sz:-160px;">
<div class="card"><h4>scheduler</h4><p>filter → score → bind</p></div></div>
<div class="p" style="left:78%; top:76%; --sx:-190px; --sy:-100px; --sz:-160px;">
<div class="card"><h4>controller-manager</h4><p>the reconciliation loops</p></div></div>
</div>
<span class="stage-cap">everything watches the api-server — nothing talks sideways</span>
</div>
<div class="phrase"><small>Say it like you mean it</small><q>etcd is the truth; everything else is a cache.</q></div>
<div class="fnote"><b>Field note</b>The API server being the bottleneck is the design, not a flaw —
one authenticated, audited, admission-controlled door beats five convenient ones.</div>
</section>
<!-- ============ COURSE 03 — THE RUNTIME ============ -->
<section id="runtime">
<div class="course-tag">Course III — where it runs</div>
<div class="eyebrow">The Node</div>
<h2>Where intent becomes a process.</h2>
<p class="copy">Every node runs a
<button class="term" data-def="The node agent. Watches the API server for pods bound to its node, drives the container runtime, mounts volumes, runs your probes, reports status back.">kubelet</button>
the only component in the whole system that actually starts your container. It speaks
<button class="term" data-def="Container Runtime Interface — the plug standard between kubelet and runtime. containerd on most clusters; CRI-O on OpenShift.">CRI</button>
to <b>containerd</b> or <b>CRI-O</b>, which pulls images and launches containers. A
<button class="term" data-def="Container Network Interface — the plugin that gives each pod its own IP and wires it into the cluster network. Flannel, Calico, Cilium…">CNI plugin</button>
hands each pod a real IP, and
<button class="term" data-def="Programs the node's dataplane (iptables/IPVS rules) so Service VIPs actually route to live pods. Modern CNIs like Cilium replace it entirely with eBPF.">kube-proxy</button>
or an <button class="term" data-def="Programs run safely inside the kernel. Cilium uses eBPF to replace kube-proxy: service routing, network policy and observability at kernel speed.">eBPF</button>
dataplane replacing it — makes Service addresses route to real pods.</p>
<div class="stage" id="st-node">
<div class="scene">
<svg class="wires" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line class="flow" x1="50" y1="26" x2="28" y2="58"/><line x1="28" y1="66" x2="28" y2="80"/>
<line class="flow" x1="50" y1="26" x2="72" y2="58"/>
</svg>
<div class="p" style="left:50%; top:20%; --sx:0px; --sy:100px; --sz:40px; --sc:.9;">
<div class="card c"><h4>kubelet</h4><p>node agent — the only thing that starts your container</p></div></div>
<div class="p" style="left:28%; top:56%; --sx:120px; --sy:-80px; --sz:-180px;">
<div class="card v"><h4>containerd / CRI-O</h4><p>pulls images, runs containers (CRI)</p></div></div>
<div class="p" style="left:72%; top:56%; --sx:-120px; --sy:-80px; --sz:-180px;">
<div class="card"><h4>CNI plugin</h4><p>a real IP per pod</p></div></div>
<div class="p" style="left:28%; top:84%; --sx:120px; --sy:-140px; --sz:-240px;">
<div class="card g"><h4>pod ⬤⬤</h4><p>lands here</p></div></div>
<div class="p" style="left:72%; top:84%; --sx:-120px; --sy:-140px; --sz:-240px;">
<div class="card"><h4>kube-proxy / eBPF</h4><p>makes Services route</p></div></div>
</div>
<span class="stage-cap">kubelet drives · runtime executes · cni wires · proxy routes</span>
</div>
<div class="phrase"><small>Say it like you mean it</small><q>The kubelet is the only component that actually starts your container.</q></div>
<div class="fnote"><b>Field note</b>Node <code>NotReady</code>? Check kubelet ↔ api-server connectivity
before anything else — a node that can't phone home is presumed lost.</div>
</section>
<section aria-label="The pod">
<div class="eyebrow">The Pod</div>
<h2>One IP, shared fate.</h2>
<p class="copy">A pod isn't a container — it's a jacket around one or more. Everything inside shares a
<button class="term" data-def="Linux network namespace — the pod's containers share ONE IP and can reach each other on localhost. Held open by a tiny hidden 'pause' container.">network namespace</button>
(one IP, localhost between friends) and can share
<button class="term" data-def="Storage mounted into the pod — emptyDir scratch space, ConfigMaps, Secrets, PersistentVolumeClaims.">volumes</button>.
<button class="term" data-def="Run BEFORE the app containers, one at a time, each to completion. Perfect for wait-for-dependency gates, permission fixes, and refuse-to-start guards.">initContainers</button>
run first, in order — gatekeepers.
<button class="term" data-def="A helper container riding in the same pod: log shipper, proxy, config reloader. Shares the pod's network and volumes.">Sidecars</button>
ride along. Then the probes — three different jobs:
<button class="term" data-def="Owns warmup. While it runs, liveness and readiness stay out of the way. Generous budget — slow starts are normal.">startup</button> owns warmup,
<button class="term" data-def="Gates traffic. Failing readiness pulls the pod out of Service endpoints — it does NOT restart it.">readiness</button> gates traffic,
<button class="term" data-def="Restarts a genuinely hung process. Make it TOLERANT — a self-healing app killed mid-recovery recovers slower.">liveness</button>
restarts the truly hung. Confusing them is how healthy pods get executed.</p>
<div class="stage" id="st-pod">
<div class="scene">
<div class="p" style="left:50%; top:47%; --sx:0px; --sy:0px; --sz:-80px; --sc:.7;">
<div class="ring">shared network namespace — one IP</div></div>
<div class="p" style="left:50%; top:47%; --sx:0px; --sy:0px; --sz:-40px; --sc:.8;">
<div class="capsule" role="presentation"></div></div>
<div class="p" style="left:38%; top:44%; --sx:60px; --sy:10px; --sz:30px;">
<div class="card g"><h4>app container</h4><p>your process</p></div></div>
<div class="p" style="left:63%; top:52%; --sx:-60px; --sy:-10px; --sz:20px;">
<div class="card"><h4>sidecar</h4><p>proxy · logs · reload</p></div></div>
<div class="p" style="left:14%; top:16%; --sx:150px; --sy:110px; --sz:-200px;">
<div class="card a"><h4>initContainer</h4><p>runs first, to completion — the gatekeeper</p></div></div>
<div class="p" style="left:85%; top:16%; --sx:-150px; --sy:110px; --sz:-200px;">
<div class="card"><h4>volumes</h4><p>shared mounts</p></div></div>
<div class="p" style="left:16%; top:85%; --sx:140px; --sy:-120px; --sz:-160px;">
<div class="card c"><h4>probes</h4><p>startup → readiness → liveness</p></div></div>
</div>
<span class="stage-cap">smallest schedulable unit — the jacket, not the container</span>
</div>
<div class="phrase"><small>Say it like you mean it</small><q>containerPort is documentation — the app still has to bind the port itself.</q></div>
<div class="fnote"><b>Field note</b><code>exit 137</code> with reason <code>Error</code> is the kubelet's
probe kill; <code>OOMKilled</code> is memory. Same exit code, different crimes — read the reason.</div>
</section>
<!-- ============ COURSE 04 — TRAFFIC ============ -->
<section id="traffic">
<div class="course-tag">Course IV — the traffic</div>
<div class="eyebrow">Services &amp; Ingress</div>
<h2>Pods are cattle; Services are the brand.</h2>
<p class="copy">Pods are ephemeral — they die, respawn, change IPs. A
<button class="term" data-def="A stable virtual IP + DNS name over a set of pods chosen by label selector. The endpoints churn; the Service name never does.">Service</button>
is the stable name in front of the churn: a
<button class="term" data-def="The default Service type — a virtual IP reachable only inside the cluster.">ClusterIP</button>
inside, a
<button class="term" data-def="A Service exposed on an external IP — in the cloud, a cloud LB; on bare metal, something answering ARP for a VIP (MetalLB, Cilium LB-IPAM).">LoadBalancer</button>
at the edge, and
<button class="term" data-def="L7 routing: host/path rules + TLS termination in front of many Services. Gateway API is its successor.">Ingress</button>
(or the <b>Gateway API</b>) for host- and path-routing above them. Membership is decided by
<b>readiness</b> — a pod failing readiness silently leaves the pool. That's the feature. It's also the
first place to look when traffic "disappears".</p>
<div class="stage" id="st-traffic">
<div class="scene">
<svg class="wires" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<line class="flow" x1="12" y1="50" x2="34" y2="50"/><line class="flow" x1="40" y1="50" x2="62" y2="50"/>
<line class="flow" x1="68" y1="46" x2="84" y2="28"/><line class="flow" x1="68" y1="54" x2="84" y2="72"/>
</svg>
<div class="p" style="left:12%; top:50%; --sx:120px; --sy:0px; --sz:-140px;"><div class="hop">☺ client</div></div>
<div class="p" style="left:37%; top:50%; --sx:40px; --sy:0px; --sz:-180px;">
<div class="hop" style="border-color:rgba(232,121,249,.5); color:var(--magenta);">LB / Ingress</div></div>
<div class="p" style="left:65%; top:50%; --sx:-60px; --sy:0px; --sz:-140px;">
<div class="hop" style="border-color:rgba(63,186,245,.5); color:var(--cyan);">Service VIP</div></div>
<div class="p" style="left:88%; top:24%; --sx:-140px; --sy:60px; --sz:-200px;">
<div class="card g"><h4>pod · ready</h4><p>in the pool</p></div></div>
<div class="p" style="left:88%; top:76%; --sx:-140px; --sy:-60px; --sz:-200px;">
<div class="card a"><h4>pod · not ready</h4><p>silently removed</p></div></div>
</div>
<span class="stage-cap">readiness decides membership — traffic follows</span>
</div>
<div class="phrase"><small>Say it like you mean it</small><q>Never talk to a pod; talk to a Service.</q></div>
<div class="fnote"><b>Field note</b>"The network is broken" after a deploy is usually readiness telling
the truth about your app — not the network lying about your packets.</div>
</section>
<!-- ============ COURSE 05 — OPENSHIFT (orbit) ============ -->
<section id="ocp">
<div class="course-tag">Course V — dessert</div>
<div class="eyebrow">OpenShift</div>
<h2>Kubernetes with opinions — and a security guard.</h2>
<p class="copy">OpenShift is a <em>distribution</em> of Kubernetes: same engine, opinionated chassis. The doorman is the
<button class="term m" data-def="Security Context Constraints — OpenShift's pod admission policy. Decides what a pod may BE: which UIDs, capabilities, volumes, host access.">SCC</button>:
the default,
<button class="term m" data-def="The default SCC. Non-root, RANDOM UID, capabilities dropped, no host access. Your image must cope with an arbitrary UID — design for GID 0.">restricted-v2</button>,
runs your container as a <b>random non-root UID</b> — your image has to cope. Workloads that genuinely
need privilege get a <b>dedicated ServiceAccount</b> bound to a <b>minimal custom SCC</b> — never the stock one.
<button class="term m" data-def="OpenShift's L7 exposure object — it predates Ingress and is still idiomatic on OCP. HAProxy-backed by default.">Routes</button>
predate Ingress and still rule here.
<button class="term m" data-def="A controller + CRDs that manage software the way you'd manage it: install, upgrade, reconfigure, self-heal. OpenShift runs ITSELF as operators.">Operators</button>
run the platform itself, and
<button class="term m" data-def="Single-Node OpenShift — the entire control plane and workload on one box. The edge form factor: a shop's back office, a factory floor.">SNO</button>
puts the whole thing on one box in a shop's back office. The OS underneath is immutable — changed by
<button class="term m" data-def="Declarative node configuration for immutable RHCOS — files, units, kernel args. Applying one rolls/reboots nodes. No SSH-and-tweak.">MachineConfig</button>,
not SSH. Scroll — the opinions orbit the core.</p>
</section>
<div id="orbit-stage" aria-label="OpenShift components orbiting the Kubernetes core in 3D">
<div id="core"><div class="card c" style="text-align:center;"><h4>KUBERNETES CORE</h4><p>same engine underneath</p></div></div>
<div id="orbit">
<div class="sat" style="--a: 0deg;"><div class="card m"><h4>SCC</h4><p>admission: what a pod may BE</p></div></div>
<div class="sat" style="--a: 60deg;"><div class="card m"><h4>Routes</h4><p>L7 exposure, pre-Ingress</p></div></div>
<div class="sat" style="--a: 120deg;"><div class="card m"><h4>Operators / OLM</h4><p>the platform runs itself</p></div></div>
<div class="sat" style="--a: 180deg;"><div class="card m"><h4>oc + console</h4><p>kubectl, extended</p></div></div>
<div class="sat" style="--a: 240deg;"><div class="card m"><h4>MachineConfig</h4><p>immutable OS, declared changes</p></div></div>
<div class="sat" style="--a: 300deg;"><div class="card m"><h4>SNO</h4><p>the whole cluster on one edge box</p></div></div>
</div>
<div id="orbit-cap">a ring of opinions around the same core — scroll to spin it</div>
</div>
<section aria-label="OpenShift key phrase">
<div class="phrase"><small>Say it like you mean it</small><q>On OpenShift, admission is the interview — the SCC is the dress code.</q></div>
<div class="fnote"><b>Field note</b>Deployment stuck at 0/1 with <em>no pod at all</em>? The refusal
happened above scheduling — read the ReplicaSet events. The error lives a level up.</div>
</section>
<!-- ============ PHRASEBOOK ============ -->
<section id="book">
<div class="course-tag">Petits fours</div>
<div class="eyebrow">Phrasebook</div>
<h2>Take the words with you.</h2>
<p class="copy">Hover (or tap) any term — the load-bearing phrases of the whole trade. Use them precisely
and people will assume, correctly, that you know where the bodies are buried.</p>
<div class="book">
<button class="term" data-def="You declare the end state; the system computes the steps.">declarative</button>
<button class="term" data-def="Compare desired vs actual, act, repeat forever. The heartbeat of Kubernetes.">reconciliation loop</button>
<button class="term" data-def="The spec you asked for, stored in etcd. The reconciler's target.">desired state</button>
<button class="term" data-def="The consensus store holding cluster truth. Quorum or nothing.">etcd</button>
<button class="term" data-def="Filter nodes, score survivors, bind the pod. Runs nothing itself.">scheduling</button>
<button class="term" data-def="The node agent — the only component that actually starts containers.">kubelet</button>
<button class="term" data-def="Container Runtime Interface: kubelet ↔ containerd/CRI-O.">CRI</button>
<button class="term" data-def="Container Network Interface: a real IP per pod.">CNI</button>
<button class="term" data-def="Kernel-resident programs replacing kube-proxy in modern CNIs like Cilium.">eBPF</button>
<button class="term" data-def="Smallest schedulable unit — a jacket around containers sharing an IP and volumes.">pod</button>
<button class="term" data-def="Runs before app containers, in order, to completion. The gatekeeper.">initContainer</button>
<button class="term" data-def="Startup owns warmup, readiness gates traffic, liveness restarts the hung.">probes</button>
<button class="term v" data-def="One immutable filesystem diff per Dockerfile step. Cached, shared, content-addressed.">image layer</button>
<button class="term v" data-def="What a process can SEE (namespaces) vs what it can USE (cgroups). That's a container.">namespaces + cgroups</button>
<button class="term" data-def="A mutable pointer to an image. :latest can and will move.">tag</button>
<button class="term" data-def="@sha256:… — the content address of the artefact itself. Pin production to this.">digest</button>
<button class="term" data-def="A stable VIP + DNS name over churning pods, membership decided by readiness.">Service</button>
<button class="term" data-def="L7 host/path routing + TLS in front of Services. Gateway API is the successor.">Ingress</button>
<button class="term" data-def="Git as the desired state; an agent (ArgoCD/Flux) reconciles the cluster to it. kubectl becomes read-only.">GitOps</button>
<button class="term m" data-def="OpenShift pod admission: what a pod may BE. Default restricted-v2 = random non-root UID.">SCC</button>
<button class="term m" data-def="OpenShift's L7 exposure object, older than Ingress and still idiomatic.">Route</button>
<button class="term m" data-def="CRDs + a controller that install, upgrade and heal software autonomously.">Operator</button>
<button class="term m" data-def="Extend the Kubernetes API with your own object types — the mechanism behind operators.">CRD</button>
<button class="term m" data-def="Single-Node OpenShift: the whole cluster on one edge box.">SNO</button>
<button class="term" data-def="A workload that starts only if its preconditions hold — and refuses loudly otherwise.">fail-closed</button>
<button class="term" data-def="Immutable host OS: nodes are replaced or declaratively reconfigured, never hand-edited.">immutable infrastructure</button>
</div>
</section>
<footer>
<div>Cut from real clusters — every field note above cost somebody a late night.</div>
<div class="sig">the exploded cluster · five courses · bztmon</div>
</footer>
</main>
<div id="tip" role="tooltip" aria-hidden="true"></div>
+252
View File
@@ -0,0 +1,252 @@
<script>
(() => {
"use strict";
const RM = matchMedia("(prefers-reduced-motion: reduce)").matches;
const heroPin = document.getElementById("hero-pin");
const crt = document.getElementById("crt");
const crtbody = document.getElementById("crtbody");
const caret = document.getElementById("caret");
const cue = document.getElementById("cue");
const skip = document.getElementById("skip");
const titlewrap = document.querySelector(".titlewrap");
/* ---------- split the big title into 3D chars ---------- */
document.querySelectorAll("#bigtitle .x1, #bigtitle .x2").forEach(el => {
const t = el.textContent; el.textContent = "";
[...t].forEach(c => {
const s = document.createElement("span");
s.className = "ch"; s.textContent = c === " " ? " " : c;
el.appendChild(s);
});
});
const chars = document.querySelectorAll("#bigtitle .ch");
/* ---------- terminal script ---------- */
const LINES = [
["p1", "jonny@edge:~$ ", "docker build -t shop/app:0723R1 ."],
["dm", " => [1/4] FROM ubi9-minimal", " CACHED"],
["dm", " => [2/4] RUN microdnf install deps", " 0.8s"],
["dm", " => [3/4] COPY src/ /app", " 0.1s"],
["dm", " => [4/4] USER 10001", " done"],
["cy", " => exporting layers ", "sha256:9f2c11ab…"],
["p1", "jonny@edge:~$ ", "docker push shop/app:0723R1"],
["p1", "jonny@edge:~$ ", "kubectl apply -f app.yaml"],
["ok", "deployment.apps/shop-app created", ""],
["ok", "service/shop-app created", ""],
["p1", "jonny@edge:~$ ", "kubectl get pods -w"],
["dm", "shop-app-7d4b9c-x2x3g 0/1 ContainerCreating", ""],
["ok", "shop-app-7d4b9c-x2x3g 1/1 Running", ""],
["mg", "…but what actually just happened?", ""]
];
let li = 0, done = false;
function renderAll() { // instant final state (skip / reduced motion / no theatre)
crtbody.innerHTML = "";
LINES.forEach(L => {
const ln = document.createElement("span");
ln.className = "ln";
ln.innerHTML = '<span class="' + L[0] + '"></span><span></span>';
ln.children[0].textContent = L[1]; ln.children[1].textContent = L[2];
crtbody.appendChild(ln);
});
crtbody.appendChild(caret);
finishIntro();
}
function typeLine() {
if (done) return;
if (li >= LINES.length) { finishIntro(); return; }
const L = LINES[li++];
const ln = document.createElement("span");
ln.className = "ln";
const a = document.createElement("span"); a.className = L[0]; a.textContent = L[1];
const b = document.createElement("span");
ln.appendChild(a); ln.appendChild(b);
crtbody.insertBefore(ln, caret);
const isCmd = L[0] === "p1";
if (!isCmd) { b.textContent = L[2]; setTimeout(typeLine, 170); return; }
let i = 0;
(function tick() {
if (done) return;
b.textContent = L[2].slice(0, ++i);
if (i < L[2].length) setTimeout(tick, 26);
else setTimeout(typeLine, 320);
})();
}
function finishIntro() {
if (done) return; done = true;
skip.style.display = "none";
cue.style.opacity = 1;
}
skip.addEventListener("click", () => { done = false; renderAll(); });
/* ---------- tooltip (hover / focus / tap) ---------- */
const tip = document.getElementById("tip");
let tipFor = null;
function showTip(el) {
tipFor = el;
tip.innerHTML = "<b></b>";
tip.firstChild.textContent = el.textContent.trim();
tip.appendChild(document.createTextNode(el.dataset.def));
tip.classList.add("show"); tip.setAttribute("aria-hidden", "false");
const r = el.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight;
let x = Math.max(10, Math.min(r.left + r.width / 2 - tw / 2, innerWidth - tw - 10));
let y = r.top - th - 10; if (y < 10) y = r.bottom + 10;
tip.style.left = x + "px"; tip.style.top = y + "px";
}
function hideTip() { tipFor = null; tip.classList.remove("show"); tip.setAttribute("aria-hidden", "true"); }
document.querySelectorAll(".term").forEach(el => {
el.addEventListener("mouseenter", () => showTip(el));
el.addEventListener("mouseleave", hideTip);
el.addEventListener("focus", () => showTip(el));
el.addEventListener("blur", hideTip);
el.addEventListener("click", ev => { ev.preventDefault();
(tipFor === el && tip.classList.contains("show")) ? hideTip() : showTip(el); });
});
addEventListener("scroll", () => { if (tipFor) hideTip(); }, { passive: true });
/* ---------- progress bar ---------- */
const bar = document.getElementById("bar");
let raf = 0;
addEventListener("scroll", () => {
if (raf) return;
raf = requestAnimationFrame(() => {
const h = document.documentElement;
bar.style.transform = "scaleX(" + (h.scrollTop / (h.scrollHeight - h.clientHeight || 1)) + ")";
raf = 0;
});
}, { passive: true });
/* ---------- section reveal + nav (IO, all contexts) ---------- */
const navLinks = document.querySelectorAll("nav a");
const io = new IntersectionObserver(ents => ents.forEach(e => {
if (!e.isIntersecting) return;
e.target.classList.add("live");
const id = e.target.id;
if (id) navLinks.forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + id));
}), { threshold: 0.3 });
document.querySelectorAll("section").forEach(s => io.observe(s));
/* ================= THE THEATRE (motion-safe contexts only) ================= */
if (RM || typeof gsap === "undefined") { renderAll(); document.body.classList.remove("fx"); return; }
document.body.classList.add("fx");
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.config({ ignoreMobileResize: true });
/* particles: capped starfield with scroll parallax */
const cv = document.getElementById("stars"), cx = cv.getContext("2d");
let W, H, DPR, stars = [];
function sizeCv() {
DPR = Math.min(devicePixelRatio || 1, 2);
W = cv.width = innerWidth * DPR; H = cv.height = innerHeight * DPR;
cv.style.width = innerWidth + "px"; cv.style.height = innerHeight + "px";
stars = Array.from({ length: innerWidth < 720 ? 46 : 90 }, () => ({
x: Math.random() * W, y: Math.random() * H,
z: .3 + Math.random() * .7, r: (.6 + Math.random() * 1.5) * DPR,
tw: Math.random() * Math.PI * 2, c: Math.random() < .12 ? "232,121,249" : "63,186,245"
}));
}
sizeCv(); addEventListener("resize", sizeCv);
let lastY = 0, running = true;
document.addEventListener("visibilitychange", () => { running = !document.hidden; if (running) loop(); });
function loop(t) {
if (!running) return;
const dy = (scrollY - lastY) * .18; lastY = scrollY;
cx.clearRect(0, 0, W, H);
for (const s of stars) {
s.y -= dy * s.z * DPR; s.tw += .02;
if (s.y < -8) s.y += H + 16; else if (s.y > H + 8) s.y -= H + 16;
cx.globalAlpha = .25 + .45 * s.z * (0.7 + 0.3 * Math.sin(s.tw));
cx.fillStyle = "rgba(" + s.c + ",1)";
cx.beginPath(); cx.arc(s.x, s.y, s.r * s.z, 0, 7); cx.fill();
}
cx.globalAlpha = 1;
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
/* card tilt (hover-capable pointers only) */
if (matchMedia("(hover:hover)").matches) {
let tiltRaf = 0;
document.addEventListener("pointermove", ev => {
const card = ev.target.closest(".card"); if (!card) return;
if (tiltRaf) return;
tiltRaf = requestAnimationFrame(() => {
const r = card.getBoundingClientRect();
const px = (ev.clientX - r.left) / r.width, py = (ev.clientY - r.top) / r.height;
card.style.setProperty("--ry", ((px - .5) * 16).toFixed(2) + "deg");
card.style.setProperty("--rx", ((.5 - py) * 12).toFixed(2) + "deg");
card.style.setProperty("--gx", (px * 100).toFixed(1) + "%");
card.style.setProperty("--gy", (py * 100).toFixed(1) + "%");
tiltRaf = 0;
});
}, { passive: true });
document.addEventListener("pointerout", ev => {
const card = ev.target.closest(".card"); if (!card) return;
card.style.setProperty("--rx", "0deg"); card.style.setProperty("--ry", "0deg");
}, { passive: true });
}
/* start typing the terminal */
gsap.set(titlewrap, { autoAlpha: 0 });
gsap.set(chars, { z: -600, autoAlpha: 0, rotateX: -70 });
setTimeout(typeLine, 500);
/* COURSE 00 — pin: terminal tilts back, flies apart; title chars fly in from depth */
const mm = gsap.matchMedia();
mm.add("(min-width: 721px)", () => {
gsap.timeline({
scrollTrigger: { trigger: heroPin, start: "top top", end: "+=1600", scrub: .6, pin: true }
})
.to(crt, { rotateX: 38, z: -180, y: -60, ease: "power1.in" }, 0)
.to(crt, { autoAlpha: 0, scale: 1.25, rotateX: 62, y: -220, ease: "power2.in", duration: .6 }, .35)
.to(cue, { autoAlpha: 0, duration: .15 }, 0)
.to("#skip", { autoAlpha: 0, duration: .1 }, 0)
.to(titlewrap, { autoAlpha: 1, duration: .2 }, .45)
.to(chars, { z: 0, autoAlpha: 1, rotateX: 0, stagger: { each: .012, from: "random" }, ease: "back.out(1.4)" }, .5)
.to("#bigtitle", { scale: .96, y: -14, ease: "none" }, .9);
/* COURSE 01 — pin: layers rise from the deep into the tilted stack */
const layers = gsap.utils.toArray(".layer3d");
gsap.set(layers, { z: -650, autoAlpha: 0, y: 260 });
gsap.set(".laycard", { autoAlpha: 0, y: 40 });
gsap.timeline({
scrollTrigger: { trigger: "#lay-stage", start: "top top", end: "+=1800", scrub: .6, pin: true }
})
.to(layers[0], { z: 0, autoAlpha: 1, y: 150, ease: "power2.out" })
.to(layers[1], { z: 46, autoAlpha: 1, y: 75, ease: "power2.out" })
.to(layers[2], { z: 92, autoAlpha: 1, y: 0, ease: "power2.out" })
.to(layers[3], { z: 138, autoAlpha: 1, y: -75, ease: "power2.out" })
.to("#laystack", { rotateX: 24, rotateZ: -6, ease: "none" }, "<")
.to(".laycard", { autoAlpha: 1, y: 0, stagger: .15 })
.to("#laystack", { rotateX: 10, rotateZ: 0, ease: "none" });
/* COURSE 05 — pin: the ring of opinions spins around the core */
gsap.timeline({
scrollTrigger: { trigger: "#orbit-stage", start: "top top", end: "+=2000", scrub: .7, pin: true }
})
.fromTo("#orbit", { rotateY: -15, rotateX: 8 }, { rotateY: 285, rotateX: 8, ease: "none" }, 0)
.fromTo("#core > .card", { scale: .7, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: .12 }, 0);
return () => {}; /* matchMedia cleanup handled by GSAP */
});
/* small screens: no pins — everything just assembles via IO; terminal still types */
mm.add("(max-width: 720px)", () => {
gsap.set(titlewrap, { autoAlpha: 0 });
ScrollTrigger.create({
trigger: heroPin, start: "bottom 70%",
onEnter: () => {
gsap.to(crt, { autoAlpha: 0, y: -80, duration: .5 });
gsap.to(titlewrap, { autoAlpha: 1, duration: .5 });
gsap.to(chars, { z: 0, autoAlpha: 1, rotateX: 0, stagger: .01, duration: .6 });
}
});
gsap.set(".layer3d", { clearProps: "all" });
gsap.set(".laycard", { clearProps: "all" });
gsap.fromTo("#orbit", { rotateY: -15 }, {
rotateY: 285, ease: "none",
scrollTrigger: { trigger: "#orbit-stage", start: "top 80%", end: "bottom 20%", scrub: .7 }
});
return () => {};
});
})();
</script>
File diff suppressed because one or more lines are too long
Vendored
+11
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
# NB2 prompt contract — The Exploded Cluster v3
Every scene prompt = MASTER STYLE BLOCK (verbatim, below) + the scene's content lines.
User generates in Nano Banana 2 (Gemini), 16:9, ~4 variations, pre-curates, drops in the NAS share.
## Master style block (paste verbatim at the start of every scene prompt)
Dark technical exploded-view illustration in a cinematic sci-fi engineering style.
Solid near-black deep void background, hex #070b14 — completely clean: no stars, no grid,
no texture, no particles behind or around the objects. Matte dark metal-and-glass surfaces
with glowing neon edge lighting: primary cyan #3fbaf5, secondary magenta #e879f9 accents,
cool white rim highlights. Camera: fixed 30-degree isometric angle, subject perfectly
centered with generous empty margins on all sides. Even studio lighting, no vignette,
no lens flare, no depth-of-field blur. ABSOLUTELY NO text, no labels, no numbers, no
arrows, no callout lines, no UI elements anywhere in the image. Every component is fully
separated from its neighbours by generous gaps of pure void — nothing touching, nothing
overlapping, and no beams, cables, or connectors spanning between the separated parts.
## Slicing constraints (WHY the weird rules — do not relax)
- Solid uniform void → overlapping clip-path slices blend invisibly when collapsed.
- No connectors between parts → a beam crossing a gap would tear/duplicate when parts move.
- No baked text → all labels/tooltips live in the DOM legend layer (a11y + no-JS).
- Centre-safe, generous margins → the same hero crops clean for the <721px stepper.
## Reject checklist (per candidate, before dropping in the share)
- [ ] any text/letters/numbers sneaked in anywhere (NB2 loves adding labels — reject hard)
- [ ] parts touching or overlapping, or anything spanning the gaps
- [ ] stars/grid/texture in the background instead of clean void
- [ ] camera not isometric (top-down / straight-on flat)
- [ ] subject clipped by the frame edge
+50
View File
@@ -0,0 +1,50 @@
# Scene prompt — Course I: Docker image layers (S0 SPIKE)
**v2 (2026-07-27)** — tuned from the first two candidates: keep shot 2's generous gaps,
kill the fake-code glyphs on the magenta layer (shot 1's abstract treatment wins).
Generate ~4 at 16:9, curate with the reject checklist, save to disk for pickup.
---
Dark technical exploded-view illustration in a cinematic sci-fi engineering style.
Solid near-black deep void background, hex #070b14 — completely clean: no stars, no grid,
no texture, no particles behind or around the objects. Matte dark metal-and-glass surfaces
with glowing neon edge lighting: primary cyan #3fbaf5, secondary magenta #e879f9 accents,
cool white rim highlights. Camera: fixed 30-degree isometric angle, subject perfectly
centered with generous empty margins on all sides. Even studio lighting, no vignette,
no lens flare, no depth-of-field blur. ABSOLUTELY NO text, no letters, no numbers, no
words, no code, no glyphs, no labels, no arrows, no callout lines, no UI elements
anywhere in the image — every surface marking must be an abstract geometric pattern
only. Every component is fully separated from its neighbours by generous gaps of pure
void — nothing touching, nothing overlapping, and no beams, cables, or connectors
spanning between the separated parts.
The subject: an exploded vertical stack of four flat square slabs floating in the void,
widely spaced along a vertical axis with large empty gaps of void between each one — the
layers of a container image, seen mid-explosion. Push the slabs far apart so each floats
clearly on its own with dark void visible between them.
Bottom slab: the thickest and heaviest, a solid foundational block of dark brushed metal
with a bright cyan glowing seam running around its edge.
Second slab up: thinner, dark, its top face covered in an intricate circuit-board trace
pattern glowing soft cyan — fine geometric pathways and nodes.
Third slab: thin and precise, dark, its top face decorated with sparse abstract glowing
magenta line-work — elegant angular geometric strokes and flowing traces, purely
decorative shapes with no resemblance to writing, text, or code.
Top slab: the thinnest, semi-transparent frosted glass with a soft white-magenta ghost
glow — barely-there and ephemeral.
All four slabs share the same square footprint and sit in perfect vertical alignment,
reading as one machine pulled apart for inspection.
---
## Build-side notes (not part of the prompt)
- **Slice plan:** 4 polygons (one per slab). Scrub: collapsed stack → outward to the
hero's exploded spacing. DOM legend: base OS → dependencies → app code → writable layer.
- **Motion constraint (locked 2026-07-27):** slabs occlude each other in 2D, so each lower
slice has a hidden "bite". Animating collapsed→hero only ever INCREASES occlusion, so
bites stay hidden. Never over-explode past the source layout and never fan sideways —
that exposes the tears.
- **Watermark:** Gemini sparkle bottom-right — strip with
`bztmon-site/scripts/dewatermark.mjs` before slicing.
Vendored
+11
View File
File diff suppressed because one or more lines are too long
+271
View File
@@ -0,0 +1,271 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Exploded Cluster — slice editor</title>
<style>
:root{
--void:#070b14; --panel:#0d1422; --line:#1e2b42;
--cyan:#3fbaf5; --magenta:#e879f9; --ink:#c8d6ee; --dim:#6b7fa3;
}
*{box-sizing:border-box}
body{margin:0;background:var(--void);color:var(--ink);
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
display:grid;grid-template-columns:1fr 330px;height:100vh;overflow:hidden}
@media (max-width:860px){body{grid-template-columns:1fr;grid-template-rows:44vh 1fr}}
#stage{position:relative;overflow:auto;display:grid;place-items:center;padding:20px;
background:var(--void)}
#frame{position:relative;line-height:0;box-shadow:0 0 0 1px var(--line);background:var(--void)}
#frame img#base{display:block;max-width:100%;height:auto}
.part{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;
will-change:transform}
#overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
#hint{position:absolute;left:10px;top:10px;font-size:11px;color:var(--dim);
background:rgba(7,11,20,.85);padding:4px 8px;border:1px solid var(--line);pointer-events:none}
#panel{background:var(--panel);border-left:1px solid var(--line);
overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:13px}
h1{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--cyan);margin:0}
h2{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
margin:0 0 6px;font-weight:400}
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
button{background:#12203a;color:var(--ink);border:1px solid var(--line);
padding:6px 9px;font:inherit;font-size:11px;cursor:pointer;border-radius:3px}
button:hover{border-color:var(--cyan);color:#fff}
button.pri{background:var(--cyan);color:#04121f;border-color:var(--cyan);font-weight:700}
:is(button,input,textarea,select):focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
input[type=file]{font-size:11px;width:100%}
input[type=text]{background:#08101c;border:1px solid var(--line);color:var(--ink);
font:inherit;font-size:11px;padding:4px 6px;border-radius:3px;width:100%}
input[type=range]{accent-color:var(--cyan);width:100%;height:18px}
textarea{width:100%;height:100px;background:#08101c;border:1px solid var(--line);
color:var(--ink);font:inherit;font-size:10px;padding:6px;border-radius:3px}
.note{font-size:10px;color:var(--dim);line-height:1.45;margin:6px 0 0}
.sep{height:1px;background:var(--line)}
.cut{border:1px solid var(--line);border-radius:4px;padding:8px;background:#0a1120}
.cut.on{border-color:var(--cyan)}
.cut h3{margin:0 0 6px;font-size:11px;font-weight:400;color:var(--ink);
display:flex;justify-content:space-between;align-items:center}
.cut h3 b{font-weight:400;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums}
.sl{display:grid;grid-template-columns:34px 1fr 38px;gap:6px;align-items:center;
font-size:10px;color:var(--dim);margin-top:3px}
.sl output{font-variant-numeric:tabular-nums;text-align:right;color:var(--ink)}
</style>
</head>
<body>
<div id="stage">
<div id="frame">
<img id="base" alt="">
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<div id="hint">load a hero →</div>
</div>
</div>
<div id="panel">
<h1>Slice editor</h1>
<div>
<h2>1 · hero image</h2>
<input type="file" id="file" accept="image/*">
<p class="note">Slices apply automatically. Pick the file, hit ▶ play.</p>
</div>
<div class="sep"></div>
<div>
<h2>2 · cut lines</h2>
<div id="geo" style="display:flex;flex-direction:column;gap:7px"></div>
<p class="note">Each cut traces a slab's <em>bottom</em> edge — left corner, down to the
point, up to the right corner. If a slab's glow gets stranded in the slice below it,
drag that corner <em>down</em> until the glow comes back.</p>
<div class="row" style="margin-top:7px">
<button id="guides">guides: on</button>
<button id="showbase">original: hidden</button>
<button id="reset">reset</button>
</div>
</div>
<div class="sep"></div>
<div>
<h2>3 · collapse &amp; scrub</h2>
<div class="sl"><span>spread</span>
<input type="range" id="spread" min="0" max="20" step=".5" value="11">
<output id="spreadv">11</output></div>
<input type="range" id="scrub" min="0" max="1" step="0.001" value="1" style="margin-top:8px">
<div class="row" style="justify-content:space-between">
<span class="note" style="margin:0">collapsed</span>
<button id="play" class="pri">▶ play</button>
<span class="note" style="margin:0">hero</span>
</div>
</div>
<div class="sep"></div>
<div>
<h2>4 · manifest</h2>
<input type="text" id="scene" value="course-I-docker-layers">
<div class="row" style="margin-top:6px">
<button id="export" class="pri">export</button>
<button id="copy">copy</button>
</div>
<textarea id="json" spellcheck="false" placeholder="manifest JSON appears here"></textarea>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
const frame = $('#frame'), base = $('#base'), overlay = $('#overlay');
const SVGNS = 'http://www.w3.org/2000/svg';
/* ── geometry ──────────────────────────────────────────────────────────────
One cut per slab, following its BOTTOM silhouette: left corner → bottom tip →
right corner. A slab's region is bounded ABOVE by the cut of the slab in front
of it (exactly where it's occluded) and BELOW by its own cut. Nothing is
duplicated; nothing loses its body. */
const DEFAULT = {
glass:{ l:27.5, tip:45.5, r:27.0 },
code: { l:42.5, tip:59.5, r:42.0 },
deps: { l:57.0, tip:73.5, r:56.5 }
};
const X = { l:28.3, r:71.4 }; // where the slabs' side corners sit horizontally
let GEO = structuredClone(DEFAULT);
/* Seam fix: regions expand UPWARD only. The overlap always lands under the slab
painted in front of it, and collapsing moves that slab further down still — so
the overlap can never be revealed. Expanding downward would ghost. */
const EPS = 0.7;
const Vof = g => [[X.l, g.l], [50, g.tip], [X.r, g.r]];
const up = (v,e) => v.map(p => [p[0], +(p[1]-e).toFixed(2)]);
/* region between two silhouettes; sides run out to the frame edge (pure void) */
function band(top, bot){
const p = [];
if (top) p.push([0,top[0][1]], ...top, [100,top[2][1]]); else p.push([0,0],[100,0]);
if (bot) p.push([100,bot[2][1]], ...[...bot].reverse(), [0,bot[0][1]]);
else p.push([100,100],[0,100]);
return p;
}
let SPREAD = 11, parts = [], imgSrc = null, showBase = false, showGuides = true;
function build(){
const g = Vof(GEO.glass), c = Vof(GEO.code), d = Vof(GEO.deps);
parts = [
{ name:'base · the foundation', points: band(up(d,EPS), null), dy: 0 },
{ name:'deps · dependencies', points: band(up(c,EPS), d), dy: SPREAD },
{ name:'code · your app', points: band(up(g,EPS), c), dy: SPREAD*2 },
{ name:'writable · the top layer', points: band(null, g), dy: SPREAD*3 }
];
}
/* ── render ─────────────────────────────────────────────────────────────── */
function paint(){
const t = +$('#scrub').value;
frame.querySelectorAll('.part').forEach((el,i) => {
el.style.transform = `translate(0, ${(parts[i].dy*(1-t)).toFixed(3)}%)`;
});
}
function render(){
frame.querySelectorAll('.part').forEach(n => n.remove());
parts.forEach((p,i) => {
const d = document.createElement('div');
d.className = 'part';
if (imgSrc) d.style.backgroundImage = `url(${imgSrc})`;
d.style.clipPath = 'polygon(' + p.points.map(q => q[0]+'% '+q[1]+'%').join(',') + ')';
d.style.zIndex = i + 1;
frame.insertBefore(d, overlay);
});
base.style.opacity = imgSrc ? (showBase ? 1 : 0) : 1;
overlay.innerHTML = '';
if (showGuides) ['glass','code','deps'].forEach(k => {
const pl = document.createElementNS(SVGNS,'polyline');
pl.setAttribute('points', Vof(GEO[k]).map(p=>p.join(',')).join(' '));
pl.setAttribute('fill','none');
pl.setAttribute('stroke', k==='glass' ? '#e879f9' : k==='code' ? '#3fbaf5' : '#7ee787');
pl.setAttribute('stroke-width','.3');
pl.setAttribute('vector-effect','non-scaling-stroke');
pl.setAttribute('opacity','.85');
overlay.appendChild(pl);
});
paint();
}
/* ── controls ───────────────────────────────────────────────────────────── */
const LABEL = { glass:['writable', 'glass slab'], code:['code','magenta slab'], deps:['deps','circuit slab'] };
function geoUI(){
const host = $('#geo'); host.innerHTML = '';
Object.keys(GEO).forEach(k => {
const box = document.createElement('div'); box.className = 'cut';
const h = document.createElement('h3');
h.innerHTML = `<span>${LABEL[k][0]} <b>${LABEL[k][1]}</b></span>`;
box.appendChild(h);
[['l','left'],['tip','point'],['r','right']].forEach(([key,lab]) => {
const row = document.createElement('div'); row.className = 'sl';
const nm = document.createElement('span'); nm.textContent = lab;
const sl = document.createElement('input');
sl.type='range'; sl.min='0'; sl.max='100'; sl.step='.25'; sl.value=GEO[k][key];
sl.setAttribute('aria-label', `${LABEL[k][0]} ${lab} edge`);
const out = document.createElement('output'); out.textContent = GEO[k][key];
sl.oninput = e => { GEO[k][key] = +e.target.value; out.textContent = e.target.value;
box.classList.add('on'); build(); render(); };
row.append(nm, sl, out); box.appendChild(row);
});
host.appendChild(box);
});
}
$('#file').onchange = e => {
const f = e.target.files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => { imgSrc = r.result; base.src = imgSrc;
$('#hint').textContent = 'sliced — hit ▶ play'; build(); render(); };
r.readAsDataURL(f);
};
$('#spread').oninput = e => { SPREAD = +e.target.value; $('#spreadv').textContent = e.target.value;
build(); render(); };
$('#scrub').oninput = paint;
$('#guides').onclick = e => { showGuides = !showGuides;
e.target.textContent = 'guides: ' + (showGuides?'on':'off'); render(); };
$('#showbase').onclick = e => { showBase = !showBase;
e.target.textContent = 'original: ' + (showBase?'shown':'hidden'); render(); };
$('#reset').onclick = () => { GEO = structuredClone(DEFAULT); geoUI(); build(); render(); };
$('#play').onclick = () => {
const s = $('#scrub');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { s.value = 1; paint(); return; }
const t0 = performance.now(), dur = 1700;
const step = now => {
const t = Math.min(1, (now - t0) / dur);
s.value = 1 - Math.pow(1 - t, 3); // easeOutCubic — settles AT hero, never past it
paint();
if (t < 1) requestAnimationFrame(step);
};
s.value = 0; paint(); requestAnimationFrame(step);
};
const manifest = () => ({
scene: $('#scene').value,
note: 'travel clamps at hero (t=1); regions expand upward by EPS to kill clip-path seams',
eps: EPS, spread: SPREAD, cuts: GEO, xCorners: X,
parts: parts.map(p => ({ name:p.name, points:p.points, rest:{dx:0, dy:p.dy} }))
});
$('#export').onclick = () => { $('#json').value = JSON.stringify(manifest(), null, 2); };
$('#copy').onclick = async () => {
$('#json').value = JSON.stringify(manifest(), null, 2);
try { await navigator.clipboard.writeText($('#json').value); $('#copy').textContent='copied'; }
catch { $('#json').select(); }
setTimeout(() => $('#copy').textContent = 'copy', 1200);
};
geoUI(); build(); render();
</script>
</body>
</html>