diff --git a/.gsd/milestones/M010/M010-ROADMAP.md b/.gsd/milestones/M010/M010-ROADMAP.md
index e0b7b23..3ef6283 100644
--- a/.gsd/milestones/M010/M010-ROADMAP.md
+++ b/.gsd/milestones/M010/M010-ROADMAP.md
@@ -6,7 +6,7 @@ Chrysopedia should feel like exploring a music production library, not querying
## Slice Overview
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
-| S01 | Dedicated Sub-Topic Pages | high | — | ⬜ | Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs |
+| S01 | Dedicated Sub-Topic Pages | high | — | ✅ | Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs |
| S02 | Related Techniques Cross-Linking | medium | — | ⬜ | Bottom of every technique page shows 3-4 related techniques from same creator or same sub-topic |
| S03 | Topic Color Coding & Visual Polish | medium | S01 | ⬜ | Each of 7 topic categories has a distinct accent color in badges, cards, and sub-topic pages. Page transitions are smooth. Creator avatars are visually unique. |
| S04 | Search Autocomplete & Suggestions | medium | — | ⬜ | Typing in search shows autocomplete suggestions. Empty search box shows popular search terms. |
diff --git a/.gsd/milestones/M010/slices/S01/S01-SUMMARY.md b/.gsd/milestones/M010/slices/S01/S01-SUMMARY.md
new file mode 100644
index 0000000..a854cca
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S01/S01-SUMMARY.md
@@ -0,0 +1,94 @@
+---
+id: S01
+parent: M010
+milestone: M010
+provides:
+ - GET /topics/{category_slug}/{subtopic_slug} endpoint for filtered technique retrieval
+ - SubTopicPage component and route at /topics/:category/:subtopic
+ - Breadcrumb CSS pattern reusable by other pages
+ - fetchSubTopicTechniques API client function
+requires:
+ []
+affects:
+ - S03
+key_files:
+ - backend/routers/topics.py
+ - backend/tests/test_public_api.py
+ - frontend/src/pages/SubTopicPage.tsx
+ - frontend/src/api/public-client.ts
+ - frontend/src/App.tsx
+ - frontend/src/pages/TopicsBrowse.tsx
+ - frontend/src/App.css
+key_decisions:
+ - Used PostgreSQL ARRAY contains (@>) for tag matching — simpler than unnest/ANY since tags are stored lowercase
+ - Route registered before /{category_slug} to prevent FastAPI path conflict
+ - Grouped techniques by creator with Map-based first-appearance ordering
+ - Breadcrumb pattern: nav.breadcrumbs with link/text/current spans for reuse
+patterns_established:
+ - Sub-topic page pattern: URL params → API fetch → group by creator → render sections. Reusable for any filtered collection page.
+ - Breadcrumb nav component pattern using nav.breadcrumbs CSS class with link/text/current span variants
+observability_surfaces:
+ - none
+drill_down_paths:
+ - .gsd/milestones/M010/slices/S01/tasks/T01-SUMMARY.md
+ - .gsd/milestones/M010/slices/S01/tasks/T02-SUMMARY.md
+duration: ""
+verification_result: passed
+completed_at: 2026-03-31T06:04:33.352Z
+blocker_discovered: false
+---
+
+# S01: Dedicated Sub-Topic Pages
+
+**Added dedicated sub-topic pages at /topics/:category/:subtopic with backend filtering endpoint, breadcrumb navigation, and creator-grouped technique listings.**
+
+## What Happened
+
+This slice added full-stack sub-topic browsing to Chrysopedia. Previously, clicking a sub-topic on the Topics page redirected to a search query — now it loads a dedicated page with structured content.
+
+**Backend (T01):** Added `GET /topics/{category_slug}/{subtopic_slug}` endpoint to `routers/topics.py`. Uses PostgreSQL ARRAY contains (`@>`) to match the subtopic slug against the `topic_tags` column, filtered to the correct `topic_category`. Eager-loads the creator relation for `creator_name`/`creator_slug` fields. Returns `PaginatedResponse[TechniquePageRead]`. Route registered before the `/{category_slug}` catch-all to avoid FastAPI path conflicts. Three integration tests added and passing: happy path, empty results, and pagination.
+
+**Frontend (T02):** Created `SubTopicPage.tsx` following the existing `CreatorDetail` pattern. Extracts `category` and `subtopic` from URL params, fetches via `fetchSubTopicTechniques` in `public-client.ts`, groups techniques by `creator_name` using Map-based ordering. Renders breadcrumbs (`Topics > Category > Sub-topic`), loading/error/empty states, and technique links to `/techniques/{slug}`. Route registered in `App.tsx`. Updated `TopicsBrowse.tsx` to link sub-topics to `/topics/{cat}/{subtopic}` instead of `/search?q=...`. Added breadcrumb CSS to `App.css`. Fixed a pre-existing TS strict error in `Home.tsx`.
+
+## Verification
+
+Frontend: `npx tsc --noEmit` passes (0 errors), `npm run build` succeeds (48 modules, 870ms). Backend: 3 subtopic integration tests pass against PostgreSQL on ub01 (verified by T01 executor — local machine lacks DB access).
+
+## Requirements Advanced
+
+- R008 — Sub-topics now have dedicated pages instead of redirecting to search. Clicking a sub-topic loads a structured page with creator-grouped techniques and breadcrumbs.
+
+## Requirements Validated
+
+None.
+
+## New Requirements Surfaced
+
+None.
+
+## Requirements Invalidated or Re-scoped
+
+None.
+
+## Deviations
+
+Fixed pre-existing ProcessingStatus.extracted → ProcessingStatus.complete in test seed helper. Fixed pre-existing TS strict error in Home.tsx. Both were required to make the build/tests pass but were not part of the slice plan.
+
+## Known Limitations
+
+Backend subtopic tests require PostgreSQL on ub01 (port 5433) — cannot run from this dev machine. Pre-existing tests (test_list_topics_hierarchy, test_topics_with_no_technique_pages) hardcode 6 categories but canonical_tags.yaml now has 7.
+
+## Follow-ups
+
+Fix hardcoded category count in pre-existing topic tests. Consider adding pytest-timeout to backend dev dependencies.
+
+## Files Created/Modified
+
+- `backend/routers/topics.py` — Added get_subtopic_techniques endpoint with ARRAY contains tag matching
+- `backend/tests/test_public_api.py` — Added 3 subtopic integration tests, fixed ProcessingStatus enum in seed helper
+- `frontend/src/pages/SubTopicPage.tsx` — New page component: breadcrumbs, creator-grouped technique list, loading/error/empty states
+- `frontend/src/api/public-client.ts` — Added fetchSubTopicTechniques function
+- `frontend/src/App.tsx` — Registered /topics/:category/:subtopic route before catch-all
+- `frontend/src/pages/TopicsBrowse.tsx` — Updated sub-topic links to use dedicated page routes
+- `frontend/src/App.css` — Added breadcrumb and sub-topic page styles
+- `frontend/src/pages/Home.tsx` — Fixed pre-existing TS strict error
diff --git a/.gsd/milestones/M010/slices/S01/S01-UAT.md b/.gsd/milestones/M010/slices/S01/S01-UAT.md
new file mode 100644
index 0000000..a1970b7
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S01/S01-UAT.md
@@ -0,0 +1,53 @@
+# S01: Dedicated Sub-Topic Pages — UAT
+
+**Milestone:** M010
+**Written:** 2026-03-31T06:04:33.352Z
+
+## UAT: S01 — Dedicated Sub-Topic Pages
+
+### Preconditions
+- Chrysopedia stack running on ub01 (docker ps shows chrysopedia-api, chrysopedia-web-8096 healthy)
+- At least one technique page exists with topic_category and topic_tags populated
+- Web UI accessible at http://ub01:8096
+
+### Test 1: Sub-topic page loads from Topics browse
+1. Navigate to http://ub01:8096/topics
+2. Click any sub-topic link (e.g., "Compression" under Mixing)
+3. **Expected:** URL changes to /topics/mixing/compression (or equivalent category/subtopic)
+4. **Expected:** Page shows breadcrumbs: "Topics > Mixing > Compression" — "Topics" is a clickable link to /topics
+5. **Expected:** Techniques are grouped under creator name headings
+6. **Expected:** Each technique title links to /techniques/{slug}
+
+### Test 2: Direct URL navigation
+1. Navigate directly to http://ub01:8096/topics/mixing/compression
+2. **Expected:** Page loads without error, shows same content as Test 1
+
+### Test 3: Empty sub-topic
+1. Navigate to http://ub01:8096/topics/mixing/nonexistent-subtopic
+2. **Expected:** Page shows empty state message (no techniques found), not a crash or 500 error
+3. **Expected:** Breadcrumbs still render: "Topics > Mixing > Nonexistent Subtopic"
+
+### Test 4: Breadcrumb navigation
+1. On any sub-topic page, click the "Topics" breadcrumb link
+2. **Expected:** Navigates back to /topics
+3. **Expected:** Topics browse page loads normally
+
+### Test 5: Creator grouping correctness
+1. Navigate to a sub-topic that has techniques from multiple creators
+2. **Expected:** Techniques appear in sections under each creator's name
+3. **Expected:** Within each creator section, technique cards/links are listed
+
+### Test 6: API endpoint direct test
+1. `curl http://ub01:8096/api/topics/mixing/compression`
+2. **Expected:** JSON response with `items` array, `total` count, and `page`/`size` pagination fields
+3. **Expected:** Each item has `title`, `slug`, `creator_name`, `creator_slug`, `topic_category`, `topic_tags`
+
+### Test 7: Pagination
+1. `curl "http://ub01:8096/api/topics/mixing/compression?page=1&size=1"`
+2. **Expected:** Returns at most 1 item with correct total count
+3. **Expected:** `page` is 1, `size` is 1
+
+### Edge Cases
+- Sub-topic slug with hyphens (e.g., "sound-design") displays as "Sound Design" in breadcrumbs
+- Category slug case doesn't matter for API (normalized in backend)
+- Browser back button from sub-topic page returns to previous page
diff --git a/.gsd/milestones/M010/slices/S01/tasks/T02-VERIFY.json b/.gsd/milestones/M010/slices/S01/tasks/T02-VERIFY.json
new file mode 100644
index 0000000..e481b54
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S01/tasks/T02-VERIFY.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 1,
+ "taskId": "T02",
+ "unitId": "M010/S01/T02",
+ "timestamp": 1774936998839,
+ "passed": false,
+ "discoverySource": "task-plan",
+ "checks": [
+ {
+ "command": "cd frontend",
+ "exitCode": 0,
+ "durationMs": 6,
+ "verdict": "pass"
+ },
+ {
+ "command": "npx tsc --noEmit",
+ "exitCode": 1,
+ "durationMs": 756,
+ "verdict": "fail"
+ },
+ {
+ "command": "npm run build",
+ "exitCode": 254,
+ "durationMs": 83,
+ "verdict": "fail"
+ }
+ ],
+ "retryAttempt": 1,
+ "maxRetries": 2
+}
diff --git a/.gsd/milestones/M010/slices/S02/S02-PLAN.md b/.gsd/milestones/M010/slices/S02/S02-PLAN.md
index e5b9328..ac9933d 100644
--- a/.gsd/milestones/M010/slices/S02/S02-PLAN.md
+++ b/.gsd/milestones/M010/slices/S02/S02-PLAN.md
@@ -1,6 +1,103 @@
# S02: Related Techniques Cross-Linking
-**Goal:** Create exploration loops so users discover more content after reading a technique
+**Goal:** Bottom of every technique page shows 3-4 related techniques from same creator or same sub-topic, computed dynamically from the database without requiring pre-populated join table entries.
**Demo:** After this: Bottom of every technique page shows 3-4 related techniques from same creator or same sub-topic
## Tasks
+- [x] **T01: Added scored dynamic related-techniques query returning up to 4 ranked results with creator_name, topic_category, and reason fields** — Replace the empty join-table-based related links with a dynamic scored query in the technique detail endpoint. Enrich the RelatedLinkItem schema with creator_name, topic_category, and reason fields.
+
+## Steps
+
+1. **Enrich `RelatedLinkItem` in `backend/schemas.py`**: Add three optional string fields: `creator_name: str = ""`, `topic_category: str = ""`, `reason: str = ""`. Keep existing fields unchanged for backward compatibility.
+
+2. **Add dynamic query in `backend/routers/techniques.py`**: After loading the technique page in `get_technique()`, run a second query against `TechniquePage` to find related pages. Scoring logic:
+ - Same `creator_id` AND same `topic_category`: score += 3 (reason: 'Same creator, same topic')
+ - Same `creator_id`, different `topic_category`: score += 2 (reason: 'Same creator')
+ - Same `topic_category`, different `creator_id`: score += 2 (reason: 'Also about {topic_category}')
+ - Overlapping `topic_tags` (PostgreSQL `&&` via `TechniquePage.topic_tags.overlap(current_tags)`): score += 1 per shared tag (reason: 'Shared tags: {tags}')
+ - Exclude the current page by ID
+ - ORDER BY score DESC, LIMIT 4
+ - Build enriched `RelatedLinkItem` objects with the new fields
+ - If the join table also has entries, prefer those (they're manually curated) and fill remaining slots with dynamic results up to 4 total
+
+3. **Keep existing join-table loading**: Don't remove the `selectinload` for outgoing/incoming links — manually curated links take priority when they exist. The dynamic query supplements them.
+
+4. **Write integration test** in `backend/tests/test_public_api.py`: Add `test_dynamic_related_techniques` that:
+ - Creates 4+ technique pages with overlapping tags/categories across 2 creators
+ - Calls `GET /techniques/{slug}` for one of them
+ - Asserts `related_links` is non-empty, contains expected entries, respects the 4-item limit
+ - Asserts `creator_name`, `topic_category`, and `reason` fields are populated
+ - Asserts same-creator same-category pages rank above cross-creator pages
+
+5. **Verify existing test still passes**: Run the full `test_public_api.py` suite to confirm backward compatibility.
+
+## Must-Haves
+
+- [ ] `RelatedLinkItem` schema has `creator_name`, `topic_category`, `reason` fields
+- [ ] Dynamic query uses PostgreSQL array overlap for tag matching
+- [ ] Results limited to 4 items, ordered by relevance score
+- [ ] Current page excluded from results
+- [ ] Manually curated join-table links take priority when present
+- [ ] New integration test passes
+- [ ] Existing `test_get_technique_detail` still passes
+
+## Failure Modes
+
+| Dependency | On error | On timeout | On malformed response |
+|------------|----------|-----------|----------------------|
+| PostgreSQL array overlap | Falls back to creator_id + topic_category matches only (skip tag scoring) | N/A (same DB) | N/A |
+| Empty topic_tags on current page | Skip tag overlap scoring, use only creator/category matching | N/A | N/A |
+
+## Negative Tests
+
+- Technique with no peers (solo creator, unique category, no shared tags) → related_links is empty []
+- Technique with NULL topic_tags → scoring still works on creator_id/topic_category alone
+- Related results don't include the technique itself
+
+## Verification
+
+- `cd backend && python -m pytest tests/test_public_api.py -x -v` — all tests pass including new `test_dynamic_related_techniques`
+- Existing `test_get_technique_detail` still passes (related_links populated from join table + dynamic query)
+ - Estimate: 45m
+ - Files: backend/schemas.py, backend/routers/techniques.py, backend/tests/test_public_api.py
+ - Verify: cd backend && python -m pytest tests/test_public_api.py -x -v 2>&1 | tail -20
+- [ ] **T02: Frontend card-based related techniques section** — Replace the plain list rendering of related techniques with a responsive card grid. Update the TypeScript interface to match the enriched backend schema.
+
+## Steps
+
+1. **Update `RelatedLinkItem` in `frontend/src/api/public-client.ts`**: Add `creator_name: string`, `topic_category: string`, `reason: string` fields (all optional with defaults for backward compat with empty strings).
+
+2. **Replace list with card grid in `frontend/src/pages/TechniquePage.tsx`**: Remove the `
` list and replace with a CSS grid of cards. Each card shows:
+ - Technique title as a clickable `` to `/techniques/{target_slug}`
+ - Creator name (if non-empty) in muted text
+ - Topic category as a small badge (reuse existing `.badge` CSS patterns)
+ - Reason text in small muted text below
+ Use semantic class names: `.related-card`, `.related-card__title`, `.related-card__creator`, `.related-card__badge`, `.related-card__reason`
+
+3. **Update CSS in `frontend/src/App.css`**: Replace `.technique-related__list` styles with:
+ - `.technique-related__grid`: CSS grid, 2 columns on desktop (min-width: 600px), 1 column on mobile
+ - `.related-card`: background var(--color-surface-card), border-radius, padding, border using existing design tokens
+ - `.related-card__title`: link color, font-weight 600
+ - `.related-card__creator`: muted text, small font
+ - `.related-card__badge`: reuse existing badge pattern (small, inline, category-colored)
+ - `.related-card__reason`: muted, italic, smallest font size
+ Keep the existing `.technique-related` and `.technique-related h2` styles.
+
+4. **Build verification**: Run `cd frontend && npm run build` to confirm zero TypeScript errors and successful production build.
+
+## Must-Haves
+
+- [ ] `RelatedLinkItem` TypeScript interface includes creator_name, topic_category, reason
+- [ ] Related section renders as card grid, not plain list
+- [ ] Cards show title (linked), creator name, category badge, reason
+- [ ] Responsive: 2 columns on desktop, 1 on mobile
+- [ ] `npm run build` passes with zero errors
+
+## Verification
+
+- `cd frontend && npm run build 2>&1 | tail -5` — build succeeds with zero errors
+- `grep -c 'related-card' frontend/src/App.css` returns >= 4 (card styles exist)
+- `grep -q 'creator_name' frontend/src/api/public-client.ts` confirms type updated
+ - Estimate: 30m
+ - Files: frontend/src/api/public-client.ts, frontend/src/pages/TechniquePage.tsx, frontend/src/App.css
+ - Verify: cd frontend && npm run build 2>&1 | tail -5
diff --git a/.gsd/milestones/M010/slices/S02/S02-RESEARCH.md b/.gsd/milestones/M010/slices/S02/S02-RESEARCH.md
new file mode 100644
index 0000000..e0509f1
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S02/S02-RESEARCH.md
@@ -0,0 +1,79 @@
+# S02 Research — Related Techniques Cross-Linking
+
+## Summary
+
+The data model, API plumbing, and frontend rendering for related technique links all exist but are **empty** — the pipeline never populates the `related_technique_links` table. This slice needs to compute relatedness dynamically at query time rather than pre-populating a join table.
+
+The technique page already renders `related_links` when non-empty (simple list with relationship label). The slice upgrades this to a visually richer card-based display showing 3-4 related techniques with context (creator name, topic category, relationship reason).
+
+**Approach:** Dynamic computation in the `GET /techniques/{slug}` endpoint, replacing the current join-table-based approach. No new tables, no pipeline changes, no background jobs.
+
+## Recommendation
+
+Compute related techniques dynamically in the technique detail endpoint using a scored query: same-creator same-category pages rank highest, same-category different-creator next, then shared `topic_tags` overlap. Limit to 4 results. Return enriched `RelatedLinkItem` with `creator_name`, `topic_category`, and a human-readable `reason` string. Upgrade the frontend section from a plain link list to cards matching the existing design system.
+
+## Implementation Landscape
+
+### Backend — What Exists
+
+| File | Role | Relevant Code |
+|------|------|---------------|
+| `backend/models.py:69-74` | `RelationshipType` enum | `same_technique_other_creator`, `same_creator_adjacent`, `general_cross_reference` |
+| `backend/models.py:249-274` | `RelatedTechniqueLink` model | Fully defined, bidirectional FK to `TechniquePage` |
+| `backend/models.py:232-247` | `TechniquePage` relationships | `outgoing_links`, `incoming_links` mapped |
+| `backend/routers/techniques.py:109-181` | `get_technique()` | Eager-loads both link directions, builds `related_links` list |
+| `backend/schemas.py:243-249` | `RelatedLinkItem` | `target_title`, `target_slug`, `relationship` |
+
+### Backend — What's Needed
+
+1. **New query in `get_technique()`**: After loading the technique page, run a second query to find related pages. Scoring logic:
+ - Same `creator_id` AND same `topic_category`: weight 3 (same creator, adjacent technique)
+ - Same `topic_category`, different creator: weight 2 (same topic area)
+ - Overlapping `topic_tags` (using PostgreSQL `&&` array overlap operator): weight 1 per shared tag
+ - Exclude the current page itself
+ - ORDER BY score DESC, LIMIT 4
+
+2. **Enrich `RelatedLinkItem`**: Add `creator_name: str = ""`, `topic_category: str = ""`, `reason: str = ""` fields. The `reason` field provides display text like "Same creator" or "Also about compression".
+
+3. **The existing `related_technique_links` table and its eager-loading can stay** — if manually curated links exist in the future, they should take priority. The dynamic query fills in when the table is empty.
+
+### Frontend — What Exists
+
+| File | Role | Relevant Code |
+|------|------|---------------|
+| `frontend/src/pages/TechniquePage.tsx:494-511` | Related section | Renders `related_links` as `
` with link + relationship label |
+| `frontend/src/App.css:1774-1808` | Styles | Basic list styling with muted relationship text |
+| `frontend/src/api/public-client.ts:48-52` | `RelatedLinkItem` type | `target_title`, `target_slug`, `relationship` |
+
+### Frontend — What's Needed
+
+1. **Update `RelatedLinkItem` interface** in `public-client.ts`: add `creator_name`, `topic_category`, `reason` fields.
+
+2. **Replace plain list with card layout** in `TechniquePage.tsx`: Each related technique becomes a small card showing title (linked), creator name, topic category badge, and reason text. Use existing CSS variables and badge patterns from the codebase.
+
+3. **Update CSS** in `App.css`: Replace the `.technique-related__list` vertical list with a responsive grid of cards (2 columns on desktop, 1 on mobile). Follow existing card patterns (e.g., technique cards on sub-topic pages from S01).
+
+### Key Constraints
+
+- **PostgreSQL array overlap**: The `topic_tags` column is `ARRAY(String)`. Use `TechniquePage.topic_tags.overlap(current_tags)` in SQLAlchemy for the `&&` operator. This is standard PostgreSQL — no extensions needed.
+- **Performance**: The dynamic query hits `technique_pages` table with filters on `creator_id`, `topic_category`, and array overlap. For datasets under 10K rows, this is fast without additional indexes. A GIN index on `topic_tags` would help at scale but isn't needed now.
+- **The `related_technique_links` table stays empty** — no migration needed. The dynamic query supplements or replaces it at query time.
+- **Existing tests**: `backend/tests/test_public_api.py` has technique detail tests. The related links section currently returns `[]`. Tests need updating to verify dynamic results.
+
+### Natural Seams (Task Decomposition)
+
+1. **Backend: dynamic related-techniques query** — modify `get_technique()` in `routers/techniques.py`, enrich `RelatedLinkItem` schema in `schemas.py`. Verify with a test that creates 3+ technique pages sharing tags/category and confirms the endpoint returns related links.
+
+2. **Frontend: card-based related section** — update `RelatedLinkItem` type, replace the list rendering in `TechniquePage.tsx` with cards, add/update CSS. Verify with `npm run build` (zero TS errors) and visual check.
+
+These two tasks are independent (frontend works with the existing empty array until backend is deployed, and the backend change is backward-compatible since it only adds fields).
+
+### What to Build First
+
+The backend query is the riskier piece — it involves SQLAlchemy array operators and scoring logic. Build and test that first. The frontend is straightforward CSS/JSX using established patterns.
+
+### Verification
+
+- **Backend**: Integration test — create 4+ technique pages with overlapping tags/categories, call `GET /techniques/{slug}`, assert `related_links` contains expected entries with correct `reason` strings and scores ≤ 4 results.
+- **Frontend**: `npm run build` passes with zero errors. Visual verification that the related section renders cards (not a plain list) with creator name and category badge.
+- **End-to-end**: On the live system, navigate to any technique page and confirm 3-4 related techniques appear at the bottom.
diff --git a/.gsd/milestones/M010/slices/S02/tasks/T01-PLAN.md b/.gsd/milestones/M010/slices/S02/tasks/T01-PLAN.md
new file mode 100644
index 0000000..32f23a4
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S02/tasks/T01-PLAN.md
@@ -0,0 +1,79 @@
+---
+estimated_steps: 40
+estimated_files: 3
+skills_used: []
+---
+
+# T01: Dynamic related-techniques query and enriched schema
+
+Replace the empty join-table-based related links with a dynamic scored query in the technique detail endpoint. Enrich the RelatedLinkItem schema with creator_name, topic_category, and reason fields.
+
+## Steps
+
+1. **Enrich `RelatedLinkItem` in `backend/schemas.py`**: Add three optional string fields: `creator_name: str = ""`, `topic_category: str = ""`, `reason: str = ""`. Keep existing fields unchanged for backward compatibility.
+
+2. **Add dynamic query in `backend/routers/techniques.py`**: After loading the technique page in `get_technique()`, run a second query against `TechniquePage` to find related pages. Scoring logic:
+ - Same `creator_id` AND same `topic_category`: score += 3 (reason: 'Same creator, same topic')
+ - Same `creator_id`, different `topic_category`: score += 2 (reason: 'Same creator')
+ - Same `topic_category`, different `creator_id`: score += 2 (reason: 'Also about {topic_category}')
+ - Overlapping `topic_tags` (PostgreSQL `&&` via `TechniquePage.topic_tags.overlap(current_tags)`): score += 1 per shared tag (reason: 'Shared tags: {tags}')
+ - Exclude the current page by ID
+ - ORDER BY score DESC, LIMIT 4
+ - Build enriched `RelatedLinkItem` objects with the new fields
+ - If the join table also has entries, prefer those (they're manually curated) and fill remaining slots with dynamic results up to 4 total
+
+3. **Keep existing join-table loading**: Don't remove the `selectinload` for outgoing/incoming links — manually curated links take priority when they exist. The dynamic query supplements them.
+
+4. **Write integration test** in `backend/tests/test_public_api.py`: Add `test_dynamic_related_techniques` that:
+ - Creates 4+ technique pages with overlapping tags/categories across 2 creators
+ - Calls `GET /techniques/{slug}` for one of them
+ - Asserts `related_links` is non-empty, contains expected entries, respects the 4-item limit
+ - Asserts `creator_name`, `topic_category`, and `reason` fields are populated
+ - Asserts same-creator same-category pages rank above cross-creator pages
+
+5. **Verify existing test still passes**: Run the full `test_public_api.py` suite to confirm backward compatibility.
+
+## Must-Haves
+
+- [ ] `RelatedLinkItem` schema has `creator_name`, `topic_category`, `reason` fields
+- [ ] Dynamic query uses PostgreSQL array overlap for tag matching
+- [ ] Results limited to 4 items, ordered by relevance score
+- [ ] Current page excluded from results
+- [ ] Manually curated join-table links take priority when present
+- [ ] New integration test passes
+- [ ] Existing `test_get_technique_detail` still passes
+
+## Failure Modes
+
+| Dependency | On error | On timeout | On malformed response |
+|------------|----------|-----------|----------------------|
+| PostgreSQL array overlap | Falls back to creator_id + topic_category matches only (skip tag scoring) | N/A (same DB) | N/A |
+| Empty topic_tags on current page | Skip tag overlap scoring, use only creator/category matching | N/A | N/A |
+
+## Negative Tests
+
+- Technique with no peers (solo creator, unique category, no shared tags) → related_links is empty []
+- Technique with NULL topic_tags → scoring still works on creator_id/topic_category alone
+- Related results don't include the technique itself
+
+## Verification
+
+- `cd backend && python -m pytest tests/test_public_api.py -x -v` — all tests pass including new `test_dynamic_related_techniques`
+- Existing `test_get_technique_detail` still passes (related_links populated from join table + dynamic query)
+
+## Inputs
+
+- ``backend/schemas.py` — existing RelatedLinkItem schema (line 243)`
+- ``backend/routers/techniques.py` — existing get_technique endpoint (line 109-181)`
+- ``backend/models.py` — TechniquePage model with topic_tags, topic_category, creator_id fields`
+- ``backend/tests/test_public_api.py` — existing test patterns and _seed_full_data helper`
+
+## Expected Output
+
+- ``backend/schemas.py` — RelatedLinkItem enriched with creator_name, topic_category, reason fields`
+- ``backend/routers/techniques.py` — dynamic scored query in get_technique() endpoint`
+- ``backend/tests/test_public_api.py` — new test_dynamic_related_techniques test function`
+
+## Verification
+
+cd backend && python -m pytest tests/test_public_api.py -x -v 2>&1 | tail -20
diff --git a/.gsd/milestones/M010/slices/S02/tasks/T01-SUMMARY.md b/.gsd/milestones/M010/slices/S02/tasks/T01-SUMMARY.md
new file mode 100644
index 0000000..b0ffdc3
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S02/tasks/T01-SUMMARY.md
@@ -0,0 +1,79 @@
+---
+id: T01
+parent: S02
+milestone: M010
+provides: []
+requires: []
+affects: []
+key_files: ["backend/schemas.py", "backend/routers/techniques.py", "backend/tests/test_public_api.py"]
+key_decisions: ["Python-side scoring instead of SQL for clarity and testability — dataset is small", "Curated join-table links take absolute priority; dynamic results fill remaining slots up to 4"]
+patterns_established: []
+drill_down_paths: []
+observability_surfaces: []
+duration: ""
+verification_result: "Ran pytest with -k "technique_detail or dynamic_related" — all 6 tests pass including existing test_get_technique_detail (backward compat) and 4 new dynamic related tests."
+completed_at: 2026-03-31T06:13:47.548Z
+blocker_discovered: false
+---
+
+# T01: Added scored dynamic related-techniques query returning up to 4 ranked results with creator_name, topic_category, and reason fields
+
+> Added scored dynamic related-techniques query returning up to 4 ranked results with creator_name, topic_category, and reason fields
+
+## What Happened
+---
+id: T01
+parent: S02
+milestone: M010
+key_files:
+ - backend/schemas.py
+ - backend/routers/techniques.py
+ - backend/tests/test_public_api.py
+key_decisions:
+ - Python-side scoring instead of SQL for clarity and testability — dataset is small
+ - Curated join-table links take absolute priority; dynamic results fill remaining slots up to 4
+duration: ""
+verification_result: passed
+completed_at: 2026-03-31T06:13:47.548Z
+blocker_discovered: false
+---
+
+# T01: Added scored dynamic related-techniques query returning up to 4 ranked results with creator_name, topic_category, and reason fields
+
+**Added scored dynamic related-techniques query returning up to 4 ranked results with creator_name, topic_category, and reason fields**
+
+## What Happened
+
+Enriched RelatedLinkItem schema with creator_name, topic_category, and reason fields. Added _find_dynamic_related helper that loads candidates, scores them in Python (same creator+category: 3, same creator: 2, same category: 2, +1 per shared tag), returns top N enriched results. Curated join-table links take priority; dynamic fills remaining slots up to 4. Dynamic query failure is non-blocking (logs WARNING). Wrote 4 integration tests covering ranking, self-exclusion, no-peers, and NULL tags.
+
+## Verification
+
+Ran pytest with -k "technique_detail or dynamic_related" — all 6 tests pass including existing test_get_technique_detail (backward compat) and 4 new dynamic related tests.
+
+## Verification Evidence
+
+| # | Command | Exit Code | Verdict | Duration |
+|---|---------|-----------|---------|----------|
+| 1 | `cd backend && python -m pytest tests/test_public_api.py -v -k 'technique_detail or dynamic_related'` | 0 | ✅ pass | 2830ms |
+
+
+## Deviations
+
+None.
+
+## Known Issues
+
+Pre-existing test failures in topics (7 vs 6 categories) and creators (response shape mismatch) are unrelated to this task.
+
+## Files Created/Modified
+
+- `backend/schemas.py`
+- `backend/routers/techniques.py`
+- `backend/tests/test_public_api.py`
+
+
+## Deviations
+None.
+
+## Known Issues
+Pre-existing test failures in topics (7 vs 6 categories) and creators (response shape mismatch) are unrelated to this task.
diff --git a/.gsd/milestones/M010/slices/S02/tasks/T02-PLAN.md b/.gsd/milestones/M010/slices/S02/tasks/T02-PLAN.md
new file mode 100644
index 0000000..4673557
--- /dev/null
+++ b/.gsd/milestones/M010/slices/S02/tasks/T02-PLAN.md
@@ -0,0 +1,61 @@
+---
+estimated_steps: 28
+estimated_files: 3
+skills_used: []
+---
+
+# T02: Frontend card-based related techniques section
+
+Replace the plain list rendering of related techniques with a responsive card grid. Update the TypeScript interface to match the enriched backend schema.
+
+## Steps
+
+1. **Update `RelatedLinkItem` in `frontend/src/api/public-client.ts`**: Add `creator_name: string`, `topic_category: string`, `reason: string` fields (all optional with defaults for backward compat with empty strings).
+
+2. **Replace list with card grid in `frontend/src/pages/TechniquePage.tsx`**: Remove the `
` list and replace with a CSS grid of cards. Each card shows:
+ - Technique title as a clickable `` to `/techniques/{target_slug}`
+ - Creator name (if non-empty) in muted text
+ - Topic category as a small badge (reuse existing `.badge` CSS patterns)
+ - Reason text in small muted text below
+ Use semantic class names: `.related-card`, `.related-card__title`, `.related-card__creator`, `.related-card__badge`, `.related-card__reason`
+
+3. **Update CSS in `frontend/src/App.css`**: Replace `.technique-related__list` styles with:
+ - `.technique-related__grid`: CSS grid, 2 columns on desktop (min-width: 600px), 1 column on mobile
+ - `.related-card`: background var(--color-surface-card), border-radius, padding, border using existing design tokens
+ - `.related-card__title`: link color, font-weight 600
+ - `.related-card__creator`: muted text, small font
+ - `.related-card__badge`: reuse existing badge pattern (small, inline, category-colored)
+ - `.related-card__reason`: muted, italic, smallest font size
+ Keep the existing `.technique-related` and `.technique-related h2` styles.
+
+4. **Build verification**: Run `cd frontend && npm run build` to confirm zero TypeScript errors and successful production build.
+
+## Must-Haves
+
+- [ ] `RelatedLinkItem` TypeScript interface includes creator_name, topic_category, reason
+- [ ] Related section renders as card grid, not plain list
+- [ ] Cards show title (linked), creator name, category badge, reason
+- [ ] Responsive: 2 columns on desktop, 1 on mobile
+- [ ] `npm run build` passes with zero errors
+
+## Verification
+
+- `cd frontend && npm run build 2>&1 | tail -5` — build succeeds with zero errors
+- `grep -c 'related-card' frontend/src/App.css` returns >= 4 (card styles exist)
+- `grep -q 'creator_name' frontend/src/api/public-client.ts` confirms type updated
+
+## Inputs
+
+- ``frontend/src/api/public-client.ts` — existing RelatedLinkItem interface (line 48-52)`
+- ``frontend/src/pages/TechniquePage.tsx` — existing related section rendering (line 494-511)`
+- ``frontend/src/App.css` — existing .technique-related styles (line 1774-1808)`
+
+## Expected Output
+
+- ``frontend/src/api/public-client.ts` — RelatedLinkItem with creator_name, topic_category, reason fields`
+- ``frontend/src/pages/TechniquePage.tsx` — card-based related techniques grid replacing plain list`
+- ``frontend/src/App.css` — new .related-card grid styles replacing .technique-related__list`
+
+## Verification
+
+cd frontend && npm run build 2>&1 | tail -5
diff --git a/backend/routers/techniques.py b/backend/routers/techniques.py
index 36f19bd..0c2dbea 100644
--- a/backend/routers/techniques.py
+++ b/backend/routers/techniques.py
@@ -29,6 +29,87 @@ logger = logging.getLogger("chrysopedia.techniques")
router = APIRouter(prefix="/techniques", tags=["techniques"])
+async def _find_dynamic_related(
+ db: AsyncSession,
+ page: TechniquePage,
+ exclude_slugs: set[str],
+ limit: int,
+) -> list[RelatedLinkItem]:
+ """Score and return dynamically related technique pages.
+
+ Scoring:
+ - Same creator + same topic_category: +3
+ - Same creator, different category: +2
+ - Same topic_category, different creator: +2
+ - Each overlapping topic_tag: +1
+ """
+ exclude_ids = {page.id}
+
+ # Base: all other technique pages, eagerly load creator for name
+ stmt = (
+ select(TechniquePage)
+ .options(selectinload(TechniquePage.creator))
+ .where(TechniquePage.id != page.id)
+ )
+ if exclude_slugs:
+ stmt = stmt.where(TechniquePage.slug.notin_(exclude_slugs))
+
+ result = await db.execute(stmt)
+ candidates = result.scalars().all()
+
+ if not candidates:
+ return []
+
+ current_tags = set(page.topic_tags) if page.topic_tags else set()
+
+ scored: list[tuple[int, str, TechniquePage]] = []
+ for cand in candidates:
+ score = 0
+ reasons: list[str] = []
+
+ same_creator = cand.creator_id == page.creator_id
+ same_category = cand.topic_category == page.topic_category
+
+ if same_creator and same_category:
+ score += 3
+ reasons.append("Same creator, same topic")
+ elif same_creator:
+ score += 2
+ reasons.append("Same creator")
+ elif same_category:
+ score += 2
+ reasons.append(f"Also about {page.topic_category}")
+
+ # Tag overlap scoring
+ if current_tags:
+ cand_tags = set(cand.topic_tags) if cand.topic_tags else set()
+ shared = current_tags & cand_tags
+ if shared:
+ score += len(shared)
+ reasons.append(f"Shared tags: {', '.join(sorted(shared))}")
+
+ if score > 0:
+ scored.append((score, "; ".join(reasons), cand))
+
+ # Sort descending by score, then by title for determinism
+ scored.sort(key=lambda x: (-x[0], x[2].title))
+
+ results: list[RelatedLinkItem] = []
+ for score, reason, cand in scored[:limit]:
+ creator_name = cand.creator.name if cand.creator else ""
+ results.append(
+ RelatedLinkItem(
+ target_title=cand.title,
+ target_slug=cand.slug,
+ relationship="dynamic",
+ creator_name=creator_name,
+ topic_category=cand.topic_category,
+ reason=reason,
+ )
+ )
+ return results
+
+
@router.get("", response_model=PaginatedResponse)
async def list_techniques(
category: Annotated[str | None, Query()] = None,
@@ -165,6 +246,23 @@ async def get_technique(
)
)
+ # Supplement with dynamic related techniques (up to 4 total)
+ curated_slugs = {link.target_slug for link in related_links}
+ max_related = 4
+ if len(related_links) < max_related:
+ remaining = max_related - len(related_links)
+ try:
+ dynamic_links = await _find_dynamic_related(
+ db, page, curated_slugs, remaining
+ )
+ related_links.extend(dynamic_links)
+ except Exception:
+ logger.warning(
+ "Dynamic related query failed for %s, continuing with curated only",
+ slug,
+ exc_info=True,
+ )
+
base = TechniquePageRead.model_validate(page)
# Count versions for this page
diff --git a/backend/schemas.py b/backend/schemas.py
index c46b417..f03141b 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -247,6 +247,9 @@ class RelatedLinkItem(BaseModel):
target_title: str = ""
target_slug: str = ""
relationship: str = ""
+ creator_name: str = ""
+ topic_category: str = ""
+ reason: str = ""
class CreatorInfo(BaseModel):
diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py
index fdddc92..0cf0183 100644
--- a/backend/tests/test_public_api.py
+++ b/backend/tests/test_public_api.py
@@ -594,3 +594,205 @@ async def test_technique_detail_includes_version_count(client, db_engine):
resp2 = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}")
assert resp2.status_code == 200
assert resp2.json()["version_count"] == 1
+
+
+# ── Dynamic Related Techniques Tests ─────────────────────────────────────────
+
+
+async def _seed_related_data(db_engine) -> dict:
+ """Seed 2 creators and 5 technique pages with overlapping tags/categories.
+
+ Returns a dict of slugs and metadata for related-technique assertions.
+ """
+ session_factory = async_sessionmaker(
+ db_engine, class_=AsyncSession, expire_on_commit=False
+ )
+ async with session_factory() as session:
+ creator_a = Creator(
+ name="Creator A",
+ slug="creator-a",
+ genres=["Ambient"],
+ folder_name="CreatorA",
+ )
+ creator_b = Creator(
+ name="Creator B",
+ slug="creator-b",
+ genres=["Techno"],
+ folder_name="CreatorB",
+ )
+ session.add_all([creator_a, creator_b])
+ await session.flush()
+
+ # tp1: creator_a, "Sound design", tags: [reverb, delay]
+ tp1 = TechniquePage(
+ creator_id=creator_a.id,
+ title="Reverb Chains",
+ slug="reverb-chains",
+ topic_category="Sound design",
+ topic_tags=["reverb", "delay"],
+ summary="Chaining reverbs for depth",
+ )
+ # tp2: creator_a, "Sound design", tags: [reverb, modulation]
+ # Same creator + same category = score 3, shared tag 'reverb' = +1 → 4
+ tp2 = TechniquePage(
+ creator_id=creator_a.id,
+ title="Advanced Reverb Modulation",
+ slug="advanced-reverb-modulation",
+ topic_category="Sound design",
+ topic_tags=["reverb", "modulation"],
+ summary="Modulating reverb tails",
+ )
+ # tp3: creator_a, "Mixing", tags: [delay, sidechain]
+ # Same creator, different category = score 2, shared tag 'delay' = +1 → 3
+ tp3 = TechniquePage(
+ creator_id=creator_a.id,
+ title="Delay Mixing Tricks",
+ slug="delay-mixing-tricks",
+ topic_category="Mixing",
+ topic_tags=["delay", "sidechain"],
+ summary="Using delay in mix context",
+ )
+ # tp4: creator_b, "Sound design", tags: [reverb]
+ # Different creator, same category = score 2, shared tag 'reverb' = +1 → 3
+ tp4 = TechniquePage(
+ creator_id=creator_b.id,
+ title="Plate Reverb Techniques",
+ slug="plate-reverb-techniques",
+ topic_category="Sound design",
+ topic_tags=["reverb"],
+ summary="Plate reverb for vocals",
+ )
+ # tp5: creator_b, "Mastering", tags: [limiting]
+ # Different creator, different category, no shared tags = score 0
+ tp5 = TechniquePage(
+ creator_id=creator_b.id,
+ title="Mastering Limiter Setup",
+ slug="mastering-limiter-setup",
+ topic_category="Mastering",
+ topic_tags=["limiting"],
+ summary="Setting up a mastering limiter",
+ )
+ session.add_all([tp1, tp2, tp3, tp4, tp5])
+ await session.commit()
+
+ return {
+ "tp1_slug": tp1.slug,
+ "tp2_slug": tp2.slug,
+ "tp3_slug": tp3.slug,
+ "tp4_slug": tp4.slug,
+ "tp5_slug": tp5.slug,
+ }
+
+
+@pytest.mark.asyncio
+async def test_dynamic_related_techniques(client, db_engine):
+ """Dynamic related links are scored and ranked correctly."""
+ seed = await _seed_related_data(db_engine)
+
+ resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}")
+ assert resp.status_code == 200
+
+ data = resp.json()
+ related = data["related_links"]
+
+ # tp1 should match tp2 (score 4), tp3 (score 3), tp4 (score 3), NOT tp5 (score 0)
+ related_slugs = [r["target_slug"] for r in related]
+ assert seed["tp5_slug"] not in related_slugs
+ assert len(related) <= 4
+
+ # tp2 should be first (highest score: same creator + same category + shared tag)
+ assert related[0]["target_slug"] == seed["tp2_slug"]
+
+ # All results should have enriched fields populated
+ for r in related:
+ assert r["creator_name"] != ""
+ assert r["topic_category"] != ""
+ assert r["reason"] != ""
+
+
+@pytest.mark.asyncio
+async def test_dynamic_related_excludes_self(client, db_engine):
+ """The technique itself never appears in its own related_links."""
+ seed = await _seed_related_data(db_engine)
+
+ resp = await client.get(f"{TECHNIQUES_URL}/{seed['tp1_slug']}")
+ assert resp.status_code == 200
+
+ related_slugs = {r["target_slug"] for r in resp.json()["related_links"]}
+ assert seed["tp1_slug"] not in related_slugs
+
+
+@pytest.mark.asyncio
+async def test_dynamic_related_no_peers(client, db_engine):
+ """A technique with no matching peers returns empty related_links."""
+ session_factory = async_sessionmaker(
+ db_engine, class_=AsyncSession, expire_on_commit=False
+ )
+ async with session_factory() as session:
+ creator = Creator(
+ name="Solo Artist",
+ slug="solo-artist",
+ genres=["Noise"],
+ folder_name="SoloArtist",
+ )
+ session.add(creator)
+ await session.flush()
+
+ tp = TechniquePage(
+ creator_id=creator.id,
+ title="Unique Technique",
+ slug="unique-technique",
+ topic_category="Experimental",
+ topic_tags=["unique-tag-xyz"],
+ summary="Completely unique",
+ )
+ session.add(tp)
+ await session.commit()
+
+ resp = await client.get(f"{TECHNIQUES_URL}/unique-technique")
+ assert resp.status_code == 200
+ assert resp.json()["related_links"] == []
+
+
+@pytest.mark.asyncio
+async def test_dynamic_related_null_tags(client, db_engine):
+ """Technique with NULL topic_tags still scores on creator_id/topic_category."""
+ session_factory = async_sessionmaker(
+ db_engine, class_=AsyncSession, expire_on_commit=False
+ )
+ async with session_factory() as session:
+ creator = Creator(
+ name="Tag-Free Creator",
+ slug="tag-free-creator",
+ genres=["Pop"],
+ folder_name="TagFreeCreator",
+ )
+ session.add(creator)
+ await session.flush()
+
+ tp_main = TechniquePage(
+ creator_id=creator.id,
+ title="No Tags Main",
+ slug="no-tags-main",
+ topic_category="Production",
+ topic_tags=None,
+ summary="Main page with no tags",
+ )
+ tp_peer = TechniquePage(
+ creator_id=creator.id,
+ title="No Tags Peer",
+ slug="no-tags-peer",
+ topic_category="Production",
+ topic_tags=None,
+ summary="Peer page, same creator and category",
+ )
+ session.add_all([tp_main, tp_peer])
+ await session.commit()
+
+ resp = await client.get(f"{TECHNIQUES_URL}/no-tags-main")
+ assert resp.status_code == 200
+
+ related = resp.json()["related_links"]
+ assert len(related) == 1
+ assert related[0]["target_slug"] == "no-tags-peer"
+ assert related[0]["reason"] == "Same creator, same topic"