levkin.ca/shared/stack-scroll.js
ilia 21c75cdcba Rebuild stack-folder with sticky tab rail and site previews.
L0–L7 folders stack on scroll with aligned max depth, labeled tabs that
stay consistent when L7 joins the rail, Cal embeds, preview screenshots,
Playwright tests, and updated README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 21:30:05 -04:00

330 lines
9.2 KiB
JavaScript

/** Scroll depth, jump, folder fold state */
export function initStackScroll(options = {}) {
const {
sectionSelector = '.layer[data-layer], .folder[data-layer]',
depthEl = document.getElementById('depth'),
depthPrefix = 'L',
tabSelector = '[data-goto], .jump, .layer-id',
mountSelector = '.mount',
foldTabs = false,
interactionMode = 'pin',
} = options;
const sections = document.querySelectorAll(sectionSelector);
if (!sections.length) return;
const mount = document.querySelector(mountSelector);
const lastLayer = sections.length - 1;
const scrollPad = 56;
let frontLayer = null;
let layerScrollTops = [];
let maxScrollY = Infinity;
let measuringScroll = false;
const deckTabs = () =>
mount ? [...mount.querySelectorAll('.folder .tab')] : [];
function layerAnchor(el) {
return el.querySelector('.tab') || el.querySelector('.body') || el;
}
function tabRowMetrics() {
const tabs = deckTabs();
const stick = stackStickPx();
const tops = tabs.map((t) => t.getBoundingClientRect().top);
const spread = tops.length ? Math.max(...tops) - Math.min(...tops) : 0;
const onStick = tops.length
? tops.every((top) => Math.abs(top - stick) <= 6)
: false;
return { tabs, stick, spread, onStick };
}
function stackStickPx() {
const root = document.documentElement;
const raw = getComputedStyle(root).getPropertyValue('--stack-stick').trim();
const n = parseFloat(raw);
if (!Number.isFinite(n)) return scrollPad;
if (raw.endsWith('rem')) {
return n * parseFloat(getComputedStyle(root).fontSize);
}
if (raw.endsWith('px')) return n;
return n;
}
function captureLayerAnchors(force = false) {
if (!force && window.scrollY > 8 && layerScrollTops.length) return;
const stick = stackStickPx();
layerScrollTops = [...sections].map((el) =>
Math.max(0, layerAnchor(el).offsetTop - stick),
);
}
function lastTabEl() {
return sections[lastLayer]?.querySelector('.tab') ?? null;
}
function measureDeskEnd() {
const tab = lastTabEl();
if (!tab) return 0;
return Math.max(0, tab.offsetTop - stackStickPx());
}
function ensureStackRunway() {
if (!mount) return;
measuringScroll = true;
try {
captureLayerAnchors(true);
mount.style.setProperty('--stack-runway', '0px');
void mount.offsetHeight;
const stick = stackStickPx();
const tab = lastTabEl();
if (!tab) return;
for (let i = 0; i < 12; i++) {
const docMax = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
window.scrollTo({ top: docMax, behavior: 'auto' });
void mount.offsetHeight;
const shortfall = Math.ceil(tab.getBoundingClientRect().top - stick);
if (shortfall <= 1) break;
const cur =
parseFloat(getComputedStyle(mount).getPropertyValue('--stack-runway')) || 0;
mount.style.setProperty('--stack-runway', `${cur + shortfall + 2}px`);
void mount.offsetHeight;
captureLayerAnchors(true);
}
} finally {
measuringScroll = false;
window.scrollTo({ top: 0, behavior: 'auto' });
}
}
function tabsAlignedAtScroll() {
const { tabs, stick, spread } = tabRowMetrics();
if (!tabs.length) return false;
const tops = tabs.map((t) => t.getBoundingClientRect().top);
return (
spread <= 1 &&
tops.every((top) => Math.abs(top - stick) <= 2)
);
}
/** Furthest scroll Y where every tab is flush on the stick line */
function computeMaxScrollAligned() {
const docMax = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
if (!deckTabs().length) return 0;
measuringScroll = true;
try {
for (let y = docMax; y >= 0; y -= 1) {
window.scrollTo({ top: y, behavior: 'auto' });
void mount?.offsetHeight;
if (tabsAlignedAtScroll()) return y;
}
return 0;
} finally {
measuringScroll = false;
window.scrollTo({ top: 0, behavior: 'auto' });
}
}
function captureMaxScroll() {
if (window.scrollY <= 8) {
captureLayerAnchors(true);
ensureStackRunway();
captureLayerAnchors(true);
}
maxScrollY = computeMaxScrollAligned();
}
function isFolded() {
if (!foldTabs || !mount) return false;
if (deckTabs().length < sections.length) return false;
return window.scrollY > 120 && tabsAlignedAtScroll();
}
function updateFolded() {
if (!mount) return;
mount.classList.toggle('is-folded', isFolded());
}
function clampScroll() {
if (measuringScroll) return;
if (window.scrollY > maxScrollY) {
window.scrollTo({ top: maxScrollY, behavior: 'auto' });
}
}
function layerTabTitle(layer) {
const section = sections[layer];
const tab = section?.querySelector('.tab');
if (!tab) return `${depthPrefix}${layer}`;
const code = tab.querySelector('.tab-code')?.textContent?.trim() ?? `${depthPrefix}${layer}`;
const label = tab.querySelector('.tab-label')?.textContent?.trim() ?? '';
return `${code}${label}`;
}
function setIndicators(active) {
if (depthEl) {
depthEl.textContent =
active === lastLayer && isFolded()
? layerTabTitle(active)
: `${depthPrefix}${active}`;
}
document.querySelectorAll('.stack-ruler button, .stack-ruler [data-goto], .tab-rail button, .tab[data-goto], .layer-id').forEach((el) => {
const n = el.dataset.layer ?? el.dataset.goto;
if (n === undefined) return;
el.classList.toggle('active', Number(n) === active);
});
}
function setFront(layer) {
frontLayer = layer;
sections.forEach((el) => {
el.classList.toggle('is-front', layer !== null && Number(el.dataset.layer) === layer);
});
if (layer !== null) setIndicators(layer);
}
function clearFront() {
setFront(null);
}
function scrollToLayer(layer) {
if (isFolded() && layerScrollTops[layer] !== undefined) {
window.scrollTo({ top: layerScrollTops[layer], behavior: 'smooth' });
return;
}
const target =
document.getElementById(`layer-${layer}`) ||
document.querySelector(`${sectionSelector}[data-layer="${layer}"]`);
if (!target) return;
let y;
if (layer === lastLayer) {
captureMaxScroll();
y = maxScrollY;
} else {
const anchor = layerAnchor(target);
y = Math.min(
anchor.getBoundingClientRect().top + window.scrollY - scrollPad,
maxScrollY,
);
}
const behavior = layer === lastLayer ? 'auto' : 'smooth';
window.scrollTo({ top: Math.max(0, y), behavior });
if (layer === lastLayer) {
requestAnimationFrame(updateDepth);
}
}
function navigateToLayer(layer) {
clearFront();
scrollToLayer(layer);
requestAnimationFrame(() => {
updateFolded();
updateDepth();
});
}
function openLayer(layer) {
if (interactionMode === 'navigate' || isFolded()) {
navigateToLayer(layer);
return;
}
if (frontLayer === layer) {
clearFront();
updateDepth();
scrollToLayer(layer);
return;
}
if (frontLayer !== null) {
clearFront();
scrollToLayer(layer);
requestAnimationFrame(updateDepth);
return;
}
setFront(layer);
scrollToLayer(layer);
}
function currentActiveLayer() {
if (isFolded()) return lastLayer;
const y = window.scrollY;
let active = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const top = layerScrollTops[i];
if (top !== undefined && y >= top - 4) {
active = i;
break;
}
}
return active;
}
function updateCovered() {
sections.forEach((el) => {
el.classList.remove('is-covered');
el.style.removeProperty('--stack-blur');
});
}
function updateDepth() {
if (measuringScroll) return;
clampScroll();
updateFolded();
if (frontLayer !== null) return;
const active = currentActiveLayer();
updateCovered();
setIndicators(active);
window.dispatchEvent(
new CustomEvent('stack-depth', { detail: { active } }),
);
}
window.addEventListener('stack-goto-layer', (e) => {
const layer = Number(e.detail?.layer);
if (Number.isFinite(layer)) openLayer(layer);
});
sections.forEach((panel) => {
panel.addEventListener('click', (e) => {
if (e.target.closest('a[href], button[data-goto], .layer-id, .rail-tab, .tab[data-goto], .site-preview, .cta-block, .cal-slot, [data-cal-embed]')) return;
openLayer(Number(panel.dataset.layer));
});
});
document.querySelectorAll(tabSelector).forEach((tab) => {
tab.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openLayer(Number(tab.dataset.goto ?? tab.dataset.layer));
});
});
captureMaxScroll();
updateDepth();
window.addEventListener('load', () => {
if (window.scrollY > 8) window.scrollTo(0, 0);
requestAnimationFrame(() => {
captureMaxScroll();
updateDepth();
});
});
window.addEventListener('resize', () => {
captureMaxScroll();
updateDepth();
}, { passive: true });
window.addEventListener('scroll', updateDepth, { passive: true });
}