diff --git a/.planning/M016-ux-brand-reading-experience.md b/.planning/M016-ux-brand-reading-experience.md
new file mode 100644
index 0000000..5402224
--- /dev/null
+++ b/.planning/M016-ux-brand-reading-experience.md
@@ -0,0 +1,142 @@
+# M016: UX Polish, Brand & Reading Experience
+
+> **Stream:** Frontend — intended for a dedicated GSD milestone instance
+> **Conflict zone:** `frontend/src/` only — no backend Python changes
+> **Deploy cadence:** commit-build-redeploy after each slice completion
+
+---
+
+## Goal
+
+Modernize the public site's visual identity and reading experience, fix pipeline admin UI bugs, and establish a brand baseline (logo, favicon, OG tags). Every change in this milestone lives entirely in the frontend — CSS, React components, and static assets.
+
+---
+
+## Slice Breakdown
+
+### S01: Landing Page Visual Fixes (Quick Wins)
+**Risk:** Low | **Effort:** Small | **Files:** `App.css`, `Home.tsx`
+
+Research found 5 concrete bugs/inconsistencies on the homepage:
+
+| # | Issue | Fix |
+|---|-------|-----|
+| 1 | Duplicate `.btn` rule at App.css:3185 overrides CTA sizing (renders 131x38 instead of ~195x48) | Remove or merge the duplicate `.btn` block |
+| 2 | `.home-featured` uses `border-image` which kills `border-radius` — card renders square | Replace with pseudo-element gradient border technique |
+| 3 | Three different `max-width` tracks (36rem, 42rem, none) create jagged center column | Unify to 42rem for all content sections |
+| 4 | Vertical spacing irregular — Random→Featured gap is only 8px vs 24px elsewhere | Normalize section margins to 1.5rem |
+| 5 | Two `border-radius` values (0.5rem vs 0.625rem) on home cards | Unify to 0.625rem |
+
+**Verification:** Visual screenshot comparison before/after on desktop (1280px) and mobile (375px).
+
+---
+
+### S02: Pipeline Admin UI Fixes
+**Risk:** Low | **Effort:** Small-Medium | **Files:** `AdminPipeline.tsx`, `App.css`
+
+Four issues identified with root causes already diagnosed:
+
+| # | Issue | Root Cause | Fix |
+|---|-------|-----------|-----|
+| 1 | Most-recent run won't collapse / flickers | `expandedRunId` in `load()` useCallback dependency array (line 729) causes race condition — collapsing sets null, which triggers load recreation, which re-expands | Remove `expandedRunId` from dependency array + use `useRef` for initial-load tracking |
+| 2 | Mobile job cards show vertical text ("C h e e") | `.pipeline-video__creator` missing overflow rules (App.css:4477) | Add `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` (matches `.pipeline-video__filename` pattern) |
+| 3 | No stage direction chevrons | Pipeline stages listed without visual flow indicator | Add CSS chevron/arrow between stage indicators using `::after` pseudo-elements or inline SVG |
+| 4 | Filter text box should be replaced with button group | Current text input for status filter; should be "ALL \| Not Started \| In Progress \| Complete" buttons, end-aligned | Replace `` with `
` flexbox, `justify-content: flex-end`, verify vertical alignment against adjacent elements |
+
+**Verification:** Test collapse toggle on most-recent run, resize to 375px and check creator name truncation, confirm chevrons render between stages, confirm filter buttons align right and sit level with row.
+
+---
+
+### S03: Brand Minimum (Favicon, OG Tags, Logo)
+**Risk:** Low | **Effort:** Small | **Files:** `index.html`, `App.tsx`, `App.css`, new static assets
+
+The site currently has:
+- No favicon (browser default icon)
+- No OG meta tags (no preview image when sharing URL via text/Discord)
+- No logo next to "Chrysopedia" in the header
+
+Tasks:
+1. **Design a simple logo** — something that fits the dark theme + cyan accent aesthetic. Could be a stylized book/page icon, a knowledge/crystal motif matching the "chryso-" (gold) prefix, or an abstract mark. Generate an SVG.
+2. **Add favicon** — export logo as favicon.ico + apple-touch-icon + 192/512 PNG for PWA manifest
+3. **Add OG meta tags** — `og:title`, `og:description`, `og:image`, `og:url`, `twitter:card` in index.html. Create a 1200x630 OG image using the logo + brand colors.
+4. **Place logo in header** — render the SVG inline next to "Chrysopedia" text with appropriate sizing
+
+**Verification:** Share URL in Discord/iMessage and confirm preview card renders. Check favicon in browser tab. Visual check logo in header.
+
+---
+
+### S04: ToC Modernization
+**Risk:** Medium | **Effort:** Medium | **Files:** `TableOfContents.tsx`, `App.css`, `TechniquePage.tsx`
+
+Research identified these dated elements:
+- CSS counter numbering ("1.", "1.2") — biggest offender
+- Boxed card container with solid border
+- Uppercase "CONTENTS" label
+- No active-section highlighting
+- Underline-only hover states
+
+Modernization plan:
+1. **Remove numbered counters** — switch to unordered list with clean indentation
+2. **Replace box border** with left accent bar (`border-left: 2px solid var(--color-accent)`)
+3. **Change heading** from "CONTENTS" to "On this page" in sentence case
+4. **Add hover background** (`rgba(34, 211, 238, 0.08)`) instead of underline
+5. **Add IntersectionObserver** — track which section heading is in the viewport, highlight the corresponding ToC entry with accent left-border + brighter text color
+6. **Make ToC sticky** — position it at the top of the existing sidebar (above Key Moments), `position: sticky; top: 1.5rem`
+
+**Verification:** Navigate to a technique page with 4+ sections. Scroll through — ToC should highlight current section. ToC stays visible in sidebar while scrolling. Hover states work. No numbering visible.
+
+---
+
+### S05: Sticky Reading Header
+**Risk:** Medium | **Effort:** Medium | **Files:** new `ReadingHeader.tsx`, `App.css`, `TechniquePage.tsx`
+
+New component that slides in when user scrolls past the article title:
+- Shows: article title (truncated) + current section name
+- `position: sticky; top: 0; z-index: 50`
+- Thin bar (~40px height), `var(--color-bg-header)` background, subtle bottom border
+- Hidden by default, slides in via `transform: translateY(-100%)` → `translateY(0)` transition
+- Uses IntersectionObserver on the technique header element as show/hide trigger
+- Shares the section-tracking observer from S04's ToC work
+- On mobile: compact single-line with optional dropdown for section jump
+- Update `scroll-margin-top` values on section anchors to account for new header height
+
+**Verification:** Open long technique page. Scroll past title — reading header appears. Correct section name updates as you scroll. Works on mobile (375px). Doesn't break existing header.
+
+---
+
+### S06: Landing Page Personality Pass
+**Risk:** Low | **Effort:** Small | **Files:** `Home.tsx`, `App.css`
+
+After S01 fixes the bugs, this slice adds polish:
+1. **Hero tightening** — reduce hero bottom padding and how-it-works top margin to get content above the fold faster (~40-50px reclaim)
+2. **Stats scorecard enhancement** — animated count-up on first view (simple `requestAnimationFrame` counter), subtle glow on numbers
+3. **Random button treatment** — wrap in a small card with "Feeling adventurous?" tagline, or embed as secondary action inside Trending Searches
+4. **Section heading standardization** — pick one treatment (title-case with left accent bar) and apply consistently to "Recently Added", "Trending Searches", "Popular Topics"
+5. **Header brand accent** — apply `color: var(--color-accent)` or subtle gradient to "Chrysopedia" text (pairs with S03 logo)
+
+**Verification:** Visual check desktop + mobile. Stats animate on page load. Section headings consistent. Content peeks above fold on standard viewport.
+
+---
+
+## Dependency Graph
+
+```
+S01 (landing fixes) ──→ S06 (personality pass)
+S02 (pipeline fixes) [independent]
+S03 (brand minimum) ──→ S06 (header accent uses logo)
+S04 (ToC modern) ──→ S05 (reading header shares IntersectionObserver pattern)
+```
+
+S01, S02, S03, S04 can all start in parallel. S05 depends on S04. S06 depends on S01 + S03.
+
+Recommended execution order: **S01 → S02 → S03 → S04 → S05 → S06**
+
+---
+
+## Out of Scope (for this milestone)
+
+- Creator landing page redesign (depends on backend social links API — see M017)
+- Auto-avatar images (backend-gated — see M017)
+- Embed tab (needs backend investigation first — see M017)
+- Any backend Python changes
+- M015 S04/S05 leftovers (trending searches block, admin dropdown hover) — should be completed by M015's own GSD session first
diff --git a/.planning/M017-backend-perf-creator-features.md b/.planning/M017-backend-perf-creator-features.md
new file mode 100644
index 0000000..3089d44
--- /dev/null
+++ b/.planning/M017-backend-perf-creator-features.md
@@ -0,0 +1,164 @@
+# M017: Backend Performance & Creator Features
+
+> **Stream:** Backend — intended for a separate Claude Code session (not GSD)
+> **Conflict zone:** `backend/` only + one frontend API client fix
+> **Deploy cadence:** commit-build-redeploy after each task group
+
+---
+
+## Goal
+
+Fix critical performance bottlenecks in the admin pipeline API, implement auto-avatar fetching for creators, and lay the backend groundwork for creator landing page improvements. Almost entirely backend Python — minimal frontend changes.
+
+---
+
+## Task Groups (execute sequentially)
+
+### 1. Critical Fixes (do first, immediate impact)
+
+#### 1a. Fix `worker-status` async event loop blocking
+**File:** `backend/routers/pipeline.py` lines 1266-1313
+**Problem:** The endpoint is `async def` but calls three synchronous Celery inspect methods (`inspector.active()`, `.reserved()`, `.stats()`), each with a 1-second timeout. This blocks the entire uvicorn event loop for ~3 seconds, stalling ALL concurrent API requests.
+**Evidence:** Every parallel API call during page load takes ~3,024ms instead of their natural 6-38ms.
+**Fix:**
+```python
+import asyncio
+
+active = await asyncio.to_thread(inspector.active) or {}
+reserved = await asyncio.to_thread(inspector.reserved) or {}
+stats = await asyncio.to_thread(inspector.stats) or {}
+```
+Also consider: reduce inspect timeout to 0.5s, add Redis cache with 10-15s TTL to avoid repeated slow calls.
+**Impact:** Page load drops from ~3s to ~50ms.
+
+#### 1b. Fix creators endpoint 422 error
+**File (frontend):** `frontend/src/pages/AdminPipeline.tsx` line 1126
+**File (backend):** `backend/routers/creators.py` line 28
+**Problem:** Frontend requests `fetchCreators({ limit: 200 })` but backend validates `le=100`. Returns 422 on every pipeline page load — creator filter dropdown never populates.
+**Fix:** Change frontend call to `limit: 100`. (This is the ONE frontend file touch in this stream.)
+
+---
+
+### 2. Pipeline API Performance
+
+#### 2a. Rewrite `stale-pages` to eliminate N+1 queries
+**File:** `backend/routers/pipeline.py` lines 906-973
+**Problem:** Loads ALL technique pages, then runs a separate query per page for latest version + another for creator name. Currently 44 extra queries for 22 pages. Fast today (~30ms) but degrades linearly.
+**Fix:** Single query joining `technique_pages` with a lateral/window subquery for latest version + join to creators.
+
+#### 2b. Add pagination to videos endpoint
+**File:** `backend/routers/pipeline.py` lines 72-188
+**Problem:** Returns all 43 videos (23KB) with no offset/limit. Client-side filtering only.
+**Fix:** Add `offset`, `limit`, `status`, `creator_id` query params. Return paginated response with `total` count. Frontend can adopt server-side filtering later (or the M016 frontend stream can wire it up).
+
+#### 2c. Optimize `_find_dynamic_related` for technique pages
+**File:** `backend/routers/techniques.py` lines 33-111
+**Problem:** Loads ALL technique pages into memory to score relatedness in Python. O(n) in total page count.
+**Fix:** Move scoring to SQL (keyword overlap via `ts_rank` or simple tag intersection) or cache related links per technique page with invalidation on new page creation.
+
+---
+
+### 3. Auto-Avatar Integration (TheAudioDB)
+
+Research concluded: **TheAudioDB is the best first source** — free, no OAuth, no caching restrictions, decent coverage for established artists.
+
+#### 3a. Database migration
+Add to `Creator` model:
+- `avatar_url: String | None` — stored image URL or local path
+- `avatar_source: String` — enum: `"generated"`, `"theaudiodb"`, `"manual"`
+- `avatar_fetched_at: DateTime | None` — for cache invalidation
+
+Alembic migration (will be 014 or later depending on M015 state).
+
+#### 3b. TheAudioDB lookup service
+New file: `backend/services/avatar.py`
+- `async def fetch_avatar(creator_name: str, creator_genres: list[str]) -> AvatarResult | None`
+- Calls `https://www.theaudiodb.com/api/v1/json/{key}/search.php?s={name}`
+- Confidence scoring: name match via `thefuzz.fuzz.token_sort_ratio` (threshold ≥ 85%), genre overlap as tiebreaker
+- Returns `strArtistThumb` URL if confident match, None otherwise
+- Handle: no results, multiple results, missing image fields
+
+#### 3c. Celery worker task
+New task: `tasks.fetch_creator_avatar`
+- Called on creator creation or manually via admin endpoint
+- Runs TheAudioDB lookup → downloads image → stores locally (or stores URL)
+- Updates `Creator.avatar_url`, `avatar_source`, `avatar_fetched_at`
+- Falls back gracefully — if no match, leaves fields null (frontend already renders generated SVG as fallback)
+
+#### 3d. Admin endpoint for manual trigger
+`POST /admin/pipeline/creators/{id}/fetch-avatar` — triggers the worker task for a specific creator.
+`POST /admin/pipeline/creators/fetch-all-avatars` — batch trigger for all creators missing avatars.
+
+#### 3e. Wire avatar_url into creators API responses
+Add `avatar_url` to `CreatorBrowseItem` and `CreatorDetail` schemas. The frontend `CreatorAvatar` component already accepts an `imageUrl` prop — it will just work once the API returns it.
+
+---
+
+### 4. Creator Landing Page API Groundwork
+
+#### 4a. Social links model + migration
+Add to `Creator` model:
+- `social_links: JSON | None` — structured as `{"spotify": "url", "instagram": "url", "bandcamp": "url", "website": "url", ...}`
+- `bio: Text | None` — short creator bio/description
+- `featured: Boolean` — flag for homepage featuring
+
+#### 4b. Creator detail endpoint enhancement
+Expand `GET /api/v1/creators/{slug}` to return:
+- `social_links`
+- `bio`
+- `avatar_url`
+- `technique_count`
+- Full technique list with titles, slugs, created_at
+- Genre breakdown
+
+#### 4c. Admin endpoint for creator profile editing
+`PUT /admin/pipeline/creators/{id}` — update `bio`, `social_links`, `featured` flag, manually set `avatar_url`.
+
+---
+
+### 5. Embed Tab Investigation
+The "Embed" tab under pipeline jobs is non-functional. Before building, need to:
+- Read the existing frontend component to understand what it expects
+- Determine what "Embed" should show (embedding vectors? embed codes? embedded content?)
+- If it's about Qdrant vector embeddings: add an endpoint to query embedding status per technique page
+- If it's about iframe embed codes: generate shareable snippet per technique
+
+**This task starts as investigation — scope will be defined after reading the code.**
+
+---
+
+## General Load Time Optimization (apply throughout)
+
+As each endpoint is touched, also consider:
+- Add `Cache-Control` headers for public GET endpoints (technique pages, creators, search suggestions)
+- Add Redis caching (30s-5min TTL) for expensive or frequently-hit endpoints
+- Ensure database indexes exist on commonly filtered/sorted columns
+- Consider adding `select_in_loading` for SQLAlchemy relationships to avoid implicit lazy loads
+
+---
+
+## Key Files
+
+| File | What changes |
+|------|-------------|
+| `backend/routers/pipeline.py` | worker-status async fix, stale-pages rewrite, videos pagination, avatar endpoints |
+| `backend/routers/creators.py` | Creator detail expansion, social links |
+| `backend/routers/techniques.py` | Related techniques optimization |
+| `backend/models.py` | Creator model additions (avatar, social_links, bio) |
+| `backend/schemas.py` | New response schemas |
+| `backend/services/avatar.py` | New — TheAudioDB integration |
+| `backend/tasks.py` | New avatar fetch task |
+| `alembic/versions/014_*.py` | Migration for creator columns |
+| `frontend/src/pages/AdminPipeline.tsx` | Line 1126 only — fix limit: 200 → 100 |
+
+---
+
+## Merge Coordination with M016
+
+These two streams are designed to have minimal file overlap:
+- **M016 touches:** `App.css`, `Home.tsx`, `TechniquePage.tsx`, `TableOfContents.tsx`, `AdminPipeline.tsx` (CSS/JSX only), new frontend components, static assets
+- **M017 touches:** `backend/` (routers, models, schemas, services, tasks), `alembic/`, one line in `AdminPipeline.tsx`
+
+The single conflict point is `AdminPipeline.tsx` — M017's creators limit fix (line 1126) vs M016's pipeline UI fixes (collapse bug at line 729, filter buttons, chevrons). Resolve by merging M017's one-liner first, then M016's broader changes on top.
+
+For the avatar/social-links frontend wiring: M017 ships the API, M016 (or a follow-up) consumes it. No conflict — just sequencing.
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 25eeb32..c69ad74 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1028,6 +1028,16 @@ a.app-footer__repo:hover {
padding: 0.375rem 0;
}
+/* Bridge the gap between trigger and menu so hover doesn't break */
+.admin-dropdown__menu::before {
+ content: '';
+ position: absolute;
+ top: -0.5rem;
+ right: 0;
+ width: 100%;
+ height: 0.5rem;
+}
+
.admin-dropdown__item {
display: block;
padding: 0.5rem 1rem;
@@ -5097,13 +5107,9 @@ a.app-footer__repo:hover {
}
.recent-card__creator {
- display: inline-flex;
- align-items: center;
- gap: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
white-space: nowrap;
- flex-shrink: 0;
}
.recent-card__header-right {
@@ -5114,11 +5120,22 @@ a.app-footer__repo:hover {
flex-shrink: 0;
}
+.recent-card__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+ padding-top: 0.375rem;
+ border-top: 1px solid var(--color-border);
+}
+
.recent-card__date {
font-size: 0.7rem;
color: var(--color-text-secondary);
opacity: 0.65;
white-space: nowrap;
+ margin-left: auto;
}
/* ── Search result card creator ───────────────────────────────────────── */
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index 6449c63..cdf247a 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -310,19 +310,7 @@ export default function Home() {
className="recent-card card-stagger"
style={{ '--stagger-index': i } as React.CSSProperties}
>
-
- {t.title}
-
- {t.creator_name && (
- {t.creator_name}
- )}
- {t.created_at && (
-
- {new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
-
- )}
-
-
+ {t.title}
{t.topic_category}
@@ -337,11 +325,21 @@ export default function Home() {
: t.summary}
)}
+
+
+
+ {t.creator_name || ''}
+
{t.key_moment_count > 0 && (
{t.key_moment_count} moment{t.key_moment_count !== 1 ? 's' : ''}
)}
+ {t.created_at && (
+
+ {new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+
+ )}
))}