feat: Lifted scroll-spy state from TableOfContents to TechniquePage, cr…

- "frontend/src/components/ReadingHeader.tsx"
- "frontend/src/components/TableOfContents.tsx"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T01
This commit is contained in:
jlightner 2026-04-03 06:01:13 +00:00
parent a16559e668
commit e98f43193e
14 changed files with 554 additions and 55 deletions

View file

@ -35,3 +35,4 @@
| D027 | | requirement | R034 | validated | Homepage renders stats block with real counts from the API: GET /api/v1/stats returns {"technique_count":21,"creator_count":7}, and the frontend scorecard displays "21 ARTICLES" and "7 CREATORS" in cyan-on-dark design. Visual and API verification both pass. | Yes | agent |
| D028 | | requirement | R036 | validated | AdminDropdown.tsx now opens on hover at desktop widths (≥769px) via matchMedia guard with 150ms leave delay, while mobile retains tap-to-toggle. Build passes. Satisfies R036 criteria. | Yes | agent |
| D029 | | requirement | R039 | validated | Build output confirms favicon.svg, favicon-32.png, apple-touch-icon.png, og-image.png in dist/. index.html contains all required OG, Twitter card, and favicon meta tags. Inline SVG logo mark present in header. All slice plan verification checks pass. | Yes | agent |
| D030 | | ui | ToC sidebar rendering format gate | Only render ToC in sidebar for v2-format technique pages; v1 pages get no ToC | v1 pages use flat dict body_sections with no section hierarchy suitable for ToC navigation. v2 pages have structured nested sections with H2/H3 headings and slugified IDs already in the DOM. | Yes | agent |

View file

@ -9,6 +9,6 @@ Modernize the public site's visual identity and reading experience: fix landing
| S01 | Landing Page Visual Fixes | low | — | ✅ | Homepage has consistent 42rem max-width, unified spacing, rounded featured card, correct CTA button sizing. |
| S02 | Pipeline Admin UI Fixes | low | — | ✅ | Pipeline admin: collapse toggle works, mobile cards truncate, chevrons between stages, filter button group right-aligned, creator dropdown populates. |
| S03 | Brand Minimum (Favicon, OG Tags, Logo) | low | — | ✅ | Browser tab shows custom favicon. URL sharing produces preview card. Logo visible in header next to Chrysopedia. |
| S04 | ToC Modernization | medium | — | | Technique page ToC: no counters, left accent bar, On this page heading, active section highlighting on scroll, sticky in sidebar. |
| S04 | ToC Modernization | medium | — | | Technique page ToC: no counters, left accent bar, On this page heading, active section highlighting on scroll, sticky in sidebar. |
| S05 | Sticky Reading Header | medium | S04 | ⬜ | Thin sticky bar appears when scrolling past article title, shows title + current section, slides in/out. |
| S06 | Landing Page Personality Pass | low | S01, S03 | ⬜ | Homepage: animated stat count-up, consistent section headings, content above fold, header brand accent with logo. |

View file

@ -0,0 +1,81 @@
---
id: S04
parent: M016
milestone: M016
provides:
- IntersectionObserver scroll-spy pattern in TableOfContents.tsx — S05 sticky reading header can reuse the same activeId approach
- Sidebar ToC positioning — sticky sidebar layout confirmed working
requires:
[]
affects:
- S05
key_files:
- frontend/src/pages/TechniquePage.tsx
- frontend/src/components/TableOfContents.tsx
- frontend/src/App.css
key_decisions:
- ToC only renders in sidebar for v2-format pages; v1 pages have no ToC
- Active styles use --color-accent-focus (15% opacity) vs hover's --color-accent-subtle (10% opacity) plus font-weight 500
- rootMargin 0px 0px -70% 0px triggers active state when section enters top 30% of viewport
patterns_established:
- IntersectionObserver scroll-spy pattern with rootMargin for predictive active section tracking — reusable for S05 sticky reading header
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M016/slices/S04/tasks/T01-SUMMARY.md
- .gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-03T05:55:24.264Z
blocker_discovered: false
---
# S04: ToC Modernization
**Modernized technique page Table of Contents: moved to sidebar, replaced counters with accent bar, added scroll-spy active section highlighting via IntersectionObserver.**
## What Happened
Two tasks delivered the full ToC modernization for technique pages.
T01 relocated the TableOfContents component from inside the v2 prose column to the top of the sidebar div, making it automatically sticky (sidebar already has position: sticky). Restyled from a card with CSS counters to a clean list with a 2px left accent bar (--color-accent), hover backgrounds (--color-accent-subtle), and an "On this page" heading. Changed ordered list to unordered list. Gated rendering to v2-format pages only since v1 pages lack structured section hierarchy.
T02 added IntersectionObserver-based scroll-spy. A useEffect creates an observer watching all section/subsection heading elements (H2 slugs and H3 compound slugs with -- separator). rootMargin of '0px 0px -70% 0px' means a section becomes active when it enters the top 30% of the viewport. Active state uses --color-accent-focus (15% opacity) background and font-weight 500, visually distinct from the hover state (10% opacity, normal weight). Observer disconnects on unmount.
## Verification
Frontend production build passes with zero errors and zero warnings (npm run build, 945ms). All three key files modified: TechniquePage.tsx (ToC moved to sidebar), TableOfContents.tsx (new heading, ul, IntersectionObserver scroll-spy), App.css (accent bar, hover/active styles). Code inspection confirms: activeId state, IntersectionObserver with rootMargin, conditional active class names, CSS rules for --active variants.
## Requirements Advanced
- R040 — All acceptance criteria delivered: no counters, left accent bar, On this page heading, hover states, IntersectionObserver active highlighting, sticky in sidebar via existing sidebar positioning.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
T02 added a non-null assertion on intersecting[0] after a length > 0 guard because TypeScript couldn't narrow through the .sort() chain. Minor, no functional impact.
## Known Limitations
None.
## Follow-ups
None.
## Files Created/Modified
- `frontend/src/pages/TechniquePage.tsx` — Moved TableOfContents render from v2 prose column to sidebar top, gated on v2 format
- `frontend/src/components/TableOfContents.tsx` — New heading 'On this page', ol→ul, IntersectionObserver scroll-spy with activeId state and conditional active class names
- `frontend/src/App.css` — Replaced counter/card ToC styles with accent bar, hover backgrounds, and active state styles (--active variants)

View file

@ -0,0 +1,54 @@
# S04: ToC Modernization — UAT
**Milestone:** M016
**Written:** 2026-04-03T05:55:24.264Z
# S04: ToC Modernization — UAT
## Preconditions
- Frontend deployed (or `npm run dev` on local)
- At least one technique page with v2 body_sections_format and 4+ sections (H2 headings with H3 subsections)
## Test Cases
### TC1: ToC renders in sidebar, not in prose
1. Navigate to a v2 technique page (e.g., one with multiple sections)
2. **Expected:** ToC appears at the top of the right sidebar column, not inside the main prose area
3. **Expected:** Heading reads "On this page"
4. **Expected:** Items are an unordered list (no numbered counters)
### TC2: Left accent bar styling
1. On the same technique page, inspect the ToC container
2. **Expected:** A 2px solid cyan left border (--color-accent) is visible on the ToC nav element
3. **Expected:** No card background, no rounded card border
### TC3: Hover states on ToC links
1. Hover over a section link in the ToC
2. **Expected:** Background changes to a subtle accent color (--color-accent-subtle, ~10% opacity)
3. Hover over a subsection (indented) link
4. **Expected:** Same hover background treatment
### TC4: Active section highlighting on scroll
1. Scroll down through the technique page content
2. **Expected:** As each H2 section enters the top ~30% of the viewport, its ToC link gains an active highlight (stronger background at --color-accent-focus ~15% opacity, font-weight 500)
3. Continue scrolling to the next section
4. **Expected:** Active highlight moves to the new section; previous section loses highlight
5. Scroll to an H3 subsection
6. **Expected:** The subsection link in the ToC also gains active styling
### TC5: Sticky behavior
1. On a long technique page, scroll past the sidebar content
2. **Expected:** ToC remains visible (sticky positioning) as you scroll through the article
3. **Expected:** ToC doesn't overlap or clip the main content column
### TC6: v1 format pages have no ToC
1. Navigate to a technique page with v1 body_sections_format (if any exist)
2. **Expected:** No ToC appears in the sidebar
### TC7: Short page with few sections
1. Navigate to a technique page with only 1-2 sections
2. **Expected:** ToC still renders correctly with 1-2 items, no visual glitches
### TC8: Build verification
1. Run `cd frontend && npm run build`
2. **Expected:** Zero errors, zero warnings, clean build output

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M016/S04/T02",
"timestamp": 1775195654389,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 8,
"verdict": "pass"
},
{
"command": "echo 'Build OK'",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
}
]
}

View file

@ -1,6 +1,18 @@
# S05: Sticky Reading Header
**Goal:** Add a sticky reading header component that shows article title and current section name when user scrolls past the title area on technique pages.
**Goal:** Thin sticky reading header appears when scrolling past article title on technique pages, showing title + current section, with slide-in/out CSS animation.
**Demo:** After this: Thin sticky bar appears when scrolling past article title, shows title + current section, slides in/out.
## Tasks
- [x] **T01: Lifted scroll-spy state from TableOfContents to TechniquePage, created ReadingHeader sticky bar with slide-in animation triggered by H1 leaving viewport** — Extract IntersectionObserver scroll-spy from TableOfContents into the parent TechniquePage, create a ReadingHeader component that shows article title + current section in a thin fixed bar, and add CSS with slide animation. The bar appears when the H1 title scrolls out of view and hides when it reappears.
**Context for executor:**
- `TableOfContents.tsx` currently owns `activeId` state and the IntersectionObserver effect. These must move up to `TechniquePage.tsx` so both ToC and ReadingHeader can read activeId.
- `TableOfContents` currently takes `sections: BodySectionV2[]` as its only prop. After the lift, it should also accept `activeId: string`.
- The H1 element has class `technique-header__title` — use a ref or querySelector to observe it.
- z-index landscape: modals=1000, mobile menu=200, overlays/dropdowns=100. Use z-index 150 for reading header.
- v2 format gate: only render ReadingHeader when `displayFormat === 'v2'` and sections exist (same condition as ToC).
- Mobile: reading header should still appear at 375px. Truncate title with text-overflow: ellipsis.
- Estimate: 45m
- Files: frontend/src/components/ReadingHeader.tsx, frontend/src/components/TableOfContents.tsx, frontend/src/pages/TechniquePage.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build 2>&1 | tail -5 && echo '---' && grep -l 'reading-header' src/App.css src/components/ReadingHeader.tsx && echo '---' && grep -c 'activeId' src/pages/TechniquePage.tsx

View file

@ -0,0 +1,79 @@
# S05 Research: Sticky Reading Header
## Summary
Straightforward UI feature. A thin fixed bar slides in when the article title (`h1.technique-header__title`) scrolls out of view, showing the technique title and current section name. Slides out when scrolling back to the top. All required patterns (IntersectionObserver, activeId) already exist in `TableOfContents.tsx`.
## Recommendation
Single new component `ReadingHeader.tsx` that:
1. Uses IntersectionObserver on the H1 element to detect title visibility
2. Receives `activeId` and `sections` props from the parent (same data already flowing to `TableOfContents`)
3. CSS transition for slide-in/out (`transform: translateY(-100%)``translateY(0)`)
Requires lifting `activeId` state up from `TableOfContents.tsx` into `TechniquePage.tsx` so both `TableOfContents` and `ReadingHeader` can consume it. The observer logic currently inside `TableOfContents` moves to the parent or a shared hook.
## Implementation Landscape
### Existing Code
| File | Role | Lines |
|------|------|-------|
| `frontend/src/pages/TechniquePage.tsx` | Article page; renders title H1, two-column grid, passes sections to ToC | ~370 |
| `frontend/src/components/TableOfContents.tsx` | Sidebar ToC with IntersectionObserver scroll-spy tracking `activeId` | ~100 |
| `frontend/src/App.css` | All styles; technique section starts line 1842 | 5723 |
### Key Structural Facts
- **Article title**: `<h1 className="technique-header__title">{displayTitle}</h1>` inside `.technique-columns__main`
- **activeId state + IntersectionObserver**: Currently private inside `TableOfContents.tsx`. Must be lifted to `TechniquePage.tsx` so `ReadingHeader` can also read it.
- **Section slugs**: `slugify()` exported from `TableOfContents.tsx`, already shared
- **Sidebar**: `.technique-columns__sidebar` has `position: sticky; top: 1.5rem`
- **App header**: `.app-header` is NOT sticky/fixed — scrolls with page. No z-index set on it.
- **z-index landscape**: skip-to-content at 999, mobile menu at 200, modals at 1000. Reading header should use ~100.
- **Mobile breakpoint**: 768px — sidebar collapses to single column. Reading header should still work on mobile.
- **v2 gate**: ToC only renders for v2 format pages. Reading header should follow the same gate.
### Architecture
**State lifting approach:**
- Extract IntersectionObserver logic from `TableOfContents` into a `useActiveSection(sections)` custom hook (or just move the useState + useEffect into `TechniquePage.tsx`)
- `TableOfContents` receives `activeId` as a prop instead of managing it
- `ReadingHeader` receives `activeId`, `sections`, `title`, and `visible` (boolean from H1 observer)
**H1 visibility detection:**
- Second IntersectionObserver in `TechniquePage.tsx` watching the H1 element
- When H1 is not intersecting → show reading header; when intersecting → hide
- No rootMargin needed — simple "is the title visible at all" check
**Slide animation:**
- `position: fixed; top: 0; left: 0; right: 0; z-index: 100`
- `transform: translateY(-100%)` when hidden, `translateY(0)` when visible
- `transition: transform 0.3s ease`
- This is CSS-only, no animation library needed
**Current section display:**
- Look up `activeId` in `sections` array to find the matching heading text
- Display as "Title — Current Section" or "Title · Current Section"
### Natural Task Seams
1. **T01: Lift activeId + create ReadingHeader component** — Extract observer from ToC into parent, create ReadingHeader component with fixed positioning and slide animation, wire into TechniquePage, add CSS
2. **T02: Verify build + visual behavior** — Production build, check that ToC still highlights correctly, reading header appears/disappears, mobile breakpoint hides appropriately
Actually, this is small enough for a single task. The state lift is mechanical, the component is ~40 lines, the CSS is ~30 lines.
### Constraints
- Must not break existing ToC scroll-spy behavior
- Must only appear on v2 technique pages (same gate as ToC)
- Must not overlap with modal (z-index 1000) or mobile menu (z-index 200)
- CSS transition only — no spring/animation libraries
- Reading header should show a truncated title on narrow viewports
### Verification
- `npm run build` passes with zero errors/warnings
- ReadingHeader component exists and is rendered conditionally
- CSS contains `.reading-header` with `position: fixed` and `transform` transition
- ToC still shows active section highlighting (observer logic preserved)

View file

@ -0,0 +1,34 @@
---
estimated_steps: 8
estimated_files: 4
skills_used: []
---
# T01: Lift activeId state, create ReadingHeader component, wire into TechniquePage
Extract IntersectionObserver scroll-spy from TableOfContents into the parent TechniquePage, create a ReadingHeader component that shows article title + current section in a thin fixed bar, and add CSS with slide animation. The bar appears when the H1 title scrolls out of view and hides when it reappears.
**Context for executor:**
- `TableOfContents.tsx` currently owns `activeId` state and the IntersectionObserver effect. These must move up to `TechniquePage.tsx` so both ToC and ReadingHeader can read activeId.
- `TableOfContents` currently takes `sections: BodySectionV2[]` as its only prop. After the lift, it should also accept `activeId: string`.
- The H1 element has class `technique-header__title` — use a ref or querySelector to observe it.
- z-index landscape: modals=1000, mobile menu=200, overlays/dropdowns=100. Use z-index 150 for reading header.
- v2 format gate: only render ReadingHeader when `displayFormat === 'v2'` and sections exist (same condition as ToC).
- Mobile: reading header should still appear at 375px. Truncate title with text-overflow: ellipsis.
## Inputs
- ``frontend/src/components/TableOfContents.tsx` — current IntersectionObserver + activeId logic to extract`
- ``frontend/src/pages/TechniquePage.tsx` — parent page; will own activeId state and render ReadingHeader`
- ``frontend/src/App.css` — add .reading-header CSS block`
## Expected Output
- ``frontend/src/components/ReadingHeader.tsx` — new component: fixed bar with title + current section, controlled by visible prop`
- ``frontend/src/components/TableOfContents.tsx` — modified: accepts activeId as prop, no longer owns observer state`
- ``frontend/src/pages/TechniquePage.tsx` — modified: owns activeId state via IntersectionObserver useEffect, owns H1 visibility observer, renders ReadingHeader for v2 pages`
- ``frontend/src/App.css` — modified: .reading-header block with position:fixed, transform:translateY transition, z-index:150, mobile truncation`
## Verification
cd frontend && npm run build 2>&1 | tail -5 && echo '---' && grep -l 'reading-header' src/App.css src/components/ReadingHeader.tsx && echo '---' && grep -c 'activeId' src/pages/TechniquePage.tsx

View file

@ -0,0 +1,83 @@
---
id: T01
parent: S05
milestone: M016
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/ReadingHeader.tsx", "frontend/src/components/TableOfContents.tsx", "frontend/src/pages/TechniquePage.tsx", "frontend/src/App.css"]
key_decisions: ["Hide section name on mobile (<600px) rather than truncating both, for readability at 375px", "pointer-events:none when hidden so invisible bar doesn't block clicks"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Build passes (tsc + vite, 0 errors). reading-header class found in App.css and ReadingHeader.tsx. activeId referenced 5 times in TechniquePage.tsx."
completed_at: 2026-04-03T06:01:11.303Z
blocker_discovered: false
---
# T01: Lifted scroll-spy state from TableOfContents to TechniquePage, created ReadingHeader sticky bar with slide-in animation triggered by H1 leaving viewport
> Lifted scroll-spy state from TableOfContents to TechniquePage, created ReadingHeader sticky bar with slide-in animation triggered by H1 leaving viewport
## What Happened
---
id: T01
parent: S05
milestone: M016
key_files:
- frontend/src/components/ReadingHeader.tsx
- frontend/src/components/TableOfContents.tsx
- frontend/src/pages/TechniquePage.tsx
- frontend/src/App.css
key_decisions:
- Hide section name on mobile (<600px) rather than truncating both, for readability at 375px
- pointer-events:none when hidden so invisible bar doesn't block clicks
duration: ""
verification_result: passed
completed_at: 2026-04-03T06:01:11.304Z
blocker_discovered: false
---
# T01: Lifted scroll-spy state from TableOfContents to TechniquePage, created ReadingHeader sticky bar with slide-in animation triggered by H1 leaving viewport
**Lifted scroll-spy state from TableOfContents to TechniquePage, created ReadingHeader sticky bar with slide-in animation triggered by H1 leaving viewport**
## What Happened
Extracted the IntersectionObserver and activeId state from TableOfContents into parent TechniquePage. TableOfContents now receives activeId as a prop. Created ReadingHeader component — a thin fixed bar showing article title + current section heading, controlled by a visible prop driven by a separate IntersectionObserver on the H1 element. Added CSS with transform translateY transition for slide-in/out, z-index 150, and mobile truncation. Reading header only renders on v2 format pages with sections.
## Verification
Build passes (tsc + vite, 0 errors). reading-header class found in App.css and ReadingHeader.tsx. activeId referenced 5 times in TechniquePage.tsx.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build 2>&1 | tail -5` | 0 | ✅ pass | 1000ms |
| 2 | `grep -l 'reading-header' src/App.css src/components/ReadingHeader.tsx` | 0 | ✅ pass | 50ms |
| 3 | `grep -c 'activeId' src/pages/TechniquePage.tsx` | 0 | ✅ pass (5 matches) | 50ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/ReadingHeader.tsx`
- `frontend/src/components/TableOfContents.tsx`
- `frontend/src/pages/TechniquePage.tsx`
- `frontend/src/App.css`
## Deviations
None.
## Known Issues
None.

View file

@ -2029,6 +2029,72 @@ a.app-footer__repo:hover {
line-height: 1.5;
}
/* ── Sticky Reading Header ────────────────────────────────────────────────── */
.reading-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 150;
transform: translateY(-100%);
transition: transform 0.25s ease;
background: var(--bg-card);
border-bottom: 1px solid var(--color-border);
pointer-events: none;
}
.reading-header--visible {
transform: translateY(0);
pointer-events: auto;
}
.reading-header__inner {
max-width: 1200px;
margin: 0 auto;
padding: 0.5rem 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 36px;
overflow: hidden;
}
.reading-header__title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.reading-header__separator {
color: var(--text-muted);
flex-shrink: 0;
}
.reading-header__section {
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
@media (max-width: 600px) {
.reading-header__inner {
padding: 0.4rem 1rem;
}
.reading-header__section {
display: none;
}
}
/* ── Table of Contents ────────────────────────────────────────────────────── */
.technique-toc {

View file

@ -0,0 +1,34 @@
/**
* Sticky reading header that appears when the article H1 scrolls out of view.
*
* Shows the article title and current section name in a thin fixed bar
* at the top of the viewport. Uses CSS transform for slide-in/out animation.
*/
interface ReadingHeaderProps {
/** Article title */
title: string;
/** Currently active section heading (from scroll-spy) */
currentSection: string;
/** Whether the header should be visible (H1 is out of viewport) */
visible: boolean;
}
export default function ReadingHeader({ title, currentSection, visible }: ReadingHeaderProps) {
return (
<div
className={`reading-header${visible ? " reading-header--visible" : ""}`}
aria-hidden={!visible}
>
<div className="reading-header__inner">
<span className="reading-header__title">{title}</span>
{currentSection && (
<>
<span className="reading-header__separator">·</span>
<span className="reading-header__section">{currentSection}</span>
</>
)}
</div>
</div>
);
}

View file

@ -3,10 +3,9 @@
*
* Renders a nested list of anchor links matching the H2/H3 section structure.
* Uses slugified headings as IDs for scroll targeting.
* Tracks the active section via IntersectionObserver and highlights it.
* Receives activeId from parent (TechniquePage) which owns the IntersectionObserver.
*/
import { useEffect, useMemo, useState } from "react";
import type { BodySectionV2 } from "../api/public-client";
export function slugify(text: string): string {
@ -18,55 +17,10 @@ export function slugify(text: string): string {
interface TableOfContentsProps {
sections: BodySectionV2[];
activeId: string;
}
export default function TableOfContents({ sections }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>("");
// Collect all section/subsection IDs in document order
const allIds = useMemo(() => {
const ids: string[] = [];
for (const section of sections) {
const sectionSlug = slugify(section.heading);
ids.push(sectionSlug);
for (const sub of section.subsections) {
ids.push(`${sectionSlug}--${slugify(sub.heading)}`);
}
}
return ids;
}, [sections]);
useEffect(() => {
if (allIds.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
// Find the topmost currently-intersecting entry
const intersecting = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top
);
if (intersecting.length > 0) {
setActiveId(intersecting[0]!.target.id);
}
},
{
// Trigger when a section enters the top 30% of the viewport
rootMargin: "0px 0px -70% 0px",
}
);
for (const id of allIds) {
const el = document.getElementById(id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [allIds]);
export default function TableOfContents({ sections, activeId }: TableOfContentsProps) {
if (sections.length === 0) return null;
return (

View file

@ -6,7 +6,7 @@
* with pipeline metadata (prompt hashes, model config).
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
fetchTechnique,
@ -20,6 +20,7 @@ import {
import ReportIssueModal from "../components/ReportIssueModal";
import CopyLinkButton from "../components/CopyLinkButton";
import CreatorAvatar from "../components/CreatorAvatar";
import ReadingHeader from "../components/ReadingHeader";
import TableOfContents, { slugify } from "../components/TableOfContents";
import { parseCitations } from "../utils/citations";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
@ -223,8 +224,86 @@ export default function TechniquePage() {
const displayPlugins = overlay?.plugins ?? technique.plugins;
const displayQuality = overlay?.source_quality ?? technique.source_quality;
// --- Scroll-spy: activeId for ToC and ReadingHeader ---
const [activeId, setActiveId] = useState<string>("");
const [h1Visible, setH1Visible] = useState(true);
const h1Ref = useRef<HTMLHeadingElement>(null);
// Build flat list of all section/subsection IDs for observation
const allSectionIds = useMemo(() => {
if (displayFormat !== "v2" || !Array.isArray(displaySections)) return [];
const ids: string[] = [];
for (const section of displaySections as BodySectionV2[]) {
const sectionSlug = slugify(section.heading);
ids.push(sectionSlug);
for (const sub of section.subsections) {
ids.push(`${sectionSlug}--${slugify(sub.heading)}`);
}
}
return ids;
}, [displayFormat, displaySections]);
// Build a map from slug → heading text for ReadingHeader display
const sectionHeadingMap = useMemo(() => {
if (displayFormat !== "v2" || !Array.isArray(displaySections)) return new Map<string, string>();
const map = new Map<string, string>();
for (const section of displaySections as BodySectionV2[]) {
const sectionSlug = slugify(section.heading);
map.set(sectionSlug, section.heading);
for (const sub of section.subsections) {
map.set(`${sectionSlug}--${slugify(sub.heading)}`, sub.heading);
}
}
return map;
}, [displayFormat, displaySections]);
// Observe H1 visibility — drives reading header show/hide
useEffect(() => {
const el = h1Ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
setH1Visible(entry?.isIntersecting ?? true);
},
{ threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [technique]); // re-attach when technique changes
// Observe section headings — drives activeId for ToC + ReadingHeader
useEffect(() => {
if (allSectionIds.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const intersecting = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (intersecting.length > 0) {
setActiveId(intersecting[0]!.target.id);
}
},
{ rootMargin: "0px 0px -70% 0px" }
);
for (const id of allSectionIds) {
const el = document.getElementById(id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [allSectionIds]);
const currentSectionHeading = sectionHeadingMap.get(activeId) ?? "";
return (
<article className="technique-page">
{/* Reading header — v2 pages only */}
{displayFormat === "v2" && Array.isArray(displaySections) && (displaySections as BodySectionV2[]).length > 0 && (
<ReadingHeader
title={displayTitle}
currentSection={currentSectionHeading}
visible={!h1Visible}
/>
)}
{/* Back link */}
<Link to="/" className="back-link">
Back
@ -256,7 +335,7 @@ export default function TechniquePage() {
<div className="technique-columns">
<div className="technique-columns__main">
<div className="technique-header__title-row">
<h1 className="technique-header__title">{displayTitle} <CopyLinkButton /></h1>
<h1 className="technique-header__title" ref={h1Ref}>{displayTitle} <CopyLinkButton /></h1>
{displayCategory && (
<span className={"badge badge--category badge--cat-" + (displayCategory?.toLowerCase().replace(/\s+/g, "-") ?? "")}>
{displayCategory}
@ -470,7 +549,7 @@ export default function TechniquePage() {
<div className="technique-columns__sidebar">
{/* Table of Contents — v2 pages only */}
{displayFormat === "v2" && Array.isArray(displaySections) && (displaySections as BodySectionV2[]).length > 0 && (
<TableOfContents sections={displaySections as BodySectionV2[]} />
<TableOfContents sections={displaySections as BodySectionV2[]} activeId={activeId} />
)}
{/* Key moments (always from live data — not versioned) */}
{technique.key_moments.length > 0 && (

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReadingHeader.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}