site(learn): active section highlight + other-essays switcher on left rail

- Added scroll-spy to the chapter rail: as you scroll, the H2 closest
  to the upper third of the viewport becomes the active link (white
  background, sky text, sky ring).
- Below the chapter rail, an 'Other essays' list shows every other
  learn article (6 on each page) with read-time, plus an 'All articles'
  link back to the library.
This commit is contained in:
Matthew Meszaros
2026-05-26 12:40:19 +00:00
parent 79fb1d58c7
commit dc6e63f6bf
+82 -11
View File
@@ -53,11 +53,37 @@ const next = related.filter((r) => r.href !== path).slice(0, 2);
<!-- LEFT RAIL -->
<aside class="hidden lg:block border-r border-[color:var(--border)] bg-[color:var(--surface-1)]/40">
<div class="sticky top-0 max-h-screen overflow-y-auto py-8 px-6">
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">On this page</div>
<ol class="learn-chapter-rail space-y-px text-[13px]" data-chapter-rail>
<li class="text-[11.5px] text-muted-foreground/60 italic">Loading…</li>
</ol>
<div class="sticky top-0 max-h-screen overflow-y-auto py-8 px-6 flex flex-col gap-7">
<!-- On this page -->
<div>
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">On this page</div>
<ol class="learn-chapter-rail space-y-px text-[13px]" data-chapter-rail>
<li class="text-[11.5px] text-muted-foreground/60 italic">Loading…</li>
</ol>
</div>
<!-- Other articles in the library -->
<div class="pt-6 border-t border-[color:var(--border)]">
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4 flex items-center justify-between">
<span>Other essays</span>
<span class="font-mono text-[10.5px] text-muted-foreground/70">{related.length - 1}</span>
</div>
<ol class="space-y-px text-[13px]">
{related.filter((r) => r.href !== path).map((r, i) => (
<li>
<a href={r.href} class="group flex items-baseline gap-3 py-1.5 px-2 -mx-2 rounded-md text-foreground/65 hover:bg-white hover:text-[#0369a1] transition-colors">
<span class="font-mono text-[10.5px] text-muted-foreground/60 w-5 shrink-0">{String(i + 1).padStart(2, '0')}</span>
<span class="leading-snug flex-1 truncate">{r.title}</span>
<span class="font-mono text-[10px] text-muted-foreground/55 shrink-0">{r.read.replace(' read', '').replace(' min', 'm')}</span>
</a>
</li>
))}
</ol>
<a href="/learn/" class="mt-4 inline-flex items-center gap-1.5 text-[11.5px] font-mono text-[#0369a1] hover:text-[#075985]">
All articles
<Icon name="arrowRight" size={10} />
</a>
</div>
</div>
</aside>
@@ -113,26 +139,71 @@ const next = related.filter((r) => r.href !== path).slice(0, 2);
</div>
<script is:inline>
// Auto-build chapter rail from H2 elements in the prose body
// Auto-build chapter rail from H2 elements + scroll-spy active state
(function () {
const rail = document.querySelector('[data-chapter-rail]');
const main = document.querySelector('.learn-prose');
if (!rail || !main) return;
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const h2s = main.querySelectorAll('h2');
const h2s = Array.from(main.querySelectorAll('h2'));
if (!h2s.length) return;
const baseClass = 'group flex items-baseline gap-3 py-1.5 px-2 -mx-2 rounded-md transition-colors';
const inactiveClass = ' text-foreground/70 hover:bg-white hover:text-[#0369a1]';
const activeClass = ' bg-white text-[#0369a1] font-semibold ring-1 ring-[color:var(--sky-2)]';
// Build rail
rail.innerHTML = '';
const links = [];
h2s.forEach((h2, i) => {
if (!h2.id) h2.id = slug(h2.textContent || `section-${i + 1}`);
if (!h2.id) h2.id = slug(h2.textContent || 'section-' + (i + 1));
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${h2.id}`;
a.className = 'group flex items-baseline gap-3 py-1.5 px-2 -mx-2 rounded-md text-foreground/70 hover:bg-white hover:text-[#0369a1] transition-colors';
a.innerHTML = `<span class="font-mono text-[10.5px] text-muted-foreground/70 w-5 shrink-0">${String(i + 1).padStart(2, '0')}</span><span class="leading-snug">${h2.textContent}</span>`;
a.href = '#' + h2.id;
a.className = baseClass + inactiveClass;
a.innerHTML = '<span class="font-mono text-[10.5px] text-muted-foreground/70 w-5 shrink-0 chapter-num">' + String(i + 1).padStart(2, '0') + '</span><span class="leading-snug">' + h2.textContent + '</span>';
li.appendChild(a);
rail.appendChild(li);
links.push({ id: h2.id, el: a });
});
// Scroll-spy: highlight closest H2 above viewport mid-line
let currentId = null;
function setActive(id) {
if (id === currentId) return;
currentId = id;
links.forEach((l) => {
if (l.id === id) {
l.el.className = baseClass + activeClass;
} else {
l.el.className = baseClass + inactiveClass;
}
});
}
function onScroll() {
const probe = window.innerHeight * 0.35;
let active = h2s[0].id;
for (const h2 of h2s) {
const rect = h2.getBoundingClientRect();
if (rect.top - probe <= 0) {
active = h2.id;
} else {
break;
}
}
setActive(active);
}
onScroll();
let raf = 0;
window.addEventListener('scroll', () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
onScroll();
});
}, { passive: true });
})();
</script>
</Layout>