Skip to content

feat: add Notion-style floating page table of contents#90

Merged
kaiiiichen merged 1 commit into
mainfrom
feat/page-toc
Jul 5, 2026
Merged

feat: add Notion-style floating page table of contents#90
kaiiiichen merged 1 commit into
mainfrom
feat/page-toc

Conversation

@kaiiiichen

@kaiiiichen kaiiiichen commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a Notion-style floating table of contents on subpages (About, Projects, Misc, Playlists, Oxford portfolio, etc.) — excluded on home and /playlists/map.
  • Auto-discover sections from .mag-label headings (including non-card blocks like GitHub Activity), nested mag-card-inset children, and playlist card titles.
  • Hover interaction: rail lines fade out, contents panel slides in from the right; active section tracks scroll position.
  • Show the rail whenever there is enough right gutter beside the content column (not a fixed xl breakpoint), so split-screen layouts still get the TOC when space allows.

Files changed (this PR only)

  • app/components/page-toc.tsx — client TOC rail + panel
  • app/components/toc-section.tsx — optional explicit scroll targets
  • app/components/subpage-enter.tsx — mount PageToc on subpages
  • app/globals.css — TOC styles and motion

Test plan

  • Open /about, /projects, /misc on a wide window — right rail visible
  • Hover rail — panel slides in; click item scrolls to section
  • On /projects, confirm GitHub Activity appears in TOC
  • On /misc, confirm nested Things I Love categories appear indented
  • Narrow/split window until content fills width — rail hides; widen — rail returns
  • Home / and /playlists/map — no TOC

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Added a floating “On this page” table of contents for subpages.
    • TOC entries now highlight the current section and expand into a panel on hover or focus.
    • Added shared section markup support with automatic heading links and scroll offset handling.
  • Bug Fixes

    • TOC is hidden on pages with too little content, on excluded routes, and when there isn’t enough room on the right side.
    • Improved support for nested content so duplicate TOC items are avoided.

Introduce a right-rail TOC on subpages that auto-discovers semantic
sections from mag-labels and playlist headings, slides in on hover, and
shows whenever the content column leaves room in the right gutter.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kaichen.dev Ready Ready Preview, Comment Jul 5, 2026 4:46am

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a floating "On this page" table-of-contents feature: a new PageToc client component that scans headings/labels/explicit markers, renders a rail and expandable panel via portal; a TocSection helper for explicit TOC entries; wiring into SubpageEnter; and accompanying CSS.

Changes

Page TOC Feature

Layer / File(s) Summary
TocSection helper and ID generation
app/components/toc-section.tsx
Adds tocId() label normalization, TocSectionProps type, and TocSection component rendering explicit TOC markers with data attributes and default scroll margin.
DOM scanning and entry construction
app/components/page-toc.tsx
Defines TocEntry type, exclusion constants, DOM utilities for locating content, checking layout room, resolving scroll targets, and scanTocItems() collecting/ordering entries from explicit items, labels, and headings.
PageToc state and scroll tracking
app/components/page-toc.tsx
Implements effects for scanning on route change, tracking fit via resize/ResizeObserver, updating activeId on scroll, and scrollTo() smooth scrolling.
PageToc render and route wiring
app/components/page-toc.tsx, app/components/subpage-enter.tsx
Adds render guards for excluded routes/insufficient entries, renders the portal-based nav/rail/panel UI with hover/focus interactions, and integrates PageToc into SubpageEnter for non-root routes.
Floating TOC CSS styling
app/globals.css
Adds styles for the fixed TOC container, rail line indicators, floating panel with open-state animation, list/link states, dark mode, and reduced-motion overrides.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant SubpageEnter
    participant PageToc
    participant DOM
    participant User

    SubpageEnter->>PageToc: render for non-root route
    PageToc->>DOM: scanTocItems() on mount/route change
    PageToc->>DOM: observe scroll and resize events
    User->>PageToc: hover/focus rail
    PageToc->>PageToc: expand panel
    User->>PageToc: click TOC entry
    PageToc->>DOM: scrollTo(id) smooth scroll
Loading

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested labels: enhancement, ui

Suggested reviewers: kaiiiichen

🐰 A rabbit hops beside the page,
charting headings, line by line,
a floating rail to guide the way,
through content, section, sign.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: a Notion-style floating page table of contents.
Description check ✅ Passed The description includes the key template sections with a clear summary, file list, and test plan, though some optional sections are omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/page-toc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kaiiiichen kaiiiichen merged commit 5896c98 into main Jul 5, 2026
4 of 5 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
app/globals.css (1)

236-346: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded hex colors instead of design tokens.

The new rules use literal hex values throughout (#d4d4d8, #3f3f46, #C4894F, etc.) while the rest of this block reuses var(--color-border-secondary) for the border. Consider expressing these via existing/new CSS variables for consistency with the rest of the theme system.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/globals.css` around lines 236 - 346, The page TOC styles in the globals
CSS use hardcoded hex colors across the `.page-toc-*` rules, which breaks
consistency with the theme token system. Replace the literal color values in
`.page-toc-line`, `.page-toc-panel`, `.page-toc-link`, and their
dark/active/hover variants with existing CSS variables or add new design tokens
that match these states, keeping the current visual behavior intact.
app/components/page-toc.tsx (2)

172-194: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Scroll handler runs unthrottled on every scroll event.

Only the initial invocation is wrapped in requestAnimationFrame (line 186); the scroll listener added on line 187 calls onScroll directly on every native scroll event, running getBoundingClientRect() for every TOC item each time. On pages with many entries and fast scrolling this can cause layout thrashing.

♻️ Suggested rAF-throttled handler
   useEffect(() => {
     if (items.length === 0) return;
 
+    let ticking = false;
     const onScroll = () => {
-      let current: string | null = items[0].id;
-      for (const { id } of items) {
-        const el = document.getElementById(id);
-        if (el && el.getBoundingClientRect().top <= SCROLL_OFFSET) {
-          current = id;
-        }
-      }
-      setActiveId(current);
+      if (ticking) return;
+      ticking = true;
+      requestAnimationFrame(() => {
+        let current: string | null = items[0].id;
+        for (const { id } of items) {
+          const el = document.getElementById(id);
+          if (el && el.getBoundingClientRect().top <= SCROLL_OFFSET) {
+            current = id;
+          }
+        }
+        setActiveId(current);
+        ticking = false;
+      });
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/page-toc.tsx` around lines 172 - 194, The scroll handler in
page-toc’s useEffect is still running unthrottled because the window scroll
listener calls onScroll directly, causing repeated getBoundingClientRect() work
on every native event. Update the TOC scroll logic so the actual handler invoked
by the scroll and resize listeners is rAF-throttled (e.g., via a scheduled frame
or a wrapper around onScroll), and keep the existing cleanup in the same
useEffect tied to items.

27-36: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Fragile content-column selector.

main [class*="max-w-"] matches the first descendant (in document order) carrying any max-w-* class, not necessarily the outer content wrapper — a nested card or child element with its own max-w-* utility could match instead, skewing the gutter calculation used by hasRoomForToc().

Consider tagging the actual content column with a stable marker (e.g. data-page-content) instead of relying on an incidental Tailwind class substring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/page-toc.tsx` around lines 27 - 36, The page content lookup is
using a fragile Tailwind substring selector, so update the page TOC sizing logic
to target the real content wrapper via a stable marker instead of the first
nested max-w-* element. Modify findPageContentColumn() in page-toc.tsx to query
a dedicated attribute or class added to the actual content column, and keep
hasRoomForToc() using that returned element so the gutter calculation stays
accurate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/components/page-toc.tsx`:
- Line 25: The TOC visibility threshold in page-toc.tsx is too low for the
expanded panel footprint, so the rail can appear even when the panel will
overlap content or overflow. Update hasRoomForToc() and the TOC_MIN_RIGHT_GUTTER
constant to account for the full expanded panel width (use the panel’s max-width
plus the rail offset/margin), and keep the rail hidden unless there is enough
space for the complete TOC panel.
- Around line 204-224: The page TOC is keyboard-inaccessible when collapsed
because `PageToc` renders only non-focusable rail `<span>` elements and
`visibility: hidden` prevents any descendants from receiving Tab focus. Update
the `createPortal` markup in `page-toc.tsx` to include a focusable trigger
inside the `<nav>` (for example a button or a focusable rail container) that can
receive keyboard focus and call `setExpanded(true)`, and ensure it has an
appropriate visible focus state while keeping the existing mouse and blur
behavior.

---

Nitpick comments:
In `@app/components/page-toc.tsx`:
- Around line 172-194: The scroll handler in page-toc’s useEffect is still
running unthrottled because the window scroll listener calls onScroll directly,
causing repeated getBoundingClientRect() work on every native event. Update the
TOC scroll logic so the actual handler invoked by the scroll and resize
listeners is rAF-throttled (e.g., via a scheduled frame or a wrapper around
onScroll), and keep the existing cleanup in the same useEffect tied to items.
- Around line 27-36: The page content lookup is using a fragile Tailwind
substring selector, so update the page TOC sizing logic to target the real
content wrapper via a stable marker instead of the first nested max-w-* element.
Modify findPageContentColumn() in page-toc.tsx to query a dedicated attribute or
class added to the actual content column, and keep hasRoomForToc() using that
returned element so the gutter calculation stays accurate.

In `@app/globals.css`:
- Around line 236-346: The page TOC styles in the globals CSS use hardcoded hex
colors across the `.page-toc-*` rules, which breaks consistency with the theme
token system. Replace the literal color values in `.page-toc-line`,
`.page-toc-panel`, `.page-toc-link`, and their dark/active/hover variants with
existing CSS variables or add new design tokens that match these states, keeping
the current visual behavior intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a861f28-9e28-4384-964b-992e16148cf2

📥 Commits

Reviewing files that changed from the base of the PR and between b5161ec and 733e215.

📒 Files selected for processing (4)
  • app/components/page-toc.tsx
  • app/components/subpage-enter.tsx
  • app/components/toc-section.tsx
  • app/globals.css

const EXCLUDED_PATHS = new Set(["/", "/playlists/map"]);
const SCROLL_OFFSET = 112;
/** Minimum clear pixels between page content and viewport right edge. */
const TOC_MIN_RIGHT_GUTTER = 28;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gutter threshold is far smaller than the expanded panel's width.

TOC_MIN_RIGHT_GUTTER is 28px, but the expanded panel (min-width: 11rem / max-width: 13.5rem, see app/globals.css lines 264-265) needs ~176-216px of clearance. Near the 28px threshold, the rail will show but the panel will overlap page content (or overflow the viewport) on hover/focus, since hasRoomForToc() never accounts for panel width.

Raise the threshold to reflect the panel's actual footprint (e.g. panel max-width + rail offset + margin), not just the rail's.

Also applies to: 31-36, 200-200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/page-toc.tsx` at line 25, The TOC visibility threshold in
page-toc.tsx is too low for the expanded panel footprint, so the rail can appear
even when the panel will overlap content or overflow. Update hasRoomForToc() and
the TOC_MIN_RIGHT_GUTTER constant to account for the full expanded panel width
(use the panel’s max-width plus the rail offset/margin), and keep the rail
hidden unless there is enough space for the complete TOC panel.

Comment on lines +204 to +224
return createPortal(
<nav
aria-label="On this page"
className="page-toc page-toc--visible"
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
onFocus={() => setExpanded(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setExpanded(false);
}
}}
>
<div className="page-toc-rail" aria-hidden={expanded}>
{items.map(({ id, level }) => (
<span
key={id}
className={`page-toc-line page-toc-line--l${level}${activeId === id ? " page-toc-line--active" : ""}`}
/>
))}
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keyboard users cannot open the TOC panel.

When collapsed, the rail's <span> elements are not focusable, and the panel is visibility: hidden (see app/globals.css lines 273-275) — visibility:hidden removes descendants from the tab order in modern browsers. That means there is no focusable element inside <nav> while collapsed, so onFocus (which is supposed to trigger setExpanded(true)) can never fire via keyboard Tab navigation. The floating TOC is effectively mouse-only.

Add a focusable trigger (e.g. a visually-hidden button, or tabIndex={0} with a visible focus style on the rail container) so keyboard users can expand the panel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/page-toc.tsx` around lines 204 - 224, The page TOC is
keyboard-inaccessible when collapsed because `PageToc` renders only
non-focusable rail `<span>` elements and `visibility: hidden` prevents any
descendants from receiving Tab focus. Update the `createPortal` markup in
`page-toc.tsx` to include a focusable trigger inside the `<nav>` (for example a
button or a focusable rail container) that can receive keyboard focus and call
`setExpanded(true)`, and ensure it has an appropriate visible focus state while
keeping the existing mouse and blur behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant