RFC-099: Learning Platform Consumer Client¶
- Status: In Progress
- Authors: Marko
- Stakeholders: Frontend, Server API, Design/UX, Accessibility
- Related PRDs:
docs/prd/PRD-035-learning-platform.md(parent)docs/prd/PRD-037-discovery.md,docs/prd/PRD-038-catalog.md,docs/prd/PRD-039-player.md,docs/prd/PRD-040-capture.md- Related RFCs:
docs/rfc/RFC-098-learning-platform-foundation.md(the API this client consumes)docs/rfc/RFC-100-audio-bridge-subsystem.md(audio source)docs/rfc/RFC-062-gi-kg-viewer.md(operator viewer — primitives reused, kept separate)- Related UX spec:
docs/uxs/UXS-011-consumer-learning-app.md— the Editorial Bold design system + Player visual contract this client implements (a separate design system from the operator viewer's UXS-001)
Abstract¶
This RFC defines the new top-level consumer application (PRD-035 D1/D3): a mobile-first, installable
PWA that delivers the Discovery → Catalog → Player → Capture experience over the /api/app/*
foundation (RFC-098). It specifies the transcript-sync engine, the queue, the capture UX, and the
accessibility + internationalisation foundations that are non-negotiable from the first commit.
Architecture Alignment: A separate top-level app (web/learning-player/), distinct from web/gi-kg-viewer (operator
only). It reuses extracted UI primitives where sensible but ships its own shell, routing, and state.
Problem Statement¶
The intelligence layer and (after RFC-098) the consumer API exist, but there is no end-user client. The operator viewer is the wrong surface — different audience, no playback, no auth, no capture. We need a polished, Spotify-grade player whose differentiator is transcript-synced playback + inline grounded intelligence + frictionless capture, accessible and localisable from day one.
Use Cases:
- Listen: queue an episode, play it bridged from the origin host, follow the synced transcript, tap a line to seek.
- Understand: open the Knowledge Panel for grounded insights/entities and episode-scoped grounded search — no leaving the player.
- Capture: highlight the current moment or a transcript span in one interaction; add a note.
Goals¶
- New top-level PWA (
web/learning-player/): Vue 3 + TypeScript + Vite, installable, mobile-first, offline app-shell. - Transcript-sync engine: highlight + autoscroll the active segment; tap-to-seek; resume.
- Queue: add/reorder/auto-advance over local-ready episodes (scrape-on-demand on enqueue is post-#1069 — see §4).
- Knowledge Panel + in-episode grounded search (no request-time LLM, D6).
- Capture UX: highlights + notes (PRD-040) with one-interaction capture.
- a11y (WCAG 2.1 AA) + i18n built in from the first commit; voice control as a later north-star.
Constraints & Assumptions¶
Constraints:
- Audio is played from the origin URL provided by RFC-100; the client never fetches our stored media bytes.
- No request-time LLM; "ask" is
GET /api/app/episodes/{slug}/search(extractive). - a11y and i18n are acceptance criteria, not follow-ups.
Assumptions:
/api/app/*(RFC-098) is available and session-authenticated.- Cross-origin
<audio>playback withtimeupdateworks for transcript sync (standard browser behaviour).
Design & Implementation¶
1. App shell & stack¶
web/learning-player/ # new top-level project (sibling of web/)
index.html
src/
main.ts
router/ # Discovery, Catalog, Player, Library, Capture, Corpus(RFC-101)
stores/ # Pinia: auth, player, queue, capture
services/api.ts # typed client for /api/app/*
features/{discovery,catalog,player,capture}/
i18n/ # vue-i18n catalogs (en first; RTL-ready)
a11y/ # focus mgmt, live regions, skip links
primitives/ # shared UI (some extracted from gi-kg-viewer)
- PWA: service worker caches the app shell + GET API responses (stale-while-revalidate); audio is never cached/proxied by the SW. Web App Manifest for install.
- Auth: unauthenticated → redirect to
/api/app/auth/login; session cookie carries the rest. Shipped (#1091):LoginViewhas two framings — sign-in vs sign-up (?mode=signup) — both driving the same OAuth flow (open-signup get-or-creates the account); the header shows Sign in + Sign up when out. In the local dev preview, the vite proxy useschangeOrigin:falseso the API builds same-origin OAuth callbacks (else the session cookie lands on the API origin). - Visual design: implements UXS-011 (Editorial Bold, dark-primary). The single token layer is
web/learning-player/src/styles/tokens.css; the Player's now-playing artwork zone applies the per-show adaptive accent (contrast-clamped per UXS-011 Accessibility). This is a separate design system fromgi-kg-viewer.
Local mocked OAuth provider (dev / e2e)¶
Production requires Google OAuth (RFC-098 GoogleProvider). For local development and e2e tests
we add a MockOAuthProvider alongside it (same OAuthProvider protocol, RFC-098 §2) that completes
the code flow without any network call, minting a fixed dev identity (e.g. dev@localhost). It is
selected only when explicitly enabled (e.g. APP_OAUTH_PROVIDER=mock, never in prod config) so a
developer can sign in with a fake account and Playwright e2e can drive the full authed flow
deterministically — the same path CI already uses for the stub in unit/integration tests (no real LLM,
no real OAuth in CI, per project rule). The provider boundary already exists in RFC-098; this is the
required second implementation, not a new abstraction.
2. Transcript-sync engine¶
- Load
segments.jsononce; maintain an index sorted bystart. Onaudio.timeupdate, binary-search the active segment, apply the highlight class, and autoscroll into view (disabled on manual scroll, re-enabled after ~5s idle). Tap a segment →audio.currentTime = segment.start. - Speaker labels from
segment.speaker(canonicalperson:{slug}when present). - Shipped (#1091) — transcript ↔ insight bridge:
groundedSpansBySegment(segments, insights)maps each segment index to the grounded insight whose supporting quote overlaps it by timeline (robust to transcript-version char-offset drift). Those segments get a●marker + underline; tapping one emitsinsight→ the player opens the Insights panel and centre-scrolls to that claim. (Char-level highlighting of the exact quoted substring is Epic 3.6 / PRD-043 FR5.) - Shipped (#1091) — manual sync nudge: the bridged origin stream (e.g. acast) does dynamic ad
insertion, so its timeline can lead our transcribed copy. A persisted-per-episode
syncOffset(localStorage) maps audio-time ↔ content-time:activeSegmentIndex(segments, currentTime − offset)for the highlight,seek(contentSeconds + offset)for taps/?t=. ASync −/+control lets the listener align it. (This is the accepted cost of bridge-never-rehost; see the transcript/audio hosting decision — perfect sync would need rehosting the transcribed audio.)
3. Catalog & the pluggable ContentSource¶
- The Catalog (PRD-038) lists episodes via net-new read endpoints
GET /api/app/episodesandGET /api/app/podcasts/{id}/episodes(Epic-1 shipped only episode detail by slug — these lists are the central net-new server work for Epic 2). - These endpoints read through a pluggable
ContentSource. The MVP backend is aLocalCorpusSourceenumerating the already-processed local corpus — no scrape, no discovery; every catalogued episode is effectively Ready. When #1069/#1070 land, aDiscoverySourceextends the same contract to surface not-yet-processed content and provide the "add content" entry point. The client and the/api/app/episodes*response shape are unchanged across that swap.
Home (Learning Hub) & corpus-wide search — PRD-042 / UXS-012 (#1090)¶
Home (/) is the launch surface; the full catalog moves to /catalog. Routes: / Home,
/catalog, /search?q= (corpus search), /podcast/:feedId, /episode/:slug, /queue.
- Adaptive hero (behaviour for UXS-012's two states): on load, resolve auth + in-progress
history. Resume state when signed-in and
GET /api/app/playbackreturns ≥1 in-progress position (0 < position < duration, newest first) → Continue hero. Discover state otherwise → "Ask your library" + Featured. The hero never renders an empty Continue card; the search entry is mounted in both states. - Corpus-wide search: the Home search field navigates to
/search?q=; the results view callsGET /api/app/search(RFC-090 hybrid; extractive, no request-time LLM). Shipped (#1091): results are grouped by source episode (ranked by best hit), each episode header carrying an artwork thumbnail (metadata.episode_artwork) + title + show; each passage is labelled by kind (Insight / Transcript / Topic) and shows a ▶ "Play from m:ss" jump only when it carries a real timestamp (routes to/episode/:slug?t=), else the header opens the episode — no fabricated 0:00. Barekg_topicterm-matches are de-emphasised. Submit navigates; empty/no-index → graceful message. - Sections: What's new (
GET /api/app/episodes, newest) — shipped as an editorial ranked layout (featured #01 hero + numbered rows, no horizontal scroll), with the Featured spotlight folded into the #01 hero rather than a separate block; Recommended (v1 heuristic:GET /api/app/episodes/{slug}/relatedseeded by the most-recent play; hidden when no signal); Your shows (GET /api/app/podcasts). Each hides when empty/signed-out. - Net-new endpoints:
GET /api/app/podcasts(shows list — reuseaggregate_feeds) andGET /api/app/playback(list saved positions, auth) for Continue. Search + related + episodes already exist. - Phasing: Home shell + What's-new + Shows + Featured + the
/searchsurface ship first (no new auth dependency beyond/podcasts); Continue + Recommended follow with the/api/app/playbacklist endpoint. Full personalisation is PRD-041.
4. Queue¶
- Pinia
queuestore mirrored toGET/PUT /api/app/queue. Auto-advance onended. "Play next" / "add to queue" from Catalog cards. - MVP: the queue holds local-ready episodes only (no scrape-on-demand). Enqueuing an
unprocessed episode (calling
POST /api/app/scrape, showing inline progress, flipping to playable when Ready) is post-#1069 — built when scrape-on-demand and theDiscoverySourcearrive.
5. Insights panel & in-episode search¶
Shipped (#1091): the panel is titled "Insights" (matching the dock button + cards). Topics and People are merged into one compact, expandable "Topics & People" row; tapping a chip navigates to corpus search for that term — the originally-specified person→insight filter was dropped. A
●grounded marker distinguishes insights with a timestamped quote. Epic 3 (PRD-043) layers cluster-first topic ordering + person/topic entity cards on top.
- Collapsible panel: Summary, Topics & People (merged, chips → corpus search), Insights (grounded
cards with timestamp jump +
●grounded marker), "More like this" peers. - "Ask / find in this episode" →
GET /api/app/episodes/{slug}/search→ ranked grounded passages with jump-to-moment. No generation, no disclaimer (results are verbatim). - Relational context uses the RFC-094 queries by name: Insights via
who_said+cross_show_synthesis, Persons viapositions_of— scoped to this episode. - Enrichment signals (consumes RFC-088, built in parallel — stay in sync): related-topic chips from
topic_cooccurrenceand a credibility badge fromgrounding_rate("N% grounded"), shown when present.
6. Capture¶
- One-tap "highlight current moment" (anchors to active segment); transcript span selection; "save insight".
- Notes attach to highlight/insight/episode. Persisted via PRD-040 routes on
/api/app/*. - Shipped (v2.7 / Epic P2, #1112). A
capturePinia store (stores/capture.ts) mirrorsGET/POST/PATCH/DELETE /api/app/highlights+…/notes(server-authoritative, likefavorites; no-ops + empty signed-out). Three capture surfaces, all auth-gated: a mark-moment control in the player hero (PlayerView), a per-line save in the transcript (TranscriptListbehind acanCaptureprop so the signed-out transcript is unchanged), and save-to-highlights on each insight card (KnowledgePanel, distinct from the favorites heart — highlights feed the P3 corpus). Review lives in a Library Highlights tab (HighlightsView): grouped by episode, jump-to-moment, inline notes, a drift badge (timestamp re-anchor, RFC-098 §7), and a Markdown export link (GET /api/app/highlights/export.md). Transcript capture supports whole-line or a selected phrase (sub-segment char offsets viaselectionSubRange), and highlights carry a fixed-palette colour (picker + colour filter). The one tracked gap is a topic filter on the global view (PRD-040 §"As shipped").
7. Accessibility & i18n¶
- a11y: full keyboard operability of the listen→capture flow; ARIA roles; live region for "now playing" segment; visible focus; reduced-motion respect for autoscroll; target WCAG 2.1 AA.
- i18n: all copy via
vue-i18n; no hard-coded strings; locale-aware dates/numbers; layout RTL-ready. (Content/transcript translation is out — a future pipeline feature.)
8. Observability & analytics (consumer)¶
- Errors/crashes → Sentry (client). Web-vitals (LCP/INP/TTI, main-thread block measured on the worst common device — retina/DPR-2, throttled) → a metrics endpoint.
- Event taxonomy (privacy-light: no PII, no transcript text — in the spirit of the existing
query_log): play, pause, seek, segment-jump, search, recall, capture-highlight, add-note, queue-add, resurface-shown/acted. - Backend
/api/app/*latency/errors → Prometheus (reuse the existingPODCAST_METRICS_ENABLEDhook) → Grafana, aligning the consumer layer with the operator's existing observability. - GDPR-light: per-user analytics are deletable with the account (RFC-098).
- Listening analytics (shipped, UXS-014): an append-only per-user listen log
(
<data_dir>/users/<id>/listen_events.jsonl) backsPOST /api/app/listen/{slug}(record an open),GET /api/app/me/stats(the user's own streak/episodes/shows/hours + sparkline) and the public, anonymousGET /api/app/episodes/{slug}/stats(cross-user reach). Interest follows from entity cards usePOST/DELETE /api/app/interests/{token}(cluster/topic:/person:) and feed personalized discovery. Full endpoint contracts live indocs/api/PLATFORM_API.md; the interest-token model in RFC-102.
9. Optional consumer knowledge-graph browser (P2+)¶
- A read-only visual explorer reusing the RFC-069 graph toolkit (zoom/minimap/filters) + RFC-094 queries, scoped to the user's episode set. Distinct from the operator viewer; off the critical path — ships only after the core listen→capture flow.
10. Deployment & API boundary (mobile-future)¶
- Separate Docker container.
web/learning-player/builds to a static bundle served by its own lightweight image (the PWA shell + assets), independent of the API image. It talks to the backend purely over/api/app/*— no shared process, no server-rendered coupling. This keeps the consumer surface independently deployable/scalable and lets the operator API and pipeline images stay untouched. Concretely (#1086):web/learning-player/Dockerfile(node build → nginx;web/learning-player/nginx.conf= SPA fallback +/apiproxy, no audio) + thecompose/docker-compose.app.ymloverlay (addslearning-appto the stack network) +make app-docker-build/app-stack-up. - API is the only contract. Because the client consumes
/api/app/*exclusively (session cookie auth, JSON), the same API supports a future native mobile app with no server changes — the mobile client would swap the cookie session for a token grant against the same OAuth boundary (RFC-098) and reuse every read/write route. The web PWA and a future mobile app are two clients of one API. - No request-time LLM, bridge-never-rehost hold at the deployment boundary too: the container serves UI + proxies nothing audio; audio plays from the origin host (RFC-100).
Key Decisions¶
- New top-level
web/learning-player/, not an extension ofgi-kg-viewer - Decision: separate consumer app.
- Rationale: PRD-035 D3 — operator vs consumer concerns stay separate; viewer untouched.
- PWA, not native
- Decision: installable responsive PWA in v2.7.
- Rationale: PRD-035 D1 — mobile + a11y + i18n with no app-store friction; native is north-star.
- Client-side transcript sync, audio direct from origin
- Decision: no server in the sync hot path; bridge URL plays directly.
- Rationale: lowest latency; aligns with bridge-never-rehost.
Alternatives Considered¶
- Extend the operator viewer — Rejected (D3): conflates audiences, bloats the operator tool.
- Native app first — Rejected for v2.7: app-store friction, slower a11y/i18n iteration; revisit for background audio (north-star).
- Server-driven transcript sync (push current segment) — Rejected: needless latency/complexity; the client already has segments + audio time.
Testing Strategy¶
Test Coverage:
- Unit (vitest): sync index/active-segment math; queue auto-advance; capture anchoring.
- Component: Knowledge Panel degradation (missing artifacts), search results rendering.
- E2E (Playwright): sign-in (stub) → play → segment highlight + tap-seek → highlight capture; a11y checks (axe) in the listen→capture path.
Test Organization: in web/learning-player/ (cd into it before vitest/playwright); mocked /api/app/*; no real audio
fetched in CI (stub source / silent clip).
Rollout & Monitoring¶
Epic 2 = a player-first vertical slice over local content, shipped quickly as the MVP. Scope is Catalog (local corpus) + Player + Knowledge Panel + queue, with Google OAuth (+ the local mocked provider for dev/e2e). Discovery/scrape-on-demand (#1069), the audio proxy (#1070), and Capture (PRD-040) are explicitly after the app ships.
- P0: thin reference mode (auth → segments → play) to validate the RFC-098 contract — already
proven by the Epic-1 reference client (
app_reference_client.py). - P1 (Epic 2 MVP): net-new catalog list endpoints (
LocalCorpusSource) → Catalog → Player (transcript-sync, Knowledge Panel, in-episode search) → queue → auth (Google + mock). Full vertical slice, top-to-bottom, MVP-fast. - P2: Capture (PRD-040). P3: Discovery/scrape-on-demand (#1069,
DiscoverySource) + Corpus surface (RFC-101). - Monitoring: client perf (TTI, main-thread block on worst-case retina/throttled per project pref), Sentry, basic UX analytics.
- Success: listen→capture flow fully keyboard/screen-reader operable; sync has no perceptible lag.
Open Questions¶
- Resolved: top-level dir is
web/learning-player/, its own Vue 3 + Vite + Pinia build and own Docker image (§10) — not sharing a workspace/build withweb/(operator viewer stays separate, D3). - Resolved (direction): aesthetic is Editorial Bold (UXS-011); given the distinct design system,
default to reimplementing consumer components against UXS-011 tokens and only extract a
gi-kg-viewerprimitive when it is genuinely design-neutral (e.g. a focus-trap util), not for styled surfaces. - Offline scope of the PWA beyond app-shell (cache last-played transcript?).
References¶
- Related PRDs:
docs/prd/PRD-039-player.md,docs/prd/PRD-038-catalog.md,docs/prd/PRD-040-capture.md - Related RFCs:
docs/rfc/RFC-098-learning-platform-foundation.md,docs/rfc/RFC-100-audio-bridge-subsystem.md - Source Code: new
web/learning-player/; primitives fromweb/gi-kg-viewer/