v2 source, NB2 prompt contract, and the slice editor
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
assets/work/
|
||||
@@ -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
@@ -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 · Docker</span></a>
|
||||
<a href="#machine"><span>II · Machine</span></a>
|
||||
<a href="#runtime"><span>III · Runtime</span></a>
|
||||
<a href="#traffic"><span>IV · Traffic</span></a>
|
||||
<a href="#ocp"><span>V · 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 & 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 & 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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user