RFC-104: Operator Shows Library (shows-first browse)¶
Status: Draft
PRD: PRD-044 · UXS: UXS-015
Surface: web/gi-kg-viewer — the library main tab · Backend: none (reuses existing endpoints)
Abstract¶
Add a shows-first browse mode to the operator viewer's Library tab: a grid of the corpus's shows
(GET /api/corpus/feeds) that opens a per-show detail (cover, description, episode count, RSS) listing
that show's episodes (GET /api/corpus/episodes?feed_id=…), each episode cross-linked into the graph
via the existing subject.focusEpisode path. No backend change; reuses PodcastCover, the episode-row
shape, and the shared corpus lens. The existing episode-first LibraryView (UXS-003) is untouched and
becomes the "Episodes" mode of a two-mode Library tab.
Problem¶
The library tab is episode-first only — a flat, paginated list with a Feed filter chip. There is no
way to browse the corpus by show: to see its shows, their identity (art/description/count), and drill
show → episodes. The data and endpoints already exist (see PRD-044 Background); only the navigation is
missing. The consumer player already ships the shows-first shape (Home "Your shows" → PodcastView).
Design¶
No new endpoints. No new stores. Three new components + one toggle, wired into the existing library tab.
Amendment (operator feedback, 2026-07-09). A show now opens in the right subject rail, not in-panel. The card-click calls
subject.focusShow(feed_id)→ a new'show'subject kind →ShowRailPanel(mirrorsEpisodeDetailPanel's header: cover + title + "N episodes · RSS", then the episode list). Clicking an episode in the show rail →subject.focusEpisode(which now pushes the show onto the subject Back stack, so the episode rail carries a ‹ Back to the show). The grid stays in the main library surface. The former in-panelShowDetailViewis retired from the path (file kept, dormant). §1 below describes the original in-panel plan; the rail wiring supersedes the grid⇄detail hop. Phase 2 (shipped, 2026-07-09): a richer show landing — a Signals band above the episode list shows the show's top topics + key people, from a newGET /api/corpus/feed-signalsendpoint that counts Topic/Person nodes across the feed's per-episode KGs (ranked by episode count, diarization placeholders filtered). Chips open the node view in the same rail viasubject.focusTopic/focusPerson(each pushes the show onto the Back stack). Cross-show overlaps remain deferred — they need an all-feeds pass, not a single-feed aggregate.
§1 Component tree¶
library tab (App.vue)
├─ LibraryModeToggle.vue [new] segmented control: Shows | Episodes (persisted per session)
├─ v-if mode==='shows'
│ └─ ShowsBrowse.vue [new] owns shows-first sub-state (selectedFeedId)
│ ├─ ShowsView.vue [new] grid of shows (fetchCorpusFeeds)
│ └─ ShowDetailView.vue[new] one show: header + episode list (fetchCorpusEpisodes{feedId})
└─ v-else (mode==='episodes')
└─ LibraryView.vue [existing, UNCHANGED] the flat episode-first list
ShowsBrowse is the only stateful new piece: a selectedFeedId: string | null ref. null → render
ShowsView (grid); non-null → render ShowDetailView for that feed. Selecting a show sets it;
"Back to shows" clears it. This is replace-in-panel (house pattern, per UXS-014 / operator memory),
not a modal or stacked overlay.
§2 Data flow (all client-side over existing endpoints)¶
- ShowsView: on mount / corpus change,
fetchCorpusFeeds(shell.corpusPath)→CorpusFeedItem[]; sortepisode_count desc, display_title asc; render oneShowCardper feed (PodcastCover+ title + "N episodes" + clamped description). Emitsselect(feed)upward. - ShowDetailView: props
{ feed: CorpusFeedItem, corpusPath }. Onfeedchange, reset + first pagefetchCorpusEpisodes(corpusPath, { feedId: feed.feed_id, limit }); "Load more" appends vianext_cursor. Header from thefeedprop (title, count,rss_url, clampeddescription, largePodcastCover). Episode rows reuse the Library episode-row markup. - Episode open: a row click emits
open-library-episode({ metadata_relative_path }), whichApp.vueroutes tosubject.focusEpisode(metadata_relative_path)(the same handler the flat Library, Digest, and Search already use) → episode opens in graph / episode-detail. No new cross-link policy — it composes with the existinggraphNavigationpath (E2E_SURFACE_MAP §"automation contract"), deliberately not adding another band-aid load-source.
§3 State, persistence, lens¶
- Library mode (
shows|episodes) is a ref in the library-tab host, persisted tolocalStorage(gikg.library.mode, mirroring the theme/shell stores). Defaultepisodes(status quo — the existing operator flow + the 9library.spec.tse2e are unchanged); Shows is opt-in via the toggle and remembered once chosen. Promoting Shows to the default is PRD-044 OQ1 — a deliberate operator decision, not baked in here. selectedFeedIdis ephemeral sub-state ofShowsBrowse(not persisted across reloads for v1; a return from the graph to the Library tab preserves it because the component is kept alive under the tab, matching howLibraryViewretains its scroll/selection).- The shared date lens (
corpusLens.sinceYmd) is not applied to the shows grid (a show's identity is lens-independent) but the ShowDetail episode list MAY honor it later (OQ, deferred); v1 lists all of a show's episodes newest-first.
§4 Reuse (no duplication)¶
PodcastCover.vue— cover resolution (episode art → feed art → initials), already used in Digest / EpisodeDetail / NodeDetail / Library. Used for both the grid card and the detail header.- Episode-row shape (cover, title, recency dot, publish date, summary line, topic pills, GI/KG badges)
— extracted from
LibraryView's row into a sharedEpisodeListRow.vueonly if the extraction is clean; otherwiseShowDetailViewrenders a row with the same classes/testids to avoid destabilizing the 35 KBLibraryView. (Decision at implementation; default: a small sharedEpisodeListRowif it drops ≥ ~40 lines of duplication, else inline parity. Tracked in the PR description.) corpusLibraryApi.ts—fetchCorpusFeeds,fetchCorpusEpisodes(existing; typed).
§5 Accessibility & states¶
- Grid cards + episode rows are
role="button",tabindex="0", Enter/Space activate, visible focus ring (parity withdata-library-episode-row). - Every async surface has explicit loading / error / empty states (
shows-gridempty when 0 feeds;show-detailempty when a show has 0 episodes — never a silent blank). - Descriptions clamp (line-clamp) with an expand toggle when truncated (mirrors consumer PodcastView).
API¶
No change. Contract already covered by existing integration tests for /api/corpus/feeds and
/api/corpus/episodes (tests/integration/server/test_*corpus*). This RFC adds consumer-side
assertions that the shapes carry the fields the surface needs (title, count, image, description, rss).
Testing¶
Matches house tiers (roadmap rubric weak→good→excellent):
- vitest unit —
ShowsViewsort/empty/error;ShowsBrowseselect↔back state; description clamp. - vitest mount —
ShowsView.mount(grid renders N cards from a mocked feeds payload, cover + count),ShowDetailView.mount(header from feed, episodes from mocked payload, "Load more", episode-click emitsopen-library-episode),LibraryModeToggle(toggle switches + persists). - server-contract integration — assert
/api/corpus/feeds+/api/corpus/episodes?feed_idreturn the fields the surface binds (already partly covered; add field-presence asserts on the v3 fixture). - operator e2e (mocked, fast PR gate) —
web/gi-kg-viewer/e2e/shows-library.spec.ts: Library tab → Shows grid → open a show → episode list → click episode → lands on graph. Usespage.routemocks (the harness pattern of the existing 50+ specs). - served-corpus stack-test —
tests/stack-test/stack-shows-library.spec.ts: same flow against the Docker-served v3/seeded corpus (mirrorsstack-viewer/stack-person-profile). Exercises real/api/corpus/feeds+ realPodcastCoverart resolution.
DoD: every new component ≥ good (mount test asserting real payload); the end-to-end shows→episode →graph flow is excellent (served-corpus stack-test).
Phasing¶
- P1 — components + toggle (ShowsView, ShowDetailView, ShowsBrowse, LibraryModeToggle) + vitest.
- P2 — wire into App.vue library tab +
focusEpisodecross-link + surface-map updates (VIEWER_IA, E2E_SURFACE_MAP, uxs/index). - P3 — e2e (mocked spec + stack-test spec) + server field-presence asserts.
Each phase is independently green (vue-tsc 0 + targeted vitest) and bisectable.
Alternatives considered¶
- A. New top-level "Shows" tab (peer of Digest/Graph/Library). Rejected: shows + episodes are the same browse concern; a second tab fragments IA and duplicates the corpus/lens plumbing. A mode toggle inside Library keeps one home for "browse the corpus".
- B. Fold shows-first into
LibraryView(add agroupBy=showto the existing view). Rejected for v1:LibraryViewis 35 KB and heavily tested; a grid↔detail state machine inside it raises regression risk. A siblingShowsBrowseisolates the new surface. (Unifying the Feed filter chip with show-detail is PRD-044 OQ3, deferred.) - C. Add a
/api/corpus/shows/{feed_id}detail endpoint. Rejected:feeds+episodes?feed_idalready provide the header and the list; a detail endpoint is redundant server surface.
No ADR needed¶
This reuses existing endpoints, stores, and component patterns; there is no durable architectural fork
(no new dependency, schema, or cross-cutting contract). The RFC + UXS are the record. If P2 extraction
of a shared EpisodeListRow grows into a cross-surface row contract, that graduates to its own note.