intro backdrop + object-text linkage: establishing shot anchored below the copy (parallax, disabled under reduced motion); each legend row focuses its own part - the scrub walks the list and the described object stands out while the rest sit back, hover/keyboard pins the link

This commit is contained in:
2026-08-17 22:04:20 +10:00
parent 5076f52b21
commit 7fd24d0343
6 changed files with 68 additions and 9 deletions
+61 -6
View File
@@ -1,4 +1,5 @@
<header class="masthead">
<div class="introbg" aria-hidden="true"><img src="__HERO_INTRO__" alt=""></div>
<p class="eyebrow">The Exploded Cluster &middot; The Delivery Arc</p>
<h1>The machine was the easy part.<br><em>Now watch how software reaches it.</em></h1>
<p class="lede">The foundations first - what an image is, what a cluster is - then the toolchain
@@ -782,7 +783,15 @@ a:hover,a:focus-visible{border-bottom-color:var(--cyan)}
.purpose h2.kt{margin:0 0 1rem}
b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cyan)}
.masthead{max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
.masthead{position:relative;max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
/* The archipelago is anchored BELOW the text block: the words never sit over machinery.
Top edge starts under the lede; the mask feathers both ends so it dissolves into the void. */
.introbg{position:absolute;inset:52% -50vw -2vh;z-index:-1;overflow:hidden;pointer-events:none}
.introbg img{width:100%;height:100%;object-fit:cover;object-position:center 62%;opacity:.55;
-webkit-mask-image:linear-gradient(180deg,transparent 0%,#000 26%,#000 82%,transparent 100%);
mask-image:linear-gradient(180deg,transparent 0%,#000 26%,#000 82%,transparent 100%)}
@media (max-width:900px){.introbg{inset:58% -60vw -2vh}.introbg img{opacity:.4}}
.masthead > :not(.introbg){position:relative}
.eyebrow{font-family:var(--mono);font-size:.72rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--cyan);margin:0 0 1.5rem}
.masthead h1,.thesis{font-size:clamp(1.9rem,4.8vw,3.2rem);line-height:1.1;margin:0 0 1.3rem;
@@ -831,12 +840,15 @@ b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cya
gap:26px;align-items:center}
.scene{position:relative;margin:0;line-height:0}
.scene .flat{display:block;width:100%;height:auto}
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat}
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;
transition:opacity var(--dur-base) ease}
.legend{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:13px}
.legend li{padding-left:22px;border-left:1px solid var(--line);
.legend li{padding-left:22px;border-left:1px solid var(--line);cursor:default;
opacity:.34;transition:opacity var(--dur-base) ease,border-color var(--dur-base) ease}
.legend li.lit{opacity:1;border-left-color:var(--cyan)}
.legend li.lit{opacity:.72}
.legend li.act{opacity:1;border-left-color:var(--cyan)}
.legend li:hover,.legend li:focus-visible{opacity:1}
.legend .t{font-size:.92rem;font-weight:600;margin:0 0 .22rem;letter-spacing:-.01em}
.legend .b{font-size:.76rem;line-height:1.5;color:var(--dim);margin:0}
@@ -919,9 +931,11 @@ body:not(.fx) .slab{display:none}
const flat = scene.querySelector(".flat");
const legend = sec.querySelector(".legend");
// legend text comes from repo JSON; build with DOM nodes + textContent, never innerHTML
if (legend) man.parts.forEach(p => {
if (legend) man.parts.forEach((p, i) => {
if (!p.title && !p.blurb) return;
const li = document.createElement("li");
li.dataset.part = String(i);
li.tabIndex = 0; // keyboard users get the same link as the mouse
const t = document.createElement("p"); t.className = "t"; t.textContent = p.title;
const b = document.createElement("p"); b.className = "b"; b.textContent = p.blurb;
li.append(t, b); legend.appendChild(li);
@@ -953,6 +967,18 @@ body:not(.fx) .slab{display:none}
}
}
// ---- intro backdrop (class: parallax) ------------------------------------------------
// Drifts up and fades as the index arrives. Parallax is disabled outright under reduced
// motion (house rule), which is why this sits after the RM check for its own animation.
const introBg = document.querySelector(".introbg img");
if (introBg && !RM && window.gsap && window.ScrollTrigger) {
gsap.to(introBg, {
yPercent: -12, opacity: 0.12, ease: TOK.EASE.scrub,
scrollTrigger: { trigger: ".masthead", start: "top top", end: "bottom top",
scrub: TOK.SCRUB }
});
}
// Reduced motion / no GSAP: leave the flat exploded hero in place (it IS the end state).
if (RM || !window.gsap || !window.ScrollTrigger) return;
document.body.classList.add("fx");
@@ -1002,8 +1028,37 @@ body:not(.fx) .slab{display:none}
"translate3d(" + ox.toFixed(3) + "%," + oy.toFixed(3) + "%,0)" + extra;
});
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
if (n) lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
// Object <-> text linkage: the row being read is the part that stands out. The scrub
// walks the list top to bottom; whichever row is current gets .act, its slab stays at
// full strength and the rest sit back. Reading position and visual focus never diverge.
if (n) {
let act = -1;
lis.forEach((li, i) => {
const on = t >= 0.9 * ((i + 0.5) / n);
li.classList.toggle("lit", on);
if (on) act = i;
});
u.act = act;
if (u.pin == null) u.paint();
}
};
// paint() applies the focus state: pinned (hover/focus) wins over the scroll position.
u.paint = () => {
const f = u.pin != null ? u.pin : u.act;
u.slabs.forEach((el, i) => { el.style.opacity = (f < 0 || i === f) ? "1" : "0.38"; });
lis.forEach((li, i) => li.classList.toggle("act", i === f));
};
u.pin = null; u.act = -1;
// hover or keyboard focus on a row pins the link to that part - user-initiated, so it
// stays available even when the scrub is not moving.
lis.forEach((li, i) => {
const on = () => { u.pin = i; u.paint(); };
const off = () => { u.pin = null; u.paint(); };
li.addEventListener("mouseenter", on);
li.addEventListener("focus", on);
li.addEventListener("mouseleave", off);
li.addEventListener("blur", off);
});
});
const boot = () => {
+4 -2
View File
@@ -60,6 +60,7 @@ let app = tpl.slice(i + 8, j)
let body = tpl.slice(0, i) + tpl.slice(j + 9);
const heroes = {
__HERO_INTRO__: "intro-establishing.webp",
__HERO_SURROUND__: "course-00-surround.webp",
__HERO_NODE__: "course-III-node.webp",
__HERO_POD__: "course-III-pod.webp",
@@ -90,8 +91,9 @@ for (const [ph, file] of Object.entries(heroes)) {
heroUrls[ph] = url;
const dims = webpSize(buf);
const dimAttr = dims ? ` width="${dims.w}" height="${dims.h}"` : "";
// Course 00's hero is the first visible image -> load eager; the rest are below the fold.
const load = ph === "__HERO_SURROUND__" ? "eager" : "lazy";
// The intro backdrop is the above-the-fold LCP candidate -> eager. Everything else,
// including Course 00's hero, sits below the fold and lazy-loads.
const load = ph === "__HERO_INTRO__" ? "eager" : "lazy";
body = body.replace(`src="${ph}"`, `src="${url}"${dimAttr} decoding="async" loading="${load}"`);
}
{
+1
View File
@@ -26,6 +26,7 @@ out = (ROOT / "pilot/arc.tpl.html").read_text() \
.replace("__MAN_GITOPS__", man("gitops")) \
.replace("__MAN_SUPPLY__", man("supply")) \
.replace("__MAN_HELM__", man("helm")) \
.replace("__HERO_INTRO__", b64("intro-establishing.webp")) \
.replace("__HERO_SURROUND__", b64("course-00-surround.webp")) \
.replace("__HERO_NODE__", b64("course-III-node.webp")) \
.replace("__HERO_POD__", b64("course-III-pod.webp")) \