diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro
index 8a86815..9bf4403 100644
--- a/src/layouts/DocsLayout.astro
+++ b/src/layouts/DocsLayout.astro
@@ -44,3 +44,7 @@ if (sections.length) groups.push({ label: 'On this page', links: sections.map((h
+
+
diff --git a/src/scripts/docs-toc.ts b/src/scripts/docs-toc.ts
new file mode 100644
index 0000000..b27f4e7
--- /dev/null
+++ b/src/scripts/docs-toc.ts
@@ -0,0 +1,45 @@
+export {};
+
+const toc = document.querySelector('.docs-layout .page-index');
+const sections = Array.from(toc?.querySelectorAll('a[href^="#"]') ?? [])
+ .map((link) => {
+ const heading = document.getElementById(decodeURIComponent(link.hash.slice(1)));
+ return heading ? { link, heading } : null;
+ })
+ .filter((section) => section !== null);
+
+if (sections.length) {
+ const select = toc?.querySelector('[data-jump-select]');
+ let active: HTMLAnchorElement | undefined;
+ let scheduled = false;
+
+ function update() {
+ scheduled = false;
+ const offset = parseFloat(getComputedStyle(sections[0].heading).scrollMarginTop) || 0;
+ let current = sections[0];
+ for (const section of sections) {
+ if (section.heading.getBoundingClientRect().top <= offset + 1) current = section;
+ }
+ if (window.scrollY > 0 && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1) {
+ current = sections[sections.length - 1];
+ }
+ if (active === current.link) return;
+ active = current.link;
+ for (const { link } of sections) {
+ link.classList.toggle('is-active', link === active);
+ if (link === active) link.setAttribute('aria-current', 'location');
+ else link.removeAttribute('aria-current');
+ }
+ if (select) select.value = active.getAttribute('href') ?? '';
+ }
+
+ function scheduleUpdate() {
+ if (scheduled) return;
+ scheduled = true;
+ requestAnimationFrame(update);
+ }
+
+ window.addEventListener('scroll', scheduleUpdate, { passive: true });
+ window.addEventListener('resize', scheduleUpdate);
+ update();
+}
diff --git a/tests/docs-toc.test.ts b/tests/docs-toc.test.ts
new file mode 100644
index 0000000..105ee44
--- /dev/null
+++ b/tests/docs-toc.test.ts
@@ -0,0 +1,117 @@
+import { expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+
+const script = new Bun.Transpiler({ loader: 'ts', target: 'browser' }).transformSync(
+ readFileSync('src/scripts/docs-toc.ts', 'utf8').replace('export {};', ''),
+);
+
+function setup(initialScroll = 0, ids = ['first', 'second', 'last'], headingIds = ids) {
+ const listeners = new Map void>();
+ const frames: (() => void)[] = [];
+ const window = {
+ scrollY: initialScroll,
+ innerHeight: 800,
+ addEventListener: (event: string, listener: () => void) => { listeners.set(event, listener); },
+ };
+ const links = ids.map((id) => {
+ const classes = new Set();
+ const attributes = new Map([['href', `#${id}`]]);
+ return {
+ hash: new URL(`#${id}`, 'https://opentubex.org').hash,
+ getAttribute: (name: string) => attributes.get(name) ?? null,
+ classList: { toggle: (name: string, on: boolean) => { on ? classes.add(name) : classes.delete(name); } },
+ setAttribute: (name: string, value: string) => { attributes.set(name, value); },
+ removeAttribute: (name: string) => { attributes.delete(name); },
+ get active() { return classes.has('is-active'); },
+ get current() { return attributes.get('aria-current'); },
+ };
+ });
+ const guide = { active: true, current: 'page' };
+ const select = { value: '/docs/installing/' };
+ const headings = headingIds.map((id, index) => ({
+ id,
+ getBoundingClientRect: () => ({ top: [300, 1300, 2800][index] - window.scrollY }),
+ }));
+ const toc = {
+ querySelectorAll: (selector: string) => selector === 'a[href^="#"]' ? links : [guide, ...links],
+ querySelector: () => select,
+ };
+ const document = {
+ documentElement: { scrollHeight: 3200 },
+ querySelector: () => toc,
+ getElementById: (id: string) => headings.find((heading) => heading.id === id) ?? null,
+ };
+ const style = { scrollMarginTop: '92px' };
+ runInNewContext(script, {
+ document,
+ window,
+ getComputedStyle: () => style,
+ requestAnimationFrame: (callback: () => void) => { frames.push(callback); },
+ });
+
+ function flush() { frames.splice(0).forEach((callback) => callback()); }
+ function scroll(top: number) {
+ window.scrollY = top;
+ listeners.get('scroll')?.();
+ flush();
+ }
+ flush();
+ return { links, guide, select, style, listeners, frames, flush, scroll };
+}
+
+test('the current section is highlighted on load without clearing the current guide', () => {
+ const { links, guide, select } = setup();
+ expect(links.map((link) => link.active)).toEqual([true, false, false]);
+ expect(links[0].current).toBe('location');
+ expect(guide).toEqual({ active: true, current: 'page' });
+ expect(select.value).toBe('#first');
+});
+
+test('scrolling updates the section and keeps it active between headings', () => {
+ const { links, select, scroll } = setup();
+ scroll(1250);
+ expect(links.map((link) => link.active)).toEqual([false, true, false]);
+ expect(links[0].current).toBeUndefined();
+ expect(links[1].current).toBe('location');
+ expect(select.value).toBe('#second');
+ scroll(2000);
+ expect(links[1].active).toBe(true);
+ scroll(500);
+ expect(links.map((link) => link.active)).toEqual([true, false, false]);
+});
+
+test('a restored scroll position highlights the section on initialization', () => {
+ expect(setup(1500).links[1].active).toBe(true);
+});
+
+test('non-ASCII fragments keep the picker on the matching rendered option value', () => {
+ const { links, select } = setup(1500, ['first', 'café', 'last']);
+ expect(links[1].hash).toBe('#caf%C3%A9');
+ expect(links[1].active).toBe(true);
+ expect(select.value).toBe('#café');
+});
+
+test('the last section becomes active at the bottom even if its heading cannot reach the top', () => {
+ const { links, scroll } = setup();
+ scroll(2400);
+ expect(links.map((link) => link.active)).toEqual([false, false, true]);
+});
+
+test('resizing reevaluates the heading offset and scroll events share one animation frame', () => {
+ const { links, style, listeners, frames, flush } = setup(1150);
+ expect(links[0].active).toBe(true);
+ style.scrollMarginTop = '160px';
+ listeners.get('resize')?.();
+ listeners.get('scroll')?.();
+ listeners.get('scroll')?.();
+ expect(frames).toHaveLength(1);
+ flush();
+ expect(links[1].active).toBe(true);
+});
+
+test('pages with no section targets keep their guide selection and add no listeners', () => {
+ const { select, listeners } = setup(0, ['missing'], []);
+ expect(select.value).toBe('/docs/installing/');
+ expect(listeners.size).toBe(0);
+});