/*
scroll-spy — watches section elements via IntersectionObserver and updates the URL hash via history.replaceState as the user scrolls (RFC-1061). The first section in document order is exempt: while it is topmost, the URL stays hash-free.
- Module-level `activeObserver` and `activeCleanup` enforce single-instance idempotency (AC-9). Calling `initScrollSpy` twice disconnects the previous observer before creating a new one.
- Do not add navigation highlight UI — that is a separate concern.
- Do not use pushState — replaceState only to avoid history pollution.
- Do not modify section rendering or section id resolution.
- RFC-1061: Created scroll-spy script with IntersectionObserver-based section tracking and URL hash updates.
- RFC-1097: sweep — tail packages clean
Sweep batch 3: rewrote ~95 purposes across werkstatt-knowledge, werkstatt-shared, godot-game, phaser-game, lifecycle-core, projektarchiv-*, portal-*, billing-*, typescript (CONTRACT-02/PURPOSE-02). Real KEY_DECISIONS on 5 godot utils, non-goals on 5 CONTRACT-03 files, headers on 4 headerless files, CS-07 history literal fix on 2 files. Policy: vitest.config.ts + test-fixtures testPatterns, worker-configuration.d.ts excludedPath. All non-site/engine packages now 0 diagnostics.
*/
export interface ScrollSpyOptions {
/** CSS selector for elements to observe. Default: "section[id]" */
selector?: string;
/** IntersectionObserver rootMargin. Default: "-20% 0px -80% 0px" */
rootMargin?: string;
/** IntersectionObserver threshold. Default: 0 */
threshold?: number;
/** Whether to keep a clean (hash-free) URL while the first section — or the area above it — is in view. Default: true */
clearAtTop?: boolean;
}
let activeCleanup: (() => void) | null = null;
export function initScrollSpy(options?: ScrollSpyOptions): () => void {
const {
selector = "section[id]",
rootMargin = "-20% 0px -80% 0px",
threshold = 0,
clearAtTop = true,
} = options ?? {};
if (activeCleanup) {
activeCleanup();
activeCleanup = null;
}
if (!("IntersectionObserver" in window)) {
return () => {};
}
const sections = Array.from(document.querySelectorAll(selector)).filter(
(el) => !el.closest(".wl-modal"),
);
if (sections.length === 0) {
return () => {};
}
let currentId: string | null = null;
const updateHash = (id: string | null) => {
if (id === currentId) return;
currentId = id;
if (id) {
history.replaceState(null, "", `#${id}`);
} else if (clearAtTop) {
history.replaceState(null, "", location.pathname + location.search);
}
};
const observer = new IntersectionObserver(
(entries) => {
let topmostVisible: HTMLElement | null = null;
let topmostTop = Infinity;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target as HTMLElement;
const rect = el.getBoundingClientRect();
if (rect.top < topmostTop) {
topmostTop = rect.top;
topmostVisible = el;
}
}
if (topmostVisible && topmostVisible !== sections[0]) {
updateHash(topmostVisible.id);
} else {
updateHash(null);
}
},
{ rootMargin, threshold },
);
for (const section of sections) {
observer.observe(section);
}
const cleanup = () => {
observer.disconnect();
activeCleanup = null;
};
activeCleanup = cleanup;
return cleanup;
}