feat: Added IntersectionObserver scroll-spy to ToC highlighting the act…

- "frontend/src/components/TableOfContents.tsx"
- "frontend/src/App.css"

GSD-Task: S04/T02
This commit is contained in:
jlightner 2026-04-03 05:54:14 +00:00
parent 0743d80b6a
commit 89ef2751fa
8 changed files with 330 additions and 3 deletions

View file

@ -23,7 +23,7 @@ Layout note: The sidebar is already position: sticky; top: 1.5rem. Placing ToC a
- Estimate: 30m
- Files: frontend/src/pages/TechniquePage.tsx, frontend/src/components/TableOfContents.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build 2>&1 | tail -5 && echo 'Build OK'
- [ ] **T02: Add IntersectionObserver active section tracking** — Add scroll-based active section highlighting to the ToC using IntersectionObserver.
- [x] **T02: Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content** — Add scroll-based active section highlighting to the ToC using IntersectionObserver.
In TableOfContents.tsx:
- Add state: const [activeId, setActiveId] = useState<string>('')

View file

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

View file

@ -0,0 +1,77 @@
---
id: T02
parent: S04
milestone: M016
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/TableOfContents.tsx", "frontend/src/App.css"]
key_decisions: ["Active styles use --color-accent-focus (15% opacity) vs hover's --color-accent-subtle (10% opacity) plus font-weight 500 for visual distinction", "rootMargin 0px 0px -70% 0px triggers active state when section enters top 30% of viewport"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Frontend build passes with zero errors: cd frontend && npm run build (exit 0, 3.4s)"
completed_at: 2026-04-03T05:54:07.440Z
blocker_discovered: false
---
# T02: Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content
> Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content
## What Happened
---
id: T02
parent: S04
milestone: M016
key_files:
- frontend/src/components/TableOfContents.tsx
- frontend/src/App.css
key_decisions:
- Active styles use --color-accent-focus (15% opacity) vs hover's --color-accent-subtle (10% opacity) plus font-weight 500 for visual distinction
- rootMargin 0px 0px -70% 0px triggers active state when section enters top 30% of viewport
duration: ""
verification_result: passed
completed_at: 2026-04-03T05:54:07.440Z
blocker_discovered: false
---
# T02: Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content
**Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content**
## What Happened
Added useState for activeId and a useEffect that creates an IntersectionObserver watching all section/subsection heading elements. The observer uses rootMargin '0px 0px -70% 0px' so a section is considered active when it enters the top 30% of the viewport. On intersection, the topmost intersecting entry wins. The active ID drives conditional class names on each link. Added two CSS rules for the active state using --color-accent-focus background and font-weight 500, visually distinct from the hover state.
## Verification
Frontend build passes with zero errors: cd frontend && npm run build (exit 0, 3.4s)
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3400ms |
## Deviations
Added non-null assertion on intersecting[0] after length > 0 guard — TypeScript couldn't narrow through .sort() chain.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/TableOfContents.tsx`
- `frontend/src/App.css`
## Deviations
Added non-null assertion on intersecting[0] after length > 0 guard — TypeScript couldn't narrow through .sort() chain.
## Known Issues
None.

View file

@ -2376,3 +2376,56 @@ def _check_stage_prerequisites(video_id: str, stage_name: str) -> tuple[bool, st
return False, f"Unknown stage: {stage_name}"
finally:
session.close()
# ── Avatar Fetching ─────────────────────────────────────────────────────────
@celery_app.task
def fetch_creator_avatar(creator_id: str) -> dict:
"""Fetch avatar for a single creator from TheAudioDB.
Looks up the creator by ID, calls TheAudioDB, and updates the
avatar_url/avatar_source/avatar_fetched_at columns if a confident
match is found. Returns a status dict.
"""
from datetime import datetime, timezone
from services.avatar import lookup_avatar
session = _get_sync_session()
try:
creator = session.execute(
select(Creator).where(Creator.id == creator_id)
).scalar_one_or_none()
if not creator:
return {"status": "error", "detail": f"Creator {creator_id} not found"}
result = lookup_avatar(creator.name, creator.genres)
if result:
creator.avatar_url = result.url
creator.avatar_source = result.source
creator.avatar_fetched_at = datetime.now(timezone.utc)
session.commit()
return {
"status": "found",
"creator": creator.name,
"avatar_url": result.url,
"confidence": result.confidence,
"matched_artist": result.artist_name,
}
else:
creator.avatar_source = "generated"
creator.avatar_fetched_at = datetime.now(timezone.utc)
session.commit()
return {
"status": "not_found",
"creator": creator.name,
"detail": "No confident match from TheAudioDB",
}
except Exception as exc:
session.rollback()
logger.error("Avatar fetch failed for creator %s: %s", creator_id, exc)
return {"status": "error", "detail": str(exc)}
finally:
session.close()

View file

109
backend/services/avatar.py Normal file
View file

@ -0,0 +1,109 @@
"""TheAudioDB avatar lookup for music creators.
Searches TheAudioDB by artist name, applies confidence scoring, and
returns the best-match thumbnail URL or None.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import httpx
logger = logging.getLogger("chrysopedia.avatar")
AUDIODB_BASE = "https://www.theaudiodb.com/api/v1/json"
AUDIODB_KEY = "523532" # public test/community key
@dataclass
class AvatarResult:
url: str
source: str # "theaudiodb"
artist_name: str # name as returned by the API
confidence: float # 0.01.0
def _normalize(name: str) -> str:
"""Lowercase, strip, collapse whitespace."""
return " ".join(name.lower().split())
def _name_similarity(query: str, candidate: str) -> float:
"""Simple token-based similarity score (0.01.0).
Checks token overlap between the query and candidate names.
Sufficient for distinctive artist names; avoids adding thefuzz dep.
"""
q_tokens = set(_normalize(query).split())
c_tokens = set(_normalize(candidate).split())
if not q_tokens or not c_tokens:
return 0.0
overlap = q_tokens & c_tokens
# Jaccard-ish: overlap relative to the shorter set
return len(overlap) / min(len(q_tokens), len(c_tokens))
def _genre_overlap(creator_genres: list[str] | None, api_genre: str | None) -> bool:
"""Check if any of the creator's genres appear in the API genre string."""
if not creator_genres or not api_genre:
return False
api_lower = api_genre.lower()
return any(g.lower() in api_lower for g in creator_genres)
def lookup_avatar(
creator_name: str,
creator_genres: list[str] | None = None,
) -> AvatarResult | None:
"""Search TheAudioDB for an artist and return the best avatar match.
Returns None if no confident match is found. Synchronous designed
to run inside a Celery task.
"""
url = f"{AUDIODB_BASE}/{AUDIODB_KEY}/search.php"
try:
resp = httpx.get(url, params={"s": creator_name}, timeout=10.0)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
logger.warning("TheAudioDB lookup failed for %r: %s", creator_name, exc)
return None
artists = data.get("artists")
if not artists:
logger.info("TheAudioDB: no results for %r", creator_name)
return None
best: AvatarResult | None = None
best_score = 0.0
for artist in artists:
name = artist.get("strArtist", "")
thumb = artist.get("strArtistThumb")
if not thumb:
continue
similarity = _name_similarity(creator_name, name)
genre_bonus = 0.1 if _genre_overlap(creator_genres, artist.get("strGenre")) else 0.0
score = similarity + genre_bonus
if score > best_score:
best_score = score
best = AvatarResult(
url=thumb,
source="theaudiodb",
artist_name=name,
confidence=min(score, 1.0),
)
if best and best.confidence >= 0.5:
logger.info(
"TheAudioDB match for %r%r (confidence=%.2f)",
creator_name, best.artist_name, best.confidence,
)
return best
logger.info("TheAudioDB: no confident match for %r (best=%.2f)", creator_name, best_score)
return None

View file

@ -2098,6 +2098,19 @@ a.app-footer__repo:hover {
color: var(--color-accent);
}
/* Active section highlighting (IntersectionObserver) */
.technique-toc__link--active {
color: var(--color-accent);
font-weight: 500;
background: var(--color-accent-focus);
}
.technique-toc__sublink--active {
color: var(--color-accent);
font-weight: 500;
background: var(--color-accent-focus);
}
/* ── V2 subsections ───────────────────────────────────────────────────────── */
.technique-prose__subsection {

View file

@ -3,8 +3,10 @@
*
* 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.
*/
import { useEffect, useMemo, useState } from "react";
import type { BodySectionV2 } from "../api/public-client";
export function slugify(text: string): string {
@ -19,6 +21,52 @@ interface TableOfContentsProps {
}
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]);
if (sections.length === 0) return null;
return (
@ -27,20 +75,25 @@ export default function TableOfContents({ sections }: TableOfContentsProps) {
<ul className="technique-toc__list">
{sections.map((section) => {
const sectionSlug = slugify(section.heading);
const isActive = activeId === sectionSlug;
return (
<li key={sectionSlug} className="technique-toc__item">
<a href={`#${sectionSlug}`} className="technique-toc__link">
<a
href={`#${sectionSlug}`}
className={`technique-toc__link${isActive ? " technique-toc__link--active" : ""}`}
>
{section.heading}
</a>
{section.subsections.length > 0 && (
<ul className="technique-toc__sublist">
{section.subsections.map((sub) => {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
const isSubActive = activeId === subSlug;
return (
<li key={subSlug} className="technique-toc__subitem">
<a
href={`#${subSlug}`}
className="technique-toc__sublink"
className={`technique-toc__sublink${isSubActive ? " technique-toc__sublink--active" : ""}`}
>
{sub.heading}
</a>