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>
This commit is contained in:
2026-05-21 21:30:05 -04:00
co-authored by Cursor
parent 09b0f498ba
commit 21c75cdcba
28 changed files with 1669 additions and 348 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
/* Spacer after L6 so scroll can stop */
.stack-stop,
.stop {
height: 1px;
margin-bottom: 4rem;
height: 0;
margin: 0;
pointer-events: none;
}
+306 -20
View File
@@ -1,43 +1,329 @@
/** Scroll depth + jump — panels are [data-layer] sticky cards */
/** Scroll depth, jump, folder fold state */
export function initStackScroll(options = {}) {
const {
sectionSelector = '.layer[data-layer], .folder[data-layer], .frame[data-layer], .unit[data-layer]',
sectionSelector = '.layer[data-layer], .folder[data-layer]',
depthEl = document.getElementById('depth'),
depthPrefix = 'L',
tabSelector = '[data-goto], .jump',
tabSelector = '[data-goto], .jump, .layer-id',
mountSelector = '.mount',
foldTabs = false,
interactionMode = 'pin',
} = options;
const sections = document.querySelectorAll(sectionSelector);
if (!sections.length) return;
const mid = () => window.innerHeight * 0.45;
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 updateDepth() {
let active = 0;
sections.forEach((el) => {
const r = el.getBoundingClientRect();
if (r.top <= mid() && r.bottom > mid()) active = Number(el.dataset.layer);
});
if (depthEl) depthEl.textContent = `${depthPrefix}${active}`;
function layerAnchor(el) {
return el.querySelector('.tab') || el.querySelector('.body') || el;
}
document.querySelectorAll('.stack-ruler button, .stack-ruler [data-goto], .tab-rail button, .tab[data-goto]').forEach((tab) => {
const n = tab.dataset.layer ?? tab.dataset.goto;
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;
tab.classList.toggle('active', Number(n) === active);
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();
const layer = tab.dataset.goto ?? tab.dataset.layer;
const target = document.querySelector(`${sectionSelector}[data-layer="${layer}"]`);
if (!target) return;
const y = target.getBoundingClientRect().top + window.scrollY - 56;
window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' });
e.stopPropagation();
openLayer(Number(tab.dataset.goto ?? tab.dataset.layer));
});
});
window.addEventListener('scroll', updateDepth, { passive: true });
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 });
}
+7 -4
View File
@@ -1,7 +1,10 @@
/* Original working sticky stack (cfca7aa) */
/* Sticky stack — one viewport of scroll per layer until L6 is on top */
:root {
--stack-nav: 2.5rem;
--stack-stick: 3.5rem;
--stack-step: 2.75rem;
--stack-card-min: 52vh;
--stack-stick: 3rem;
--stack-tab-h: 1.72rem;
--stack-step: 1.75rem;
--stack-gap: 0.35rem;
/* ~one folder body of scroll between layers (not a full viewport each) */
--stack-scroll-slot: 1.5rem;
}