feat: Replaced thin 3px line markers with 12px color-coded circle pins,…

- "frontend/src/components/ChapterMarkers.tsx"
- "frontend/src/components/PlayerControls.tsx"
- "frontend/src/App.css"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-04-04 10:44:45 +00:00
parent 160b1a8445
commit 3c99084eb2
12 changed files with 625 additions and 20 deletions

View file

@ -6,7 +6,7 @@ Shorts pipeline goes end-to-end with captioning and templates. Player gets key m
## Slice Overview
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | [A] Shorts Publishing Flow | medium | — | | Creator approves a short → it renders → gets a shareable URL and embed code |
| S01 | [A] Shorts Publishing Flow | medium | — | | Creator approves a short → it renders → gets a shareable URL and embed code |
| S02 | [A] Key Moment Pins on Player Timeline | low | — | ⬜ | Key technique moments appear as clickable pins on the player timeline |
| S03 | [A] Embed Support (iframe Snippet) | low | — | ⬜ | Creators can copy an iframe embed snippet to put the player on their own site |
| S04 | [B] Auto-Captioning + Template System | medium | — | ⬜ | Shorts have Whisper-generated animated subtitles and creator-configurable intro/outro cards |

View file

@ -0,0 +1,103 @@
---
id: S01
parent: M024
milestone: M024
provides:
- Public short URL pattern: /shorts/{token}
- share_token field on GeneratedShort model and API response
- Public shorts API endpoint: GET /api/v1/public/shorts/{share_token}
requires:
[]
affects:
- S03
- S06
key_files:
- backend/models.py
- alembic/versions/026_add_share_token.py
- backend/pipeline/stages.py
- backend/routers/shorts_public.py
- backend/routers/shorts.py
- backend/main.py
- frontend/src/pages/ShortPlayer.tsx
- frontend/src/pages/ShortPlayer.module.css
- frontend/src/api/shorts.ts
- frontend/src/App.tsx
- frontend/src/pages/HighlightQueue.tsx
key_decisions:
- Public endpoint returns 404 for both missing and non-complete shorts — avoids leaking short status
- fetchPublicShort uses raw fetch() to avoid injecting auth token on public endpoint
- Share/embed buttons only render when share_token is non-null — graceful for pre-migration shorts
patterns_established:
- Token-based public access pattern: model gets share_token column, pipeline generates on completion, public router resolves by token with no auth
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M024/slices/S01/tasks/T01-SUMMARY.md
- .gsd/milestones/M024/slices/S01/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-04T10:37:15.032Z
blocker_discovered: false
---
# S01: [A] Shorts Publishing Flow
**Shorts get shareable public URLs with token-based access, a standalone video player page, and copy-to-clipboard share/embed buttons on the admin queue.**
## What Happened
This slice adds the complete shorts publishing flow: from token generation at pipeline completion through to a public-facing player page.
**Backend (T01):** Added `share_token` column (String(16), nullable, unique-indexed) to the `GeneratedShort` model with Alembic migration 026 that also backfills existing complete shorts. Token generation (`secrets.token_urlsafe(8)`) is wired into `stage_generate_shorts` at the completion point. A new unauthenticated endpoint `GET /api/v1/public/shorts/{share_token}` resolves the token via selectinload joins through HighlightCandidate → KeyMoment → SourceVideo → Creator to return metadata (format, dimensions, duration, creator name, highlight title) and a fresh MinIO presigned download URL. Returns 404 for both missing and non-complete shorts to avoid leaking status info.
**Frontend (T02):** Created `ShortPlayer.tsx` — a public page at `/shorts/:token` that fetches metadata via unauthenticated API call, renders a `<video>` element with the presigned URL, displays creator/highlight metadata, and provides copy-to-clipboard for both the share URL and an iframe embed snippet. Route registered outside `<ProtectedRoute>` in App.tsx, lazy-loaded as a separate chunk. On the admin `HighlightQueue` page, completed shorts now show share link (🔗) and embed code (📋) copy buttons that only render when `share_token` is non-null. Clipboard uses `navigator.clipboard` with `document.execCommand` fallback.
## Verification
All slice-level checks passed:
1. `cd backend && python -c "from routers.shorts_public import router; print('import ok')"` — exit 0
2. `test -f alembic/versions/026_add_share_token.py` — exists
3. `cd frontend && npm run build` — zero TypeScript errors, built in 2.92s
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
T01: Used async endpoint with get_session dependency instead of sync — matches existing router patterns. T02: Used `download_url` field name to match actual backend response shape. Used emoji icons for share/embed buttons instead of text labels.
## Known Limitations
Presigned MinIO URLs expire (default TTL). If a user bookmarks the public player page and returns after expiry, the video won't load until they refresh (which fetches a new presigned URL). No offline/cached playback.
## Follow-ups
None.
## Files Created/Modified
- `backend/models.py` — Added share_token column to GeneratedShort
- `alembic/versions/026_add_share_token.py` — Migration: add share_token column, backfill existing complete shorts, create unique index
- `backend/pipeline/stages.py` — Generate share_token on short completion in stage_generate_shorts
- `backend/routers/shorts.py` — Added share_token to GeneratedShortResponse schema
- `backend/routers/shorts_public.py` — New public unauthenticated endpoint for resolving share tokens
- `backend/main.py` — Registered shorts_public router
- `frontend/src/pages/ShortPlayer.tsx` — New public video player page
- `frontend/src/pages/ShortPlayer.module.css` — Styles for ShortPlayer page
- `frontend/src/api/shorts.ts` — Added fetchPublicShort and PublicShortResponse type, share_token on GeneratedShort
- `frontend/src/App.tsx` — Registered /shorts/:token as public route
- `frontend/src/pages/HighlightQueue.tsx` — Added share link and embed code copy buttons for completed shorts
- `frontend/src/pages/HighlightQueue.module.css` — Styles for share/embed buttons

