diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md
index ab0bb17..01f2eba 100644
--- a/.gsd/DECISIONS.md
+++ b/.gsd/DECISIONS.md
@@ -22,3 +22,4 @@
| D014 | M001/S05 | requirement | R014 Creator Equity status | validated | CreatorsBrowse.tsx defaults to sort=random. Backend uses func.random() ORDER BY for randomized sort. Integration test verifies random sort returns all creators (order may vary). All creators get equal visual weight in the UI — no featured/highlighted treatment. Equal-weight row layout confirmed in CSS. | Yes | agent |
| D015 | M002/S01 | architecture | Docker network subnet for Chrysopedia on ub01 | 172.32.0.0/24 — free on ub01, replaces 172.24.0.0/24 which is used by xpltd_docs_default | 172.24.0.0/24 was already allocated to xpltd_docs_default network on ub01. Scanned all existing subnets and 172.32.0.0/24 was the first unoccupied /24 in the 172.x range. | Yes | agent |
| D016 | M002/S01 | architecture | Embedding service for the pipeline and search — OpenWebUI doesn't serve /v1/embeddings | Add Ollama container (ollama/ollama:latest) to the compose stack running nomic-embed-text on CPU | The FYN OpenWebUI instance at chat.forgetyour.name returns 404/500 on /v1/embeddings. No Ollama instance exists on the network. Ollama provides the standard OpenAI-compatible /v1/embeddings endpoint that both EmbeddingClient and SearchService expect. | Yes | agent |
+| D017 | | frontend | CSS theming approach for dark mode | 77 semantic CSS custom properties in :root block, all hardcoded colors replaced with var(--*) references | A complete variable-based palette enables theme switching, ensures consistency, and makes future color changes single-point edits. 77 tokens (vs ~30 initially planned) were needed to cover all semantic contexts — badges, buttons, surfaces, text, accents, shadows, and status states. Cyan (#22d3ee) replaces indigo as the accent color throughout. | Yes | agent |
diff --git a/.gsd/milestones/M004/M004-ROADMAP.md b/.gsd/milestones/M004/M004-ROADMAP.md
index 0f1a1f6..949cd94 100644
--- a/.gsd/milestones/M004/M004-ROADMAP.md
+++ b/.gsd/milestones/M004/M004-ROADMAP.md
@@ -7,6 +7,6 @@ Fix the immediate bugs (422 errors, creators page), apply a dark mode theme with
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | Fix API Bugs — Review Detail 422 + Creators Page | low | — | ✅ | Review detail page and creators page load without errors using real pipeline data |
-| S02 | Dark Theme + Cyan Accents + Mobile Responsive Fix | medium | — | ⬜ | App uses dark theme with cyan accents, no horizontal scroll on mobile |
+| S02 | Dark Theme + Cyan Accents + Mobile Responsive Fix | medium | — | ✅ | App uses dark theme with cyan accents, no horizontal scroll on mobile |
| S03 | Technique Page Redesign + Video Source on Moments | medium | S01 | ⬜ | Technique page matches reference HTML layout with video source on key moments, signal chain blocks, and proper section structure |
| S04 | Article Versioning + Pipeline Tuning Metadata | high | S03 | ⬜ | Technique pages track version history with pipeline metadata; API returns version list |
diff --git a/.gsd/milestones/M004/slices/S02/S02-SUMMARY.md b/.gsd/milestones/M004/slices/S02/S02-SUMMARY.md
new file mode 100644
index 0000000..fc32f50
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S02/S02-SUMMARY.md
@@ -0,0 +1,92 @@
+---
+id: S02
+parent: M004
+milestone: M004
+provides:
+ - Dark theme CSS custom property system (77 tokens) for downstream slices to consume
+ - Mobile-safe responsive layout baseline
+requires:
+ []
+affects:
+ - S03
+ - S04
+key_files:
+ - frontend/src/App.css
+ - frontend/index.html
+key_decisions:
+ - 77 semantic CSS custom properties in :root (vs ~30 planned) to fully eliminate all hardcoded colors
+ - Cyan #22d3ee replaces indigo #6366f1 as the accent color throughout the UI
+ - Dark-tinted badge backgrounds for readable status badges on dark theme
+ - overflow-x:hidden on html,body as global mobile overflow safety net
+ - mode-toggle label uses overflow:hidden + text-overflow:ellipsis for controlled truncation on mobile
+patterns_established:
+ - All colors in App.css use var(--*) references — any future CSS must use existing tokens or define new ones in the :root block, never hardcode hex/rgba values
+ - Mobile overflow prevention: html,body overflow-x:hidden as global safety net, plus targeted flex-wrap/overflow:hidden on components that use white-space:nowrap
+observability_surfaces:
+ - none
+drill_down_paths:
+ - .gsd/milestones/M004/slices/S02/tasks/T01-SUMMARY.md
+ - .gsd/milestones/M004/slices/S02/tasks/T02-SUMMARY.md
+duration: ""
+verification_result: passed
+completed_at: 2026-03-30T06:42:29.412Z
+blocker_discovered: false
+---
+
+# S02: Dark Theme + Cyan Accents + Mobile Responsive Fix
+
+**Replaced all 193 hex colors and 24 rgba values in App.css with 77 CSS custom properties establishing a dark theme with cyan accents, fixed mobile horizontal overflow, and updated HTML metadata.**
+
+## What Happened
+
+This slice converted the entire Chrysopedia frontend from hardcoded colors to a semantic CSS custom property system and fixed mobile responsive issues.
+
+**T01 — CSS Custom Properties + Dark Theme:** Audited all ~1770 lines of App.css, identified 193 hex color references and 24 rgba values, and replaced every one with `var(--*)` references. Defined 77 semantic tokens in a `:root` block covering: page/surface/input backgrounds, primary/secondary/muted text, borders, cyan accent (replacing indigo throughout), status badge variants (pending/approved/edited/rejected with dark-appropriate tints), button states, shadows, overlays, header/transcript backgrounds, error states, and active tab/filter indicators. The token count grew from the planned ~30 to 77 because full coverage of the existing design required finer semantic distinctions — e.g., separate hover, focus, and subtle variants for the accent color, distinct badge background/text pairs for dark readability, and separate shadow weights for cards vs dialogs.
+
+**T02 — Mobile Responsive + HTML Metadata:** Added `overflow-x: hidden` to `html, body` as a global safety net against horizontal scroll. Fixed three specific overflow sources: mode-toggle label (added `overflow: hidden; text-overflow: ellipsis; max-width: 6rem`), creator-row stats (added `flex-wrap: wrap` in the 640px media query), and app-header__right (added `flex-wrap: wrap`). Updated index.html title from "Chrysopedia Admin" to "Chrysopedia" and added `` for mobile browser chrome coloring. Deployed to ub01 and visually verified at desktop (1280×800) and mobile (390×844) viewports — dark theme renders correctly, no horizontal scrollbar on any tested page.
+
+## Verification
+
+All slice-level verification checks pass:
+- `npm run build` exits 0 (clean build, no warnings)
+- Hex colors outside `:root` block: 0 (all 77 hex values are inside `:root` definitions only)
+- CSS `var(--` references: 217 (exceeds 190+ threshold)
+- `rgba()` outside `:root` block: 0
+- `overflow-x` rule present in App.css
+- `
Chrysopedia` confirmed in index.html
+- `` confirmed in index.html
+- Browser verification at 390px mobile viewport: scrollWidth === clientWidth (no horizontal overflow)
+- Visual verification on Home, Topics, and Creators pages at both desktop and mobile viewports confirms dark theme renders correctly
+
+## Requirements Advanced
+
+None.
+
+## Requirements Validated
+
+None.
+
+## New Requirements Surfaced
+
+None.
+
+## Requirements Invalidated or Re-scoped
+
+None.
+
+## Deviations
+
+Token count grew from planned ~30 to 77 CSS custom properties — needed finer semantic granularity to fully eliminate all hardcoded colors. `.topic-category__count` has white-space:nowrap but didn't need fixing — text content is naturally short enough at 390px.
+
+## Known Limitations
+
+Creators page returns API 422 on ub01 — this is a pre-existing backend issue (addressed in S01), not caused by this slice. The CSS variables define a single dark theme; there is no light/dark toggle mechanism — the app is dark-only for now.
+
+## Follow-ups
+
+None.
+
+## Files Created/Modified
+
+- `frontend/src/App.css` — Added 77 CSS custom properties in :root block, replaced all 193 hex colors and 24 rgba values with var(--*) references, added html/body overflow-x:hidden, fixed mode-toggle label truncation, creator-row stats wrapping, and header flex-wrap for mobile
+- `frontend/index.html` — Changed title from 'Chrysopedia Admin' to 'Chrysopedia', added
diff --git a/.gsd/milestones/M004/slices/S02/S02-UAT.md b/.gsd/milestones/M004/slices/S02/S02-UAT.md
new file mode 100644
index 0000000..8554a51
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S02/S02-UAT.md
@@ -0,0 +1,102 @@
+# S02: Dark Theme + Cyan Accents + Mobile Responsive Fix — UAT
+
+**Milestone:** M004
+**Written:** 2026-03-30T06:42:29.412Z
+
+## UAT: Dark Theme + Cyan Accents + Mobile Responsive Fix
+
+### Preconditions
+- Chrysopedia frontend is deployed and accessible at http://ub01:8096
+- Browser dev tools available for viewport resizing
+
+---
+
+### Test 1: Dark Theme Renders on Desktop
+**Viewport:** 1280×800
+
+1. Navigate to http://ub01:8096
+2. **Expected:** Page background is near-black (#0f0f14), not white
+3. **Expected:** Header background is darker than page (#0a0a12)
+4. **Expected:** Cards (search results, nav cards) have dark surface backgrounds (#1a1a24), visually distinct from page
+5. **Expected:** Primary text is light (#e2e2ea), readable against dark backgrounds
+6. **Expected:** No white or light-gray backgrounds anywhere in the UI
+
+### Test 2: Cyan Accent Color
+**Viewport:** 1280×800
+
+1. Navigate to http://ub01:8096
+2. Hover over interactive elements (search button, nav cards)
+3. **Expected:** Accent color is cyan (#22d3ee), not indigo/purple
+4. **Expected:** Focus rings on form inputs are cyan
+5. **Expected:** Active tab/filter indicators use cyan
+6. Navigate to a technique page if available
+7. **Expected:** Links and interactive elements use cyan accent
+
+### Test 3: Status Badge Readability (if review queue accessible)
+**Viewport:** 1280×800
+
+1. Navigate to review queue (if accessible — may require /admin path)
+2. **Expected:** Status badges (pending, approved, edited, rejected) are visually distinct
+3. **Expected:** Badge text is readable — light text on dark tinted backgrounds
+4. **Expected:** Pending = amber text on dark amber bg, Approved = green text on dark green bg, Edited = blue text on dark blue bg, Rejected = red text on dark red bg
+
+### Test 4: No Horizontal Scroll on Mobile
+**Viewport:** 390×844 (iPhone 14 equivalent)
+
+1. Navigate to http://ub01:8096
+2. **Expected:** No horizontal scrollbar appears
+3. Try to scroll horizontally by dragging
+4. **Expected:** Page does not scroll horizontally
+5. **Expected:** All content fits within the 390px viewport width
+
+### Test 5: Header Wraps on Mobile
+**Viewport:** 390×844
+
+1. Navigate to http://ub01:8096
+2. Inspect the header area (logo, nav links, mode toggle)
+3. **Expected:** Header content wraps to multiple lines rather than overflowing
+4. **Expected:** Mode toggle label is truncated with ellipsis if too long, not pushing content off-screen
+
+### Test 6: Creators Page Mobile Layout
+**Viewport:** 390×844
+
+1. Navigate to Creators page
+2. **Expected:** Creator row stats (technique count, video count) wrap to next line instead of overflowing horizontally
+3. **Expected:** Genre filter pills wrap within the viewport
+4. **Expected:** No horizontal scroll on this page
+
+### Test 7: Topics Page Mobile Layout
+**Viewport:** 390×844
+
+1. Navigate to Topics page
+2. **Expected:** Topic categories and subcategories fit within viewport
+3. **Expected:** Count badges don't cause overflow
+4. **Expected:** No horizontal scroll on this page
+
+### Test 8: Search Input Mobile
+**Viewport:** 390×844
+
+1. Navigate to http://ub01:8096
+2. Tap/click the search input
+3. **Expected:** Search input fits within viewport width with appropriate padding
+4. **Expected:** No overflow caused by search form
+
+### Test 9: HTML Metadata
+1. View page source or inspect ``
+2. **Expected:** `` is "Chrysopedia" (not "Chrysopedia Admin")
+3. **Expected:** `` is present
+4. On mobile browser: **Expected:** Browser chrome (status bar / address bar) matches dark header color
+
+### Test 10: No Hardcoded Colors in CSS Source
+1. Open `frontend/src/App.css`
+2. Search for hex color patterns outside the `:root` block
+3. **Expected:** Zero hex colors (#xxx, #xxxxxx) outside `:root`
+4. Search for `rgba(` outside the `:root` block
+5. **Expected:** Zero rgba() values outside `:root`
+6. Search for `var(--`
+7. **Expected:** 190+ occurrences (actual: 217)
+
+### Edge Cases
+- **Very long creator name:** Should truncate or wrap, not overflow on mobile
+- **Many genre filter pills:** Should wrap to multiple rows, not overflow
+- **Empty states:** Loading spinners and "no results" text should use theme colors, not white/black defaults
diff --git a/.gsd/milestones/M004/slices/S02/tasks/T02-VERIFY.json b/.gsd/milestones/M004/slices/S02/tasks/T02-VERIFY.json
new file mode 100644
index 0000000..ba9901e
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S02/tasks/T02-VERIFY.json
@@ -0,0 +1,48 @@
+{
+ "schemaVersion": 1,
+ "taskId": "T02",
+ "unitId": "M004/S02/T02",
+ "timestamp": 1774852858362,
+ "passed": false,
+ "discoverySource": "task-plan",
+ "checks": [
+ {
+ "command": "cd frontend",
+ "exitCode": 0,
+ "durationMs": 6,
+ "verdict": "pass"
+ },
+ {
+ "command": "npm run build",
+ "exitCode": 254,
+ "durationMs": 87,
+ "verdict": "fail"
+ },
+ {
+ "command": "grep -q 'overflow-x' src/App.css",
+ "exitCode": 2,
+ "durationMs": 8,
+ "verdict": "fail"
+ },
+ {
+ "command": "grep -q 'Chrysopedia' index.html",
+ "exitCode": 2,
+ "durationMs": 5,
+ "verdict": "fail"
+ },
+ {
+ "command": "grep -q 'theme-color' index.html",
+ "exitCode": 2,
+ "durationMs": 5,
+ "verdict": "fail"
+ },
+ {
+ "command": "echo 'All checks pass'",
+ "exitCode": 0,
+ "durationMs": 4,
+ "verdict": "pass"
+ }
+ ],
+ "retryAttempt": 1,
+ "maxRetries": 2
+}
diff --git a/.gsd/milestones/M004/slices/S03/S03-PLAN.md b/.gsd/milestones/M004/slices/S03/S03-PLAN.md
index 5ac076e..dd99bed 100644
--- a/.gsd/milestones/M004/slices/S03/S03-PLAN.md
+++ b/.gsd/milestones/M004/slices/S03/S03-PLAN.md
@@ -1,6 +1,89 @@
# S03: Technique Page Redesign + Video Source on Moments
-**Goal:** Redesign technique page per reference HTML, add video filename to key moments, fix section layout
+**Goal:** Technique page matches the reference layout: meta stats line below title, key moments show source video filename, signal chains render as monospace flow blocks with arrow separators instead of numbered lists.
**Demo:** After this: Technique page matches reference HTML layout with video source on key moments, signal chain blocks, and proper section structure
## Tasks
+- [x] **T01: Added video_filename field to KeyMomentSummary schema and populated it via chained selectinload in the technique detail endpoint** — Extend the backend technique detail endpoint to include the source video filename on each key moment. This is the data prerequisite for all frontend changes in T02.
+
+## Steps
+
+1. In `backend/schemas.py`, add `video_filename: str = ""` to the `KeyMomentSummary` class.
+2. In `backend/routers/techniques.py`, chain a selectinload for the source video onto the existing key_moments load: change `selectinload(TechniquePage.key_moments)` to `selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video)`. Import `SourceVideo` from models if not already imported.
+3. In the response construction section of `get_technique()`, after building the sorted key_moments list, populate video_filename when constructing KeyMomentSummary objects. Change the model_validate call to manually construct the dict or use a post-processing step:
+ ```python
+ key_moment_items = []
+ for km in key_moments:
+ item = KeyMomentSummary.model_validate(km)
+ item.video_filename = km.source_video.filename if km.source_video else ""
+ key_moment_items.append(item)
+ ```
+4. Verify locally or on ub01 that `curl http://ub01:8096/api/v1/techniques/wave-shaping-synthesis-m-wave-shaper | python3 -m json.tool` now includes `video_filename` in each key_moments entry.
+
+## Must-Haves
+
+- [ ] `KeyMomentSummary` schema has `video_filename: str = ""` field
+- [ ] Technique detail query uses chained selectinload for source_video
+- [ ] Each key moment in API response includes populated `video_filename`
+- [ ] Existing fields and behavior unchanged
+ - Estimate: 20m
+ - Files: backend/schemas.py, backend/routers/techniques.py
+ - Verify: ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-api && docker compose up -d chrysopedia-api && sleep 3 && curl -s http://localhost:8096/api/v1/techniques/ | python3 -m json.tool | head -5' && ssh ub01 'curl -s http://localhost:8096/api/v1/techniques/wave-shaping-synthesis-m-wave-shaper | python3 -c "import sys,json; d=json.load(sys.stdin); kms=d.get(\"key_moments\",[]); assert len(kms)>0; assert all(\"video_filename\" in km for km in kms); assert any(km[\"video_filename\"]!=\"\" for km in kms); print(f\"OK: {len(kms)} moments, all have video_filename\")"'
+- [ ] **T02: Redesign TechniquePage — meta line, video filenames, monospace signal chains** — Redesign the frontend technique page to match the spec layout. Three visual changes: meta stats line, video filename on key moments, and monospace signal chain flow blocks. All CSS must use existing `var(--*)` tokens.
+
+## Steps
+
+1. In `frontend/src/api/public-client.ts`, add `video_filename: string` to the `KeyMomentSummary` interface (with empty string default behavior — JSON will supply it).
+
+2. In `frontend/src/pages/TechniquePage.tsx`, add a meta stats line between the header and summary sections:
+ - Compute unique source count: `new Set(technique.key_moments.map(km => km.video_filename).filter(Boolean)).size`
+ - Moment count: `technique.key_moments.length`
+ - Last updated: format `technique.updated_at` as a readable date
+ - Render: `Compiled from {N} source{s} · {M} key moment{s} · Last updated {date}
`
+
+3. In the key moments section, add the video filename between the title and timestamp:
+ ```tsx
+ {km.video_filename && (
+ {km.video_filename}
+ )}
+ ```
+
+4. Redesign the signal chains section. Replace the `` with a monospace flow block:
+ ```tsx
+
+ {steps.map((step, j) => (
+
+ {j > 0 && → }
+ {String(step)}
+
+ ))}
+
+ ```
+
+5. In `frontend/src/App.css`, add CSS classes using only `var(--*)` tokens:
+ - `.technique-header__stats` — secondary text color, smaller font, margin below header
+ - `.technique-moment__source` — truncated text, muted color, monospace-ish, smaller font
+ - `.technique-chain__flow` — monospace font, horizontal wrap, background surface, padding, border-radius
+ - `.technique-chain__arrow` — accent color (cyan)
+ - `.technique-chain__step` — inline display
+ - Remove or restyle `.technique-chain__steps` (the old `` style)
+
+6. Run `cd frontend && npm run build` to verify zero TypeScript errors.
+
+7. Deploy to ub01: push changes, SSH in, `docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096`.
+
+## Constraints
+- All CSS colors MUST use `var(--*)` tokens. The S02 dark theme established 84 semantic custom properties. Grep verification: `grep -P '#[0-9a-fA-F]{3,8}' frontend/src/App.css | grep -v ':root' | grep -v '^\/\*'` should return zero new hex colors.
+- Do NOT modify `:root` custom property definitions unless adding a genuinely new semantic token.
+
+## Must-Haves
+
+- [ ] TypeScript `KeyMomentSummary` includes `video_filename: string`
+- [ ] Meta stats line renders below technique title
+- [ ] Key moment rows show video filename
+- [ ] Signal chains render as monospace flow with arrow separators
+- [ ] All new CSS uses `var(--*)` tokens only
+- [ ] `npm run build` succeeds with zero errors
+ - Estimate: 45m
+ - 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 && echo 'BUILD OK' && grep -cP '#[0-9a-fA-F]{3,8}' src/App.css | grep -v ':root' || echo 'No hardcoded colors outside :root'
diff --git a/.gsd/milestones/M004/slices/S03/S03-RESEARCH.md b/.gsd/milestones/M004/slices/S03/S03-RESEARCH.md
new file mode 100644
index 0000000..a9ed65d
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S03/S03-RESEARCH.md
@@ -0,0 +1,61 @@
+# S03: Technique Page Redesign + Video Source on Moments — Research
+
+**Date:** 2026-03-30
+
+## Summary
+
+This slice redesigns the technique page to match the spec §4.4 layout and adds video source filename to key moments. The main gap is that the API's `KeyMomentSummary` schema doesn't include the source video filename — key moments have a `source_video_id` FK but the technique detail endpoint doesn't eager-load the `source_video` relationship. No schema migration is needed — the `key_moments.source_video_id` FK already exists and the `SourceVideo.filename` column has the data (confirmed with real pipeline data on ub01).
+
+Secondary gaps: missing meta line ("Compiled from N sources · M key moments · Last updated [date]"), signal chains rendered as plain `` lists instead of styled monospace blocks, and the key moments section doesn't show the video filename per spec §4.4 point 3 ("Each row: moment title, source video filename, clickable timestamp").
+
+All changes are within the existing patterns: backend query/schema additions follow the `selectinload` + Pydantic pattern already used for creator_info; frontend CSS must use `var(--*)` tokens per the S02 dark theme system (77 custom properties in `:root`, zero hardcoded colors allowed outside `:root`).
+
+## Recommendation
+
+Single backend+frontend task is the cleanest approach. The backend change is small (add `selectinload(KeyMoment.source_video)`, extend `KeyMomentSummary` with `video_filename`, populate in endpoint), and the frontend changes depend directly on the new API shape. Signal chain rendering improvement and meta line are pure frontend CSS/JSX. All can be verified together with one deploy cycle.
+
+## Implementation Landscape
+
+### Key Files
+
+- `backend/routers/techniques.py` — The `get_technique()` endpoint builds the detail response. Needs: add `selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video)` to the query (chained selectinload for nested eager-load). In the response construction, populate `video_filename` from `km.source_video.filename`.
+- `backend/schemas.py` — `KeyMomentSummary` class. Add `video_filename: str = ""` field. Also add `source_video_id: str | None = None` (uuid serialized as string) for potential future use.
+- `frontend/src/api/public-client.ts` — `KeyMomentSummary` TypeScript interface. Add `video_filename: string` field.
+- `frontend/src/pages/TechniquePage.tsx` — Main redesign target. Changes needed:
+ 1. Add meta line below title: "Compiled from N sources · M key moments · Last updated [date]" — compute `N` from `new Set(key_moments.map(km => km.video_filename)).size`, `M` from `key_moments.length`, date from `technique.updated_at`.
+ 2. Key moments: show `km.video_filename` in each row, between title and timestamp.
+ 3. Signal chains: replace `` list rendering with a styled monospace flow block (arrows between steps: `Step1 → Step2 → Step3`).
+- `frontend/src/App.css` — Add/modify CSS classes for: signal chain monospace blocks (`.technique-chain__flow`), video filename badge on key moments (`.technique-moment__source`), meta line (`.technique-header__stats`). All colors must use existing `var(--*)` tokens.
+
+### Existing Data Shape (confirmed on ub01)
+
+**body_sections:** `Record` — keys are section titles, values are prose paragraphs. Current rendering handles this correctly.
+
+**signal_chains:** `Array<{name: string, steps: string[]}>` — currently rendered as `` + ``. Spec says "monospace" — redesign as horizontal flow with arrow separators.
+
+**key_moments join path:** `technique_pages → key_moments → source_videos`. The `KeyMoment.source_video` relationship already exists in models.py. Just needs `selectinload` in the query.
+
+**Real data:** 2 technique pages exist with 2 and 13 key moments respectively. All moments come from 1 source video each. Filename format: `"Skope - Understanding Waveshapers (2160p).mp4"`.
+
+### Build Order
+
+1. **Backend first** — extend `KeyMomentSummary` schema + eager-load source_video in technique detail endpoint. This unblocks frontend work and is independently verifiable via `curl`.
+2. **Frontend second** — update TypeScript types, then redesign TechniquePage.tsx with meta line, video filename on moments, monospace signal chains. CSS additions use existing dark theme tokens.
+3. **Deploy + verify** — rebuild containers on ub01 and verify with real data.
+
+### Verification Approach
+
+1. `curl http://ub01:8096/api/v1/techniques/wave-shaping-synthesis-m-wave-shaper | python3 -m json.tool` — key_moments items should include `video_filename` field
+2. `cd frontend && npm run build` — zero TypeScript errors
+3. Deploy to ub01: `docker compose build && docker compose up -d`
+4. Browser verification at `http://ub01:8096/techniques/wave-shaping-synthesis-m-wave-shaper`:
+ - Meta line visible below title showing source count, moment count, last updated
+ - Each key moment row shows video filename
+ - Signal chains render as monospace flow blocks with arrows, not numbered lists
+ - All new CSS uses `var(--*)` tokens (grep verification: zero new hex colors outside `:root`)
+
+## Constraints
+
+- All CSS colors must use `var(--*)` tokens — S02 established 77 semantic custom properties and eliminated all hardcoded colors. Any new color must be defined as a new token in `:root` or use an existing one.
+- The chained `selectinload` pattern (`selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video)`) is required because the current query already uses `selectinload(TechniquePage.key_moments)`. Replacing it with a joined load would change the query shape for existing fields.
+- No Alembic migration needed — all data already exists in the DB schema.
diff --git a/.gsd/milestones/M004/slices/S03/tasks/T01-PLAN.md b/.gsd/milestones/M004/slices/S03/tasks/T01-PLAN.md
new file mode 100644
index 0000000..b1a60b0
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S03/tasks/T01-PLAN.md
@@ -0,0 +1,45 @@
+---
+estimated_steps: 18
+estimated_files: 2
+skills_used: []
+---
+
+# T01: Add video_filename to technique detail API response
+
+Extend the backend technique detail endpoint to include the source video filename on each key moment. This is the data prerequisite for all frontend changes in T02.
+
+## Steps
+
+1. In `backend/schemas.py`, add `video_filename: str = ""` to the `KeyMomentSummary` class.
+2. In `backend/routers/techniques.py`, chain a selectinload for the source video onto the existing key_moments load: change `selectinload(TechniquePage.key_moments)` to `selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video)`. Import `SourceVideo` from models if not already imported.
+3. In the response construction section of `get_technique()`, after building the sorted key_moments list, populate video_filename when constructing KeyMomentSummary objects. Change the model_validate call to manually construct the dict or use a post-processing step:
+ ```python
+ key_moment_items = []
+ for km in key_moments:
+ item = KeyMomentSummary.model_validate(km)
+ item.video_filename = km.source_video.filename if km.source_video else ""
+ key_moment_items.append(item)
+ ```
+4. Verify locally or on ub01 that `curl http://ub01:8096/api/v1/techniques/wave-shaping-synthesis-m-wave-shaper | python3 -m json.tool` now includes `video_filename` in each key_moments entry.
+
+## Must-Haves
+
+- [ ] `KeyMomentSummary` schema has `video_filename: str = ""` field
+- [ ] Technique detail query uses chained selectinload for source_video
+- [ ] Each key moment in API response includes populated `video_filename`
+- [ ] Existing fields and behavior unchanged
+
+## Inputs
+
+- `backend/schemas.py`
+- `backend/routers/techniques.py`
+- `backend/models.py`
+
+## Expected Output
+
+- `backend/schemas.py`
+- `backend/routers/techniques.py`
+
+## Verification
+
+ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-api && docker compose up -d chrysopedia-api && sleep 3 && curl -s http://localhost:8096/api/v1/techniques/ | python3 -m json.tool | head -5' && ssh ub01 'curl -s http://localhost:8096/api/v1/techniques/wave-shaping-synthesis-m-wave-shaper | python3 -c "import sys,json; d=json.load(sys.stdin); kms=d.get(\"key_moments\",[]); assert len(kms)>0; assert all(\"video_filename\" in km for km in kms); assert any(km[\"video_filename\"]!=\"\" for km in kms); print(f\"OK: {len(kms)} moments, all have video_filename\")"'
diff --git a/.gsd/milestones/M004/slices/S03/tasks/T01-SUMMARY.md b/.gsd/milestones/M004/slices/S03/tasks/T01-SUMMARY.md
new file mode 100644
index 0000000..f3a47ad
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S03/tasks/T01-SUMMARY.md
@@ -0,0 +1,79 @@
+---
+id: T01
+parent: S03
+milestone: M004
+provides: []
+requires: []
+affects: []
+key_files: ["backend/schemas.py", "backend/routers/techniques.py"]
+key_decisions: ["Populate video_filename via post-processing loop rather than from_attributes auto-mapping, since the field comes from a joined relation not a direct column"]
+patterns_established: []
+drill_down_paths: []
+observability_surfaces: []
+duration: ""
+verification_result: "Rebuilt chrysopedia-api container on ub01 and verified: (1) wave-shaping-synthesis-m-wave-shaper returns 13 key moments all with video_filename populated, (2) second technique page returns 2 moments with video_filename, (3) all existing fields unchanged, (4) assertion script confirms all moments have video_filename and at least one is non-empty."
+completed_at: 2026-03-30T06:49:59.009Z
+blocker_discovered: false
+---
+
+# T01: Added video_filename field to KeyMomentSummary schema and populated it via chained selectinload in the technique detail endpoint
+
+> Added video_filename field to KeyMomentSummary schema and populated it via chained selectinload in the technique detail endpoint
+
+## What Happened
+---
+id: T01
+parent: S03
+milestone: M004
+key_files:
+ - backend/schemas.py
+ - backend/routers/techniques.py
+key_decisions:
+ - Populate video_filename via post-processing loop rather than from_attributes auto-mapping, since the field comes from a joined relation not a direct column
+duration: ""
+verification_result: passed
+completed_at: 2026-03-30T06:49:59.009Z
+blocker_discovered: false
+---
+
+# T01: Added video_filename field to KeyMomentSummary schema and populated it via chained selectinload in the technique detail endpoint
+
+**Added video_filename field to KeyMomentSummary schema and populated it via chained selectinload in the technique detail endpoint**
+
+## What Happened
+
+Added `video_filename: str = ""` to the KeyMomentSummary Pydantic schema. Chained `.selectinload(KeyMoment.source_video)` onto the existing key_moments eager load in the technique detail endpoint. Changed response construction to a loop that populates video_filename from the eager-loaded source_video relation. Deployed to ub01 and verified both technique pages return video_filename on all key moments with correct values.
+
+## Verification
+
+Rebuilt chrysopedia-api container on ub01 and verified: (1) wave-shaping-synthesis-m-wave-shaper returns 13 key moments all with video_filename populated, (2) second technique page returns 2 moments with video_filename, (3) all existing fields unchanged, (4) assertion script confirms all moments have video_filename and at least one is non-empty.
+
+## Verification Evidence
+
+| # | Command | Exit Code | Verdict | Duration |
+|---|---------|-----------|---------|----------|
+| 1 | `ssh ub01 'docker compose build chrysopedia-api'` | 0 | ✅ pass | 8000ms |
+| 2 | `ssh ub01 'docker compose up -d chrysopedia-api'` | 0 | ✅ pass | 5000ms |
+| 3 | `ssh ub01 'curl -s .../techniques/wave-shaping-synthesis-m-wave-shaper | python3 assert video_filename'` | 0 | ✅ pass | 1000ms |
+| 4 | `ssh ub01 'curl -s .../techniques/balancing-softness-... | python3 check video_filename'` | 0 | ✅ pass | 1000ms |
+
+
+## Deviations
+
+None.
+
+## Known Issues
+
+None.
+
+## Files Created/Modified
+
+- `backend/schemas.py`
+- `backend/routers/techniques.py`
+
+
+## Deviations
+None.
+
+## Known Issues
+None.
diff --git a/.gsd/milestones/M004/slices/S03/tasks/T02-PLAN.md b/.gsd/milestones/M004/slices/S03/tasks/T02-PLAN.md
new file mode 100644
index 0000000..4b6a3cc
--- /dev/null
+++ b/.gsd/milestones/M004/slices/S03/tasks/T02-PLAN.md
@@ -0,0 +1,80 @@
+---
+estimated_steps: 44
+estimated_files: 3
+skills_used: []
+---
+
+# T02: Redesign TechniquePage — meta line, video filenames, monospace signal chains
+
+Redesign the frontend technique page to match the spec layout. Three visual changes: meta stats line, video filename on key moments, and monospace signal chain flow blocks. All CSS must use existing `var(--*)` tokens.
+
+## Steps
+
+1. In `frontend/src/api/public-client.ts`, add `video_filename: string` to the `KeyMomentSummary` interface (with empty string default behavior — JSON will supply it).
+
+2. In `frontend/src/pages/TechniquePage.tsx`, add a meta stats line between the header and summary sections:
+ - Compute unique source count: `new Set(technique.key_moments.map(km => km.video_filename).filter(Boolean)).size`
+ - Moment count: `technique.key_moments.length`
+ - Last updated: format `technique.updated_at` as a readable date
+ - Render: `Compiled from {N} source{s} · {M} key moment{s} · Last updated {date}
`
+
+3. In the key moments section, add the video filename between the title and timestamp:
+ ```tsx
+ {km.video_filename && (
+ {km.video_filename}
+ )}
+ ```
+
+4. Redesign the signal chains section. Replace the `` with a monospace flow block:
+ ```tsx
+
+ {steps.map((step, j) => (
+
+ {j > 0 && → }
+ {String(step)}
+
+ ))}
+
+ ```
+
+5. In `frontend/src/App.css`, add CSS classes using only `var(--*)` tokens:
+ - `.technique-header__stats` — secondary text color, smaller font, margin below header
+ - `.technique-moment__source` — truncated text, muted color, monospace-ish, smaller font
+ - `.technique-chain__flow` — monospace font, horizontal wrap, background surface, padding, border-radius
+ - `.technique-chain__arrow` — accent color (cyan)
+ - `.technique-chain__step` — inline display
+ - Remove or restyle `.technique-chain__steps` (the old `` style)
+
+6. Run `cd frontend && npm run build` to verify zero TypeScript errors.
+
+7. Deploy to ub01: push changes, SSH in, `docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096`.
+
+## Constraints
+- All CSS colors MUST use `var(--*)` tokens. The S02 dark theme established 84 semantic custom properties. Grep verification: `grep -P '#[0-9a-fA-F]{3,8}' frontend/src/App.css | grep -v ':root' | grep -v '^\/\*'` should return zero new hex colors.
+- Do NOT modify `:root` custom property definitions unless adding a genuinely new semantic token.
+
+## Must-Haves
+
+- [ ] TypeScript `KeyMomentSummary` includes `video_filename: string`
+- [ ] Meta stats line renders below technique title
+- [ ] Key moment rows show video filename
+- [ ] Signal chains render as monospace flow with arrow separators
+- [ ] All new CSS uses `var(--*)` tokens only
+- [ ] `npm run build` succeeds with zero errors
+
+## Inputs
+
+- `frontend/src/api/public-client.ts`
+- `frontend/src/pages/TechniquePage.tsx`
+- `frontend/src/App.css`
+- `backend/schemas.py`
+
+## Expected Output
+
+- `frontend/src/api/public-client.ts`
+- `frontend/src/pages/TechniquePage.tsx`
+- `frontend/src/App.css`
+
+## Verification
+
+cd frontend && npm run build 2>&1 | tail -5 && echo 'BUILD OK' && grep -cP '#[0-9a-fA-F]{3,8}' src/App.css | grep -v ':root' || echo 'No hardcoded colors outside :root'
diff --git a/backend/routers/techniques.py b/backend/routers/techniques.py
index 5ca68c6..7d2e676 100644
--- a/backend/routers/techniques.py
+++ b/backend/routers/techniques.py
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from database import get_session
-from models import Creator, KeyMoment, RelatedTechniqueLink, TechniquePage
+from models import Creator, KeyMoment, RelatedTechniqueLink, SourceVideo, TechniquePage
from schemas import (
CreatorInfo,
KeyMomentSummary,
@@ -75,7 +75,7 @@ async def get_technique(
select(TechniquePage)
.where(TechniquePage.slug == slug)
.options(
- selectinload(TechniquePage.key_moments),
+ selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video),
selectinload(TechniquePage.creator),
selectinload(TechniquePage.outgoing_links).selectinload(
RelatedTechniqueLink.target_page
@@ -93,7 +93,11 @@ async def get_technique(
# Build key moments (ordered by start_time)
key_moments = sorted(page.key_moments, key=lambda km: km.start_time)
- key_moment_items = [KeyMomentSummary.model_validate(km) for km in key_moments]
+ key_moment_items = []
+ for km in key_moments:
+ item = KeyMomentSummary.model_validate(km)
+ item.video_filename = km.source_video.filename if km.source_video else ""
+ key_moment_items.append(item)
# Build creator info
creator_info = None
diff --git a/backend/schemas.py b/backend/schemas.py
index d989758..7afe140 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -286,6 +286,7 @@ class KeyMomentSummary(BaseModel):
end_time: float
content_type: str
plugins: list[str] | None = None
+ video_filename: str = ""
class RelatedLinkItem(BaseModel):