feat: blog post table of contents (desktop sidebar + mobile bottom sheet) #3

Merged
fiatcode merged 11 commits from feat/blog-post-toc into main 2026-06-18 17:29:14 +07:00
Owner

Adds a table of contents to blog post pages.

What

  • Desktop: a sticky TOC in the right rail (replaces the $ fortune quote on post pages), h2 + h3, with a scroll-driven highlight of the section you're currently reading.
  • Mobile: a collapsible bottom sheet — a slim bar docked at the bottom showing the live current section (## …), tap to expand the full list over a backdrop, tap a heading / backdrop / Esc to close. Body scroll locks while open.

How

  • Pure, tested selection logic in src/lib/toc.ts (filterHeadings, activeHeadingId) — 10 unit tests.
  • TableOfContents.astro (sidebar + mobile variants) → TocList.astro (shared list) and TocBottomSheet.astro (mobile sheet).
  • Layout.astro exposes a named sidebar slot (falls back to the fortune quote) and a body-level overlay slot for the fixed sheet.

Notable fixes found in manual + headless-browser testing

  • Sticky sidebar had no scroll range under the grid's items-start; stretched the aside column.
  • Scrollspy now keeps the current section lit while reading (was only lighting near headings).
  • Mobile sheet was invisible because position: fixed anchored to the .reveal section (its load animation leaves a transform → containing block). Moved the sheet to a body-level overlay slot, outside the transformed subtree. Verified via headless CDP that it pins to the viewport.

89 tests pass; astro build clean (48 pages).

Adds a table of contents to blog post pages. ## What - **Desktop:** a sticky TOC in the right rail (replaces the `$ fortune` quote on post pages), h2 + h3, with a scroll-driven highlight of the section you're currently reading. - **Mobile:** a collapsible bottom sheet — a slim bar docked at the bottom showing the live current section (`## …`), tap to expand the full list over a backdrop, tap a heading / backdrop / Esc to close. Body scroll locks while open. ## How - Pure, tested selection logic in `src/lib/toc.ts` (`filterHeadings`, `activeHeadingId`) — 10 unit tests. - `TableOfContents.astro` (sidebar + mobile variants) → `TocList.astro` (shared list) and `TocBottomSheet.astro` (mobile sheet). - `Layout.astro` exposes a named `sidebar` slot (falls back to the fortune quote) and a body-level `overlay` slot for the fixed sheet. ## Notable fixes found in manual + headless-browser testing - Sticky sidebar had no scroll range under the grid's `items-start`; stretched the aside column. - Scrollspy now keeps the current section lit while reading (was only lighting near headings). - Mobile sheet was invisible because `position: fixed` anchored to the `.reveal` section (its load animation leaves a transform → containing block). Moved the sheet to a body-level overlay slot, outside the transformed subtree. Verified via headless CDP that it pins to the viewport. 89 tests pass; `astro build` clean (48 pages).
The per-item <li> markup (anchor with data-toc-link, ##/### marker span,
link text) was duplicated verbatim between the sidebar and mobile variants.
Extract it into TocList.astro so the two variants share a single source of
truth; each variant's own <ul> wrapper and its distinct classes stay in the
parent.
Fix 1: drop redundant \`\$ \` prefix from the label template string and
remove both \`.replace(/^\$ /, "")\` calls — the markup already renders
the \`\$\` separately via \`<span class="text-phosphor">\`.

Fix 2: make \`setActive\` accept \`string | null\`; when \`null\`, all links
revert to the inactive style (\`text-ink-faint\`, no \`aria-current\`).
Observer callback now calls \`setActive\` unconditionally so scrolling
above the first heading clears the stale highlight.
The aside only grew to its content height under the grid's items-start
alignment, leaving position:sticky no scroll range. md:self-stretch makes
the column fill the row height so the TOC pins while scrolling.
Replace the IntersectionObserver active-zone model (which only lit a heading
while it sat in the top band, going dark between headings) with a
scroll-driven model: the current section is the last heading scrolled past an
activation line. Logic extracted to a tested pure activeHeadingId helper.
The sheet was rendered inside <article>, which lives in <section class=reveal>.
The reveal load animation leaves a transform (identity matrix) on that section,
making it the containing block for position:fixed descendants — so the sheet
anchored to the section (~32000px down) instead of the viewport and was never
visible while reading. Move it to a new <slot name=overlay> at <body> level,
outside the transformed subtree. Verified via headless CDP: container
offsetParent=null, pinned to viewport bottom (top:803/bottom:844 at 390x844).
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
fiatcode/site!3
No description provided.