View file

@ -0,0 +1,55 @@
# S01: [A] Shorts Publishing Flow — UAT
**Milestone:** M024
**Written:** 2026-04-04T10:37:15.032Z
## UAT: Shorts Publishing Flow
### Preconditions
- Chrysopedia stack running on ub01 (`docker ps --filter name=chrysopedia` shows all services healthy)
- At least one short with `status=complete` exists in the database
- Migration 026 has been applied (`docker exec chrysopedia-api alembic upgrade head`)
### Test 1: Share token exists on completed shorts
1. Query the database for a completed short: `docker exec chrysopedia-db psql -U chrysopedia -d chrysopedia -c "SELECT id, status, share_token FROM generated_shorts WHERE status='complete' LIMIT 3;"`
2. **Expected:** Every `complete` row has a non-null `share_token` value (16-char alphanumeric string)
### Test 2: Public API endpoint resolves token
1. Pick a `share_token` value from Test 1
2. `curl -s http://ub01:8096/api/v1/public/shorts/{TOKEN} | python3 -m json.tool`
3. **Expected:** 200 response with fields: `download_url` (presigned MinIO URL), `format_preset`, `width`, `height`, `duration_secs`, `creator_name`, `highlight_title`, `share_token`
### Test 3: Public API rejects invalid token
1. `curl -s -o /dev/null -w "%{http_code}" http://ub01:8096/api/v1/public/shorts/nonexistent_token`
2. **Expected:** 404 status code
### Test 4: Public API requires no authentication
1. `curl -s -o /dev/null -w "%{http_code}" http://ub01:8096/api/v1/public/shorts/{TOKEN}` (no Authorization header)
2. **Expected:** 200 (not 401/403)
### Test 5: ShortPlayer page loads
1. Open `http://ub01:8096/shorts/{TOKEN}` in a browser (not logged in)
2. **Expected:** Page loads showing video player, creator name, highlight title, and "Copy Embed Code" button
3. Video element has a valid `src` attribute pointing to MinIO
### Test 6: ShortPlayer handles invalid token
1. Open `http://ub01:8096/shorts/bogus_token_123` in a browser
2. **Expected:** Page shows "Short not found" error message (not a crash or blank page)
### Test 7: Share/embed buttons on HighlightQueue
1. Log in as admin, navigate to the Highlight Queue page
2. Find a completed short in the list
3. **Expected:** Share (🔗) and embed (📋) buttons visible next to the download button
4. Click the share button
5. **Expected:** Clipboard contains a URL like `http://ub01:8096/shorts/{token}`. Brief "Copied!" feedback shown.
6. Click the embed button
7. **Expected:** Clipboard contains an iframe snippet: `<iframe src="http://ub01:8096/shorts/{token}" width="..." height="..." ...></iframe>`
### Test 8: Presigned URL in video actually plays
1. From Test 2 response, copy the `download_url` value
2. Open it directly in a browser
3. **Expected:** Video file downloads or plays (URL is a valid presigned MinIO link)
### Edge Cases
- **Short with no share_token (pre-migration, if backfill missed):** HighlightQueue should NOT show share/embed buttons for that short
- **Expired presigned URL:** Refreshing the ShortPlayer page should fetch a fresh presigned URL from the API

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M024/S01/T02",
"timestamp": 1775298956815,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
}
]
}

