style(about): redesign /about as numbered "Liner Notes" editorial spine
Replace Home-cloned section grammar with a numbered left rail (Bodoni numerals, vertical spine, mono marginalia), an asymmetric content column, and SVG waveform dividers. Adds a degrade-safe IntersectionObserver interop. Copy verbatim.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* About-page rail active-numeral highlight.
|
||||
*
|
||||
* The Liner Notes layout carries one oversized Bodoni numeral per movement in a
|
||||
* persistent left rail. This module lights the numeral of whichever movement is
|
||||
* currently in view by toggling `.is-active` on the movement element; the CSS does
|
||||
* the colour transition. Pure progressive enhancement — the numerals render
|
||||
* statically in low-opacity navy without this, so a load failure or a no-JS client
|
||||
* degrades to a still-legible page.
|
||||
*
|
||||
* One observer at a time, re-pointed on each `observe` call. The active movement is
|
||||
* the one nearest the top of the viewport among those currently intersecting, which
|
||||
* keeps a single numeral lit during the scroll rather than flickering between
|
||||
* adjacent movements at the boundary.
|
||||
*/
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let observed: Element[] = [];
|
||||
|
||||
function refreshActive(): void {
|
||||
let best: Element | null = null;
|
||||
let bestTop = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const el of observed) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportH = window.innerHeight || document.documentElement.clientHeight;
|
||||
// In view at all (any overlap with the viewport).
|
||||
const inView = rect.bottom > 0 && rect.top < viewportH;
|
||||
if (!inView) continue;
|
||||
// Prefer the movement whose top is closest to (but not far below) the fold.
|
||||
const distance = Math.abs(rect.top);
|
||||
if (distance < bestTop) {
|
||||
bestTop = distance;
|
||||
best = el;
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of observed) {
|
||||
el.classList.toggle('is-active', el === best);
|
||||
}
|
||||
}
|
||||
|
||||
export function observe(...elements: Element[]): void {
|
||||
unobserve();
|
||||
observed = elements.filter(Boolean);
|
||||
if (observed.length === 0) return;
|
||||
|
||||
// IntersectionObserver only tells us *that* visibility changed; the actual
|
||||
// "which is nearest the fold" decision is recomputed from live rects so the
|
||||
// choice stays correct mid-scroll.
|
||||
observer = new IntersectionObserver(() => refreshActive(), {
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
for (const el of observed) observer.observe(el);
|
||||
|
||||
// Seed once so the first movement lights before any scroll.
|
||||
refreshActive();
|
||||
}
|
||||
|
||||
export function unobserve(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
for (const el of observed) el.classList.remove('is-active');
|
||||
observed = [];
|
||||
}
|
||||
Reference in New Issue
Block a user