diff --git a/.gsd/milestones/M016/slices/S04/S04-PLAN.md b/.gsd/milestones/M016/slices/S04/S04-PLAN.md index d009232..dd22eb3 100644 --- a/.gsd/milestones/M016/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M016/slices/S04/S04-PLAN.md @@ -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('') diff --git a/.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json b/.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json new file mode 100644 index 0000000..03ecd42 --- /dev/null +++ b/.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json @@ -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" + } + ] +} diff --git a/.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md b/.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..26d856e --- /dev/null +++ b/.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md @@ -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. diff --git a/backend/pipeline/stages.py b/backend/pipeline/stages.py index f466636..8415d0f 100644 --- a/backend/pipeline/stages.py +++ b/backend/pipeline/stages.py @@ -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() diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/avatar.py b/backend/services/avatar.py new file mode 100644 index 0000000..e4d18e9 --- /dev/null +++ b/backend/services/avatar.py @@ -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 diff --git a/frontend/src/App.css b/frontend/src/App.css index 75b919b..dc85c0f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/components/TableOfContents.tsx b/frontend/src/components/TableOfContents.tsx index 70d18d5..3ba8e58 100644 --- a/frontend/src/components/TableOfContents.tsx +++ b/frontend/src/components/TableOfContents.tsx @@ -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(""); + + // 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) {