View file

@ -1,6 +1,86 @@
# S02: [A] Key Moment Pins on Player Timeline
**Goal:** Add clickable key moment markers to the media player timeline
**Goal:** Key technique moments appear as clickable, color-coded pins on the player timeline with active highlighting, and technique pages include an inline player with those pins.
**Demo:** After this: Key technique moments appear as clickable pins on the player timeline
## Tasks
- [x] **T01: Replaced thin 3px line markers with 12px color-coded circle pins, added active-state highlighting keyed to currentTime, and enriched tooltips with title + time range + content type** — Replace the thin 3px line markers in ChapterMarkers.tsx with visible circle-shaped pins, color-coded by content_type. Add currentTime prop to enable active-state highlighting when playback is within a marker's time range. Enrich tooltip to show title + formatted time range + content type label. Define CSS custom properties for the four content-type colors.
## Steps
1. Add four CSS custom properties in App.css `:root` for content-type pin colors: `--color-pin-technique` (cyan), `--color-pin-settings` (amber), `--color-pin-reasoning` (purple), `--color-pin-workflow` (green).
2. Modify `ChapterMarkers.tsx`:
- Add `currentTime?: number` to the props interface.
- Compute `isActive` per chapter: `ch.start_time <= currentTime && currentTime <= ch.end_time`.
- Map `content_type` to a CSS class: `chapter-marker__pin--technique`, etc.
- Replace the `<button>` rendering: instead of a 3px-wide tick, render a 10px circle pin with the content-type background color.
- Enrich the tooltip `<span>` to show: `{title} · {formatTime(start)}{formatTime(end)} · {content_type}`.
- Add `formatTime` helper (minutes:seconds) inside the file.
- Apply an `.chapter-marker__pin--active` class when `isActive` is true.
3. Modify `PlayerControls.tsx`:
- Pass `currentTime={currentTime}` to the `<ChapterMarkers>` component.
4. Update CSS in `App.css`:
- Replace `.chapter-marker__tick` styles with `.chapter-marker__pin` styles: 10px width, 10px height, border-radius 50%, centered vertically.
- Add `.chapter-marker__pin--technique`, `--settings`, `--reasoning`, `--workflow` with their respective `var(--color-pin-*)` backgrounds.
- Add `.chapter-marker__pin--active` with scale(1.3) transform and full opacity.
- Update `.chapter-marker__tooltip` width to accommodate longer content.
- Ensure pins are visible on mobile (min-size, touch-friendly hit area).
## Must-Haves
- [ ] Circle pins replace thin line markers
- [ ] Four content-type colors defined as CSS custom properties
- [ ] Tooltip shows title + time range + content type
- [ ] Active pin highlights when playback is within its time range
- [ ] `npm run build` passes with zero errors
- Estimate: 45m
- Files: frontend/src/components/ChapterMarkers.tsx, frontend/src/components/PlayerControls.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build 2>&1 | tail -5
- [ ] **T02: Add collapsible inline player with pins to TechniquePage** — Add a collapsible inline video player to TechniquePage that shows the source video with key moment pins on its timeline. Wire key moment bibliography clicks to seek the inline player instead of navigating to WatchPage.
## Steps
1. In `TechniquePage.tsx`, add imports for `useMediaSync`, `VideoPlayer`, `PlayerControls`, `fetchChapters`, and `useState`/`useCallback`.
2. Add state for the inline player:
- `playerOpen: boolean` (default false) — whether the player section is expanded
- `chapters: Chapter[]` — fetched from the chapters endpoint
- `activeVideoId: string | null` — which source video is loaded
- Derive the initial video ID from `technique.key_moments[0]?.source_video_id` or `technique.source_videos[0]?.id`.
3. Create the `useMediaSync()` hook instance inside TechniquePage.
4. Add a `useEffect` that fetches chapters when `activeVideoId` changes and the player is open.
5. Render the inline player section between the summary and body sections:
- A toggle button/header: "▶ Preview Key Moments" / "▼ Playing: {filename}" with expand/collapse.
- When expanded: `<VideoPlayer>` with the stream URL `${BASE}/videos/${activeVideoId}/stream`, wired to `mediaSync`.
- Below the video: `<PlayerControls>` with chapters and mediaSync.
- If multiple source videos, show a small selector dropdown above the player.
- Use CSS grid-template-rows 0fr/1fr animation for smooth expand/collapse (per KNOWLEDGE.md pattern).
6. Modify the key moments bibliography section:
- If the inline player is open AND the moment's `source_video_id` matches `activeVideoId`, clicking the time range link should call `mediaSync.seekTo(km.start_time)` and scroll the player into view, instead of navigating to `/watch/...`.
- If the player is closed or the moment is from a different video, keep the existing `<Link>` to WatchPage.
7. Add CSS in `App.css`:
- `.technique-player` container with collapse/expand grid animation.
- `.technique-player__toggle` button styling.
- `.technique-player__video` responsive sizing (max-height 400px, 100% width).
- Mobile: full-width, reduced max-height.
- If multiple source videos, `.technique-player__video-select` dropdown styling.
## Must-Haves
- [ ] Inline player renders on TechniquePage with key moment pins
- [ ] Player is collapsible (collapsed by default)
- [ ] Bibliography time links seek the inline player when it's active
- [ ] Multiple source videos handled with selector
- [ ] Mobile-responsive layout
- [ ] `npm run build` passes with zero errors
- Estimate: 1h15m
- Files: frontend/src/pages/TechniquePage.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build 2>&1 | tail -5

View file

@ -0,0 +1,80 @@
# S02 Research: Key Moment Pins on Player Timeline
## Summary
This is **light research** — the infrastructure already exists. ChapterMarkers.tsx renders clickable ticks on the seek bar, and WatchPage already fetches/displays them. The work is upgrading minimal line markers to proper "pins" with richer visuals and interaction, and potentially adding an inline mini-player on the technique page.
## Recommendation
Improve the existing ChapterMarkers component into proper pin markers with content-type color coding, richer tooltips (showing time range + content type), and a hover-preview panel. Add a compact inline player to TechniquePage so users can preview key moments without navigating to WatchPage.
## Implementation Landscape
### What Already Exists
**ChapterMarkers.tsx** — Absolutely-positioned overlay on the seek bar in PlayerControls. Renders one `<button>` per chapter at `(start_time / duration) * 100` percent left offset. Current style: 3px wide, 100% height colored line with hover tooltip showing title only. Located: `frontend/src/components/ChapterMarkers.tsx`.
**PlayerControls.tsx** — Accepts optional `chapters?: Chapter[]` prop and renders `<ChapterMarkers>` inside `.player-controls__seek-container`. The seek bar is an `<input type="range">`.
**WatchPage.tsx** — Fetches chapters via `fetchChapters(videoId)``GET /api/v1/videos/{videoId}/chapters`. Passes to PlayerControls. Full player with VideoPlayer + AudioWaveform + TranscriptSidebar.
**Backend chapters endpoint** — `GET /api/v1/videos/{video_id}/chapters` returns `ChaptersResponse` with `ChapterMarkerRead` items containing: `id`, `title`, `start_time`, `end_time`, `content_type`, `chapter_status`, `sort_order`. Prefers approved chapters; falls back to all.
**TechniquePage.tsx** — No player. Key moments rendered as bibliography-style `<ol>` with `[N]` citation indices. Each moment links to `/watch/{source_video_id}?t={start_time}`. The page receives `source_videos: SourceVideoSummary[]` and `key_moments: KeyMomentSummary[]` from the API.
**KeyMomentContentType enum** — Four values: `technique`, `settings`, `reasoning`, `workflow`. Available in `content_type` field on both `KeyMomentSummary` (frontend) and `ChapterMarkerRead` (backend).
**useMediaSync hook** — Shared playback state hook with `videoRef`, `currentTime`, `duration`, `seekTo`, etc. Already used by WatchPage.
**App.css chapter styles** (lines 6232-6280) — `.chapter-markers` (absolute overlay), `.chapter-marker__tick` (3px line, 60% opacity, pointer-events), `.chapter-marker__tooltip` (positioned above, fade on hover).
### What Needs Building
1. **Upgraded pin markers** — Replace thin 3px lines with visible pin shapes (circle or diamond) color-coded by `content_type`. Four colors for four types. Tooltip should show title + formatted time range + content type label.
2. **Active pin highlighting** — When playback enters a key moment's time range (`start_time ≤ currentTime ≤ end_time`), highlight the corresponding pin. This needs `currentTime` passed to ChapterMarkers — currently not passed (only `chapters`, `duration`, `onSeek`).
3. **Technique page inline player** — Compact player on TechniquePage that plays source video with key moment pins. Uses existing VideoPlayer + PlayerControls + useMediaSync. Source video ID from `technique.source_videos[0]` or from key moments' `source_video_id`. Stream URL: `${BASE}/videos/${videoId}/stream`. Chapters come from the existing `/chapters` endpoint.
### Natural Seams
**Task 1 — Upgrade ChapterMarkers styling + add currentTime prop** (frontend only)
- Modify `ChapterMarkers.tsx`: add `currentTime` prop, render circle/diamond pins instead of lines, color by `content_type`, show richer tooltip
- Update `PlayerControls.tsx`: pass `currentTime` to ChapterMarkers
- Update `App.css`: new pin styles, content-type color classes, active state
- Files: `ChapterMarkers.tsx`, `PlayerControls.tsx`, `App.css`
**Task 2 — Add inline player to TechniquePage** (frontend only)
- Add a collapsible player section to TechniquePage between the summary and body sections
- Use existing `useMediaSync`, `VideoPlayer`, `PlayerControls` components
- Fetch chapters for the source video
- Wire key moment bibliography clicks to seek the inline player instead of navigating to WatchPage
- Files: `TechniquePage.tsx`, `App.css`
- This is the riskier task — technique pages may have multiple source videos. Simplest approach: show player for the first source video, or let user pick from a dropdown if multiple.
### Key Constraints
- **Multiple source videos per technique**: Some technique pages compile from multiple videos. The inline player can only show one at a time. Need a video selector or default to the first.
- **Audio-only content**: Some videos are audio-only (AudioWaveform used in WatchPage). The inline player should handle both cases using the same `video_url` vs stream URL logic as WatchPage.
- **Chapter data is per-video**: The chapters endpoint returns moments for a single video. If a technique page has moments from multiple videos, the pin markers only show moments from the currently-playing video.
- **No new backend endpoints needed**: All data is already available — `source_videos` on the technique detail, `/chapters` endpoint exists, `/stream` endpoint exists.
- **CSS custom properties**: All colors should use `var(--*)` pattern per D017. Content-type pin colors should be defined as new custom properties.
### Content-Type → Color Mapping
| Content Type | Suggested Color | Semantic |
|---|---|---|
| technique | cyan (accent) | Core technique demonstration |
| settings | amber/yellow | Configuration/parameter discussion |
| reasoning | purple | Why/theory explanation |
| workflow | green | Process/workflow walkthrough |
### Verification Strategy
- Build passes with zero TypeScript errors
- WatchPage player shows circle/diamond pins (not thin lines) color-coded by content type
- Hovering a pin shows title + time range + content type
- Pin highlights when playback is within its time range
- TechniquePage shows inline player with pins for the source video's key moments
- Clicking a key moment citation in the bibliography seeks the inline player
- Works at mobile (375px) and desktop (1280px) widths

View file

@ -0,0 +1,57 @@
---
estimated_steps: 25
estimated_files: 3
skills_used: []
---
# T01: Upgrade ChapterMarkers to color-coded pins with active highlighting
Replace the thin 3px line markers in ChapterMarkers.tsx with visible circle-shaped pins, color-coded by content_type. Add currentTime prop to enable active-state highlighting when playback is within a marker's time range. Enrich tooltip to show title + formatted time range + content type label. Define CSS custom properties for the four content-type colors.
## Steps
1. Add four CSS custom properties in App.css `:root` for content-type pin colors: `--color-pin-technique` (cyan), `--color-pin-settings` (amber), `--color-pin-reasoning` (purple), `--color-pin-workflow` (green).
2. Modify `ChapterMarkers.tsx`:
- Add `currentTime?: number` to the props interface.
- Compute `isActive` per chapter: `ch.start_time <= currentTime && currentTime <= ch.end_time`.
- Map `content_type` to a CSS class: `chapter-marker__pin--technique`, etc.
- Replace the `<button>` rendering: instead of a 3px-wide tick, render a 10px circle pin with the content-type background color.
- Enrich the tooltip `<span>` to show: `{title} · {formatTime(start)}{formatTime(end)} · {content_type}`.
- Add `formatTime` helper (minutes:seconds) inside the file.
- Apply an `.chapter-marker__pin--active` class when `isActive` is true.
3. Modify `PlayerControls.tsx`:
- Pass `currentTime={currentTime}` to the `<ChapterMarkers>` component.
4. Update CSS in `App.css`:
- Replace `.chapter-marker__tick` styles with `.chapter-marker__pin` styles: 10px width, 10px height, border-radius 50%, centered vertically.
- Add `.chapter-marker__pin--technique`, `--settings`, `--reasoning`, `--workflow` with their respective `var(--color-pin-*)` backgrounds.
- Add `.chapter-marker__pin--active` with scale(1.3) transform and full opacity.
- Update `.chapter-marker__tooltip` width to accommodate longer content.
- Ensure pins are visible on mobile (min-size, touch-friendly hit area).
## Must-Haves
- [ ] Circle pins replace thin line markers
- [ ] Four content-type colors defined as CSS custom properties
- [ ] Tooltip shows title + time range + content type
- [ ] Active pin highlights when playback is within its time range
- [ ] `npm run build` passes with zero errors
## Inputs
- ``frontend/src/components/ChapterMarkers.tsx` — existing thin-line chapter markers`
- ``frontend/src/components/PlayerControls.tsx` — passes chapters to ChapterMarkers, has currentTime`
- ``frontend/src/App.css` — existing .chapter-marker__tick and .chapter-marker__tooltip styles`
- ``frontend/src/api/videos.ts` — Chapter type with content_type field`
## Expected Output
- ``frontend/src/components/ChapterMarkers.tsx` — upgraded to circle pins with currentTime, content_type color, active state, rich tooltip`
- ``frontend/src/components/PlayerControls.tsx` — passes currentTime to ChapterMarkers`
- ``frontend/src/App.css` — new pin CSS custom properties and .chapter-marker__pin styles replacing .chapter-marker__tick`
## Verification
cd frontend && npm run build 2>&1 | tail -5

View file

@ -0,0 +1,79 @@
---
id: T01
parent: S02
milestone: M024
provides: []
requires: []
affects: []
key_files: ["frontend/src/components/ChapterMarkers.tsx", "frontend/src/components/PlayerControls.tsx", "frontend/src/App.css"]
key_decisions: ["Used ::before pseudo-element with inset:-6px for touch-friendly hit area without enlarging visual pin"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "npm run build passes with zero errors. grep confirms no stale chapter-marker__tick references remain."
completed_at: 2026-04-04T10:44:42.034Z
blocker_discovered: false
---
# T01: Replaced thin 3px line markers with 12px color-coded circle pins, added active-state highlighting keyed to currentTime, and enriched tooltips with title + time range + content type
> Replaced thin 3px line markers with 12px color-coded circle pins, added active-state highlighting keyed to currentTime, and enriched tooltips with title + time range + content type
## What Happened
---
id: T01
parent: S02
milestone: M024
key_files:
- frontend/src/components/ChapterMarkers.tsx
- frontend/src/components/PlayerControls.tsx
- frontend/src/App.css
key_decisions:
- Used ::before pseudo-element with inset:-6px for touch-friendly hit area without enlarging visual pin
duration: ""
verification_result: passed
completed_at: 2026-04-04T10:44:42.035Z
blocker_discovered: false
---
# T01: Replaced thin 3px line markers with 12px color-coded circle pins, added active-state highlighting keyed to currentTime, and enriched tooltips with title + time range + content type
**Replaced thin 3px line markers with 12px color-coded circle pins, added active-state highlighting keyed to currentTime, and enriched tooltips with title + time range + content type**
## What Happened
Defined four CSS custom properties for content-type pin colors in :root. Replaced all .chapter-marker__tick styles with .chapter-marker__pin circle styles (12px, border-radius 50%, centered vertically). Each content type maps to a dedicated background color class. Active pins scale to 1.3x with full opacity. Rewrote ChapterMarkers.tsx to accept currentTime, compute isActive per chapter, and render rich tooltips. Updated PlayerControls.tsx to pass currentTime through.
## Verification
npm run build passes with zero errors. grep confirms no stale chapter-marker__tick references remain.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build 2>&1 | tail -5` | 0 | ✅ pass | 10400ms |
| 2 | `grep -rn 'chapter-marker__tick' frontend/src/` | 1 | ✅ pass | 100ms |
## Deviations
Used 12px pins instead of 10px for better touch targeting. Added ::before pseudo-element for expanded hit area.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/components/ChapterMarkers.tsx`
- `frontend/src/components/PlayerControls.tsx`
- `frontend/src/App.css`
## Deviations
Used 12px pins instead of 10px for better touch targeting. Added ::before pseudo-element for expanded hit area.
## Known Issues
None.

View file

@ -0,0 +1,70 @@
---
estimated_steps: 32
estimated_files: 2
skills_used: []
---
# T02: Add collapsible inline player with pins to TechniquePage
Add a collapsible inline video player to TechniquePage that shows the source video with key moment pins on its timeline. Wire key moment bibliography clicks to seek the inline player instead of navigating to WatchPage.
## Steps
1. In `TechniquePage.tsx`, add imports for `useMediaSync`, `VideoPlayer`, `PlayerControls`, `fetchChapters`, and `useState`/`useCallback`.
2. Add state for the inline player:
- `playerOpen: boolean` (default false) — whether the player section is expanded
- `chapters: Chapter[]` — fetched from the chapters endpoint
- `activeVideoId: string | null` — which source video is loaded
- Derive the initial video ID from `technique.key_moments[0]?.source_video_id` or `technique.source_videos[0]?.id`.
3. Create the `useMediaSync()` hook instance inside TechniquePage.
4. Add a `useEffect` that fetches chapters when `activeVideoId` changes and the player is open.
5. Render the inline player section between the summary and body sections:
- A toggle button/header: "▶ Preview Key Moments" / "▼ Playing: {filename}" with expand/collapse.
- When expanded: `<VideoPlayer>` with the stream URL `${BASE}/videos/${activeVideoId}/stream`, wired to `mediaSync`.
- Below the video: `<PlayerControls>` with chapters and mediaSync.
- If multiple source videos, show a small selector dropdown above the player.
- Use CSS grid-template-rows 0fr/1fr animation for smooth expand/collapse (per KNOWLEDGE.md pattern).
6. Modify the key moments bibliography section:
- If the inline player is open AND the moment's `source_video_id` matches `activeVideoId`, clicking the time range link should call `mediaSync.seekTo(km.start_time)` and scroll the player into view, instead of navigating to `/watch/...`.
- If the player is closed or the moment is from a different video, keep the existing `<Link>` to WatchPage.
7. Add CSS in `App.css`:
- `.technique-player` container with collapse/expand grid animation.
- `.technique-player__toggle` button styling.
- `.technique-player__video` responsive sizing (max-height 400px, 100% width).
- Mobile: full-width, reduced max-height.
- If multiple source videos, `.technique-player__video-select` dropdown styling.
## Must-Haves
- [ ] Inline player renders on TechniquePage with key moment pins
- [ ] Player is collapsible (collapsed by default)
- [ ] Bibliography time links seek the inline player when it's active
- [ ] Multiple source videos handled with selector
- [ ] Mobile-responsive layout
- [ ] `npm run build` passes with zero errors
## Inputs
- ``frontend/src/pages/TechniquePage.tsx` — existing technique page with key moments bibliography`
- ``frontend/src/App.css` — existing technique page styles`
- ``frontend/src/components/ChapterMarkers.tsx` — upgraded pin markers from T01`
- ``frontend/src/components/PlayerControls.tsx` — updated with currentTime passthrough from T01`
- ``frontend/src/hooks/useMediaSync.ts` — shared playback state hook`
- ``frontend/src/components/VideoPlayer.tsx` — HLS video player component`
- ``frontend/src/api/videos.ts` — fetchChapters, Chapter type`
- ``frontend/src/api/techniques.ts` — KeyMomentSummary with source_video_id`
## Expected Output
- ``frontend/src/pages/TechniquePage.tsx` — inline collapsible player with pins, bibliography seek wiring, multi-video selector`
- ``frontend/src/App.css` — .technique-player styles for collapse animation, video sizing, mobile responsive`
## Verification
cd frontend && npm run build 2>&1 | tail -5

View file

@ -29,6 +29,12 @@
--color-accent-subtle: rgba(34, 211, 238, 0.1);
--color-accent-focus: rgba(34, 211, 238, 0.15);
/* Content-type pin colors */
--color-pin-technique: #22d3ee; /* cyan */
--color-pin-settings: #f59e0b; /* amber */
--color-pin-reasoning: #a855f7; /* purple */
--color-pin-workflow: #22c55e; /* green */
/* Shadows / overlays */
--color-shadow: rgba(0, 0, 0, 0.2);
--color-shadow-heavy: rgba(0, 0, 0, 0.4);
@ -6238,42 +6244,69 @@ a.app-footer__about:hover,
pointer-events: none;
}
.chapter-marker__tick {
.chapter-marker__pin {
position: absolute;
width: 3px;
height: 100%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-accent, #22d3ee);
opacity: 0.6;
opacity: 0.7;
pointer-events: all;
cursor: pointer;
transform: translateX(-50%);
border: none;
transform: translate(-50%, -50%);
top: 50%;
border: 2px solid rgba(0, 0, 0, 0.4);
padding: 0;
font: inherit;
transition: transform 150ms ease, opacity 150ms ease;
/* Ensure touch-friendly hit area */
min-width: 12px;
min-height: 12px;
}
.chapter-marker__tick:hover {
.chapter-marker__pin::before {
content: "";
position: absolute;
inset: -6px;
}
.chapter-marker__pin--technique { background: var(--color-pin-technique); }
.chapter-marker__pin--settings { background: var(--color-pin-settings); }
.chapter-marker__pin--reasoning { background: var(--color-pin-reasoning); }
.chapter-marker__pin--workflow { background: var(--color-pin-workflow); }
.chapter-marker__pin--active {
opacity: 1;
transform: translate(-50%, -50%) scale(1.3);
z-index: 2;
}
.chapter-marker__pin:hover {
opacity: 1;
transform: translate(-50%, -50%) scale(1.2);
}
.chapter-marker__pin--active:hover {
transform: translate(-50%, -50%) scale(1.3);
}
.chapter-marker__tooltip {
position: absolute;
bottom: 100%;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: var(--color-bg-surface, #1e293b);
color: var(--text-primary, #e2e8f0);
padding: 4px 8px;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 150ms;
margin-bottom: 4px;
}
.chapter-marker__tick:hover .chapter-marker__tooltip {
.chapter-marker__pin:hover .chapter-marker__tooltip {
opacity: 1;
}

View file

@ -4,33 +4,65 @@ interface ChapterMarkersProps {
chapters: Chapter[];
duration: number;
onSeek: (time: number) => void;
currentTime?: number;
}
/** Format seconds as m:ss */
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
const PIN_COLOR_CLASS: Record<string, string> = {
technique: "chapter-marker__pin--technique",
settings: "chapter-marker__pin--settings",
reasoning: "chapter-marker__pin--reasoning",
workflow: "chapter-marker__pin--workflow",
};
/**
* Absolutely-positioned overlay that renders tick marks on the seek bar
* for each chapter. Ticks are clickable and show a tooltip on hover.
* Absolutely-positioned overlay that renders color-coded circle pins
* on the seek bar for each chapter. Pins are clickable and show a
* rich tooltip on hover. The active pin (matching current playback
* position) is enlarged.
*/
export default function ChapterMarkers({ chapters, duration, onSeek }: ChapterMarkersProps) {
export default function ChapterMarkers({
chapters,
duration,
onSeek,
currentTime,
}: ChapterMarkersProps) {
if (!duration || chapters.length === 0) return null;
return (
<div className="chapter-markers">
{chapters.map((ch) => {
const leftPct = (ch.start_time / duration) * 100;
const isActive =
currentTime != null &&
ch.start_time <= currentTime &&
currentTime <= ch.end_time;
const colorClass = PIN_COLOR_CLASS[ch.content_type] ?? "";
const activeClass = isActive ? " chapter-marker__pin--active" : "";
return (
<button
key={ch.id}
className="chapter-marker__tick"
className={`chapter-marker__pin${colorClass ? ` ${colorClass}` : ""}${activeClass}`}
style={{ left: `${leftPct}%` }}
onClick={(e) => {
e.stopPropagation();
onSeek(ch.start_time);
}}
aria-label={`Jump to chapter: ${ch.title}`}
title={ch.title}
type="button"
>
<span className="chapter-marker__tooltip">{ch.title}</span>
<span className="chapter-marker__tooltip">
{ch.title} · {formatTime(ch.start_time)}{formatTime(ch.end_time)} · {ch.content_type}
</span>
</button>
);
})}

View file

@ -140,7 +140,7 @@ export default function PlayerControls({ mediaSync, containerRef, chapters }: Pl
style={{ "--progress": `${seekProgress}%` } as React.CSSProperties}
/>
{chapters && chapters.length > 0 && (
<ChapterMarkers chapters={chapters} duration={duration} onSeek={seekTo} />
<ChapterMarkers chapters={chapters} duration={duration} onSeek={seekTo} currentTime={currentTime} />
)}
</div>