feat: add Notion-style floating page table of contents#90
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughIntroduces a floating "On this page" table-of-contents feature: a new ChangesPage TOC Feature
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
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested labels: enhancement, ui Suggested reviewers: kaiiiichen 🐰 A rabbit hops beside the page, 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
app/globals.css (1)
236-346: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueHardcoded hex colors instead of design tokens.
The new rules use literal hex values throughout (
#d4d4d8,#3f3f46,#C4894F, etc.) while the rest of this block reusesvar(--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 winScroll handler runs unthrottled on every scroll event.
Only the initial invocation is wrapped in
requestAnimationFrame(line 186); thescrolllistener added on line 187 callsonScrolldirectly on every native scroll event, runninggetBoundingClientRect()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 winFragile content-column selector.
main [class*="max-w-"]matches the first descendant (in document order) carrying anymax-w-*class, not necessarily the outer content wrapper — a nested card or child element with its ownmax-w-*utility could match instead, skewing the gutter calculation used byhasRoomForToc().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
📒 Files selected for processing (4)
app/components/page-toc.tsxapp/components/subpage-enter.tsxapp/components/toc-section.tsxapp/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; |
There was a problem hiding this comment.
🎯 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.
| 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> |
There was a problem hiding this comment.
🎯 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.
Summary
/playlists/map..mag-labelheadings (including non-card blocks like GitHub Activity), nestedmag-card-insetchildren, and playlist card titles.xlbreakpoint), so split-screen layouts still get the TOC when space allows.Files changed (this PR only)
app/components/page-toc.tsx— client TOC rail + panelapp/components/toc-section.tsx— optional explicit scroll targetsapp/components/subpage-enter.tsx— mountPageTocon subpagesapp/globals.css— TOC styles and motionTest plan
/about,/projects,/miscon a wide window — right rail visible/projects, confirm GitHub Activity appears in TOC/misc, confirm nested Things I Love categories appear indented/and/playlists/map— no TOCMade with Cursor
Summary by CodeRabbit
New Features
Bug Fixes