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:
parent
0743d80b6a
commit
89ef2751fa
8 changed files with 330 additions and 3 deletions
|
|
@ -23,7 +23,7 @@ Layout note: The sidebar is already position: sticky; top: 1.5rem. Placing ToC a
|
||||||
- Estimate: 30m
|
- Estimate: 30m
|
||||||
- Files: frontend/src/pages/TechniquePage.tsx, frontend/src/components/TableOfContents.tsx, frontend/src/App.css
|
- 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'
|
- 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:
|
In TableOfContents.tsx:
|
||||||
- Add state: const [activeId, setActiveId] = useState<string>('')
|
- Add state: const [activeId, setActiveId] = useState<string>('')
|
||||||
|
|
|
||||||
22
.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json
Normal file
22
.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
77
.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md
Normal file
77
.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md
Normal 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.
|
||||||
|
|
@ -2376,3 +2376,56 @@ def _check_stage_prerequisites(video_id: str, stage_name: str) -> tuple[bool, st
|
||||||
return False, f"Unknown stage: {stage_name}"
|
return False, f"Unknown stage: {stage_name}"
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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()
|
||||||
|
|
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
109
backend/services/avatar.py
Normal file
109
backend/services/avatar.py
Normal 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.0–1.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.0–1.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
|
||||||
|
|
@ -2098,6 +2098,19 @@ a.app-footer__repo:hover {
|
||||||
color: var(--color-accent);
|
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 ───────────────────────────────────────────────────────── */
|
/* ── V2 subsections ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.technique-prose__subsection {
|
.technique-prose__subsection {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
*
|
*
|
||||||
* Renders a nested list of anchor links matching the H2/H3 section structure.
|
* Renders a nested list of anchor links matching the H2/H3 section structure.
|
||||||
* Uses slugified headings as IDs for scroll targeting.
|
* 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";
|
import type { BodySectionV2 } from "../api/public-client";
|
||||||
|
|
||||||
export function slugify(text: string): string {
|
export function slugify(text: string): string {
|
||||||
|
|
@ -19,6 +21,52 @@ interface TableOfContentsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TableOfContents({ sections }: 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;
|
if (sections.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,20 +75,25 @@ export default function TableOfContents({ sections }: TableOfContentsProps) {
|
||||||
<ul className="technique-toc__list">
|
<ul className="technique-toc__list">
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const sectionSlug = slugify(section.heading);
|
const sectionSlug = slugify(section.heading);
|
||||||
|
const isActive = activeId === sectionSlug;
|
||||||
return (
|
return (
|
||||||
<li key={sectionSlug} className="technique-toc__item">
|
<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}
|
{section.heading}
|
||||||
</a>
|
</a>
|
||||||
{section.subsections.length > 0 && (
|
{section.subsections.length > 0 && (
|
||||||
<ul className="technique-toc__sublist">
|
<ul className="technique-toc__sublist">
|
||||||
{section.subsections.map((sub) => {
|
{section.subsections.map((sub) => {
|
||||||
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
|
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
|
||||||
|
const isSubActive = activeId === subSlug;
|
||||||
return (
|
return (
|
||||||
<li key={subSlug} className="technique-toc__subitem">
|
<li key={subSlug} className="technique-toc__subitem">
|
||||||
<a
|
<a
|
||||||
href={`#${subSlug}`}
|
href={`#${subSlug}`}
|
||||||
className="technique-toc__sublink"
|
className={`technique-toc__sublink${isSubActive ? " technique-toc__sublink--active" : ""}`}
|
||||||
>
|
>
|
||||||
{sub.heading}
|
{sub.heading}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue