Skip to content

feat(client): add library page with five browse lenses#541

Merged
electather merged 15 commits into
mainfrom
feat/library-page
Jun 2, 2026
Merged

feat(client): add library page with five browse lenses#541
electather merged 15 commits into
mainfrom
feat/library-page

Conversation

@electather

@electather electather commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

Ports the Nama "Library" design prototype into a new /library feature page. The page renders a counted header (eyebrow + title), lens tabs, and a faceted filter popover, then groups the same filtered catalog through five lenses: A→Z (sticky letter rail), Timeline (decade strips), Collections (fanned poster stacks), Servers, and Quality. Data is mocked for now behind the React Query layer; the grouping, filtering, and facet counts are all real and survive the eventual swap to the unified media API.

The page reuses the shared MediaRowCard and MediaCard* primitives, links cards to the existing /media/$mediaType/$mediaId detail route, and styles everything with shadcn design tokens — no hard-coded colors. The repo's .dark theme already matches the prototype palette, so the port maps cleanly onto existing tokens.

While unifying the bucket/lens switcher onto the shared RouteTabs, the watchlist bucket filter lost its per-bucket count pips (now plain navigable tabs); the backing GET /api/media/counts endpoint had no remaining consumer and was removed. Documented in docs/2026-05-30-media-resource-unification-design.md (§A2 RISK-A2-counts) and .changeset/drop-counts-server.md.

Linked issue

Relates to epic #491 (media resource unification).

Type of change

  • New feature (non-breaking change that adds functionality)

Scope

  • @ent-mcp/client

Test plan

  • vp check passes locally (format, lint, type-check — 0 errors across 1490 files)
  • vp test passes locally (350 files / 2569 tests; new library-data.test.ts: 11/11)
  • Manual verification: start vp run dev, sign in, navigate to /library, switch lenses, toggle filter pills, click a card → detail page.

New unit tests cover the filtering (each facet axis incl. the watched axis + intersection), watched-state classification, facet counts, and the grouping strategies (incl. the yearless-timeline bucket).

Screenshots / recordings

Mock data only; screenshots to follow after manual run.

Checklist

  • PR title follows Conventional Commits
  • Tests added or updated for new behaviour
  • Docs updated (README, docs/, inline) where relevant — n/a (inline comments only)
  • A changeset is included (.changeset/library-page.md, @ent-mcp/client minor)
  • No secrets, credentials, or personal data committed

Notes for reviewers

  • Feature follows the flat single-surface layout from frontend-feature-architecture (rules cited inline): centralized lib/fetchers.ts, query-keys factory (rule 4), Suspense read (rule 5), page-owned state (rule 8), m.* i18n with enum label functions (rule 9), barrel exports only the public surface (rule 10), tests in __tests__/ (rule 11).
  • The card watchlist quick-action from the prototype was intentionally left out of this first pass to keep it mock-only (it needs the real watchlist mutation); easy follow-up.
  • The mock fetcher (lib/fetchers.ts) documents the API-swap point for Epic: PR #483 follow-up — media consolidation, frontend dedupe, paraglide variants #491.

Port the Nama library design into a new /library feature: a counted header
with a stats spine, lens tabs, title search, and a faceted filter popover,
over five lenses (A→Z, Timeline, Collections, Servers, Quality). Reuses the
shared MediaRowCard and shadcn tokens; data is mocked behind the React Query
layer pending the unified media API.
@github-actions github-actions Bot added the status: in-progress Actively being worked on label May 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Cloudflare preview: https://app-pr-541.omid-ocean.workers.dev

@github-actions

github-actions Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Fallow combined report

Found 25 findings.

Details
Severity Rule Location Description
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:26 Code clone group 1 (38 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:34 Code clone group 2 (32 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:44 Code clone group 3 (23 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:52 Code clone group 4 (25 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:66 Code clone group 5 (20 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:97 Code clone group 3 (23 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:109 Code clone group 6 (22 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:118 Code clone group 7 (33 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:138 Code clone group 8 (22 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:143 Code clone group 1 (38 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:162 Code clone group 9 (29 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:193 Code clone group 10 (30 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:211 Code clone group 4 (25 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:222 Code clone group 11 (25 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:246 Code clone group 5 (20 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:267 Code clone group 7 (33 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:276 Code clone group 12 (25 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:279 Code clone group 10 (30 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:301 Code clone group 9 (29 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:308 Code clone group 12 (25 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:322 Code clone group 6 (22 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:340 Code clone group 8 (22 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:361 Code clone group 11 (25 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/\_\_fixtures\_\_/library-items.fixture.ts:364 Code clone group 2 (32 lines, 2 instances)
minor fallow/code-duplication apps/client/src/features/library/lib/search.ts:40 Code clone group 13 (17 lines, 2 instances)

Generated by fallow.

Comment thread apps/client/src/routes/_authenticated/_app/library.tsx Fixed
Omid Astaraki added 2 commits June 2, 2026 10:09
- Removed `home_card_kind` and `library_kind` from home and library message files.
- Introduced new `media_kind` definitions in `media/en.json` and `media/fa.json`.
- Updated references in components and tests to use the new `media_kind` instead of the removed definitions.
- Refactored library filter popover to use a `ToggleGroup` for facet selection instead of custom pills.
- Created a reusable `LensPage` component to handle empty states and content rendering for library lenses.
- Improved filtering logic by introducing `useFilteredLibraryItems` hook for better separation of concerns.
Comment thread apps/client/src/routes/_authenticated/_app/library.collections.tsx Fixed
Comment thread apps/client/src/routes/_authenticated/_app/library.index.tsx Fixed
Comment thread apps/client/src/routes/_authenticated/_app/library.quality.tsx Fixed
Comment thread apps/client/src/routes/_authenticated/_app/library.server.tsx Fixed
Comment thread apps/client/src/routes/_authenticated/_app/library.timeline.tsx Fixed
Comment thread apps/client/src/routes/_authenticated/_app/library.tsx Fixed
@electather electather marked this pull request as ready for review June 2, 2026 10:17
@github-actions github-actions Bot added status: ready-for-review Ready for reviewer attention and removed status: in-progress Actively being worked on labels Jun 2, 2026
Comment thread apps/server/src/api/procedures/media.ts
Comment thread apps/client/src/features/library/components/library-card.tsx
Comment thread apps/client/src/features/library/hooks/use-library-filters.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9dfbee8f8a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/client/src/features/library/lib/grouping.ts Outdated
Comment thread apps/client/src/features/library/components/lenses/az-lens.tsx
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review: feat(client) — library page with five browse lenses

Good PR overall. Architecture is clean: flat feature folder, URL-owned filter state, Suspense-reads everywhere, es-toolkit for grouping/counting, shared RouteTabs/RouteTab primitives, proper fallow zone registration, bilingual i18n. The mock-data-first approach with a real query layer is the right call for an API that doesn't exist yet.

Two blocking issues before merge; four non-blocking suggestions.


🚨 BLOCKING 1 — No library design doc

The library feature is substantive new product surface: five lenses, URL-filter state, faceted popover, scroll-spy rail, fanned collection cards. Per Rule 13 and the review checklist, every subsystem change needs a linked docs/ design document in the same PR.

The PR links epic #491 (an issue), and the only candidate doc — 2026-05-30-media-resource-unification-design.md — covers the media API unification (§B1–§B4 are watchlist + home consolidation). The library page appears nowhere in it: not its lenses, not its filter axes, not its LibraryData shape, not the mock-first/API-swap strategy.

Required: add docs/<date>-library-page-design.md describing the feature's intent, the five lenses, the LibraryData contract, and the API-swap plan, and link it in the PR description. Or point to an existing doc that I missed.


🚨 BLOCKING 2 — Counts endpoint deleted, design doc not updated

GET /api/media/counts is deleted in apps/server/src/api/procedures/media.ts. The design doc explicitly lists it in the target surface:

2026-05-30-media-resource-unification-design.md §A2

GET /api/media/counts    # adapter → watchlist.getCounts

§A6: "countswatchlist.getCounts(ctx)"

The deletion also drops fetchCounts (client), useCounts hook, watchlistKeys.counts, and removes the pre-fetch from the watchlist route loader — cascading through the watchlist header where count pips no longer appear. None of this is documented in the design doc or mentioned in the PR description.

drop-counts-server.md uses empty frontmatter (internal-only signal), but removing a public REST endpoint is not an internal refactor.

Required: update §A2/§A6 in the design doc (in this PR) to remove the counts endpoint and explain the rationale. Or restore the endpoint and handle the watchlist header change separately.


Non-blocking suggestions

Location Issue
library-card.tsx:15 buildMediaHref(...) ?? "#"# creates a clickable broken link; prefer ?? undefined so MediaRowCard can opt out of rendering an anchor
bucket-tabs.tsx:29 Drops count pips that BucketChips showed (e.g. "Ready · 3") — silent watchlist regression; flag as intentional in the PR description
use-library-filters.ts:10 strict: false + type cast — safe today, but silently returns EMPTY_FILTERS outside /library/*; worth a brief comment
collections-lens.tsx:33 CollectionCard has hover affordance but no click handler — add a note that navigation is out-of-scope for this pass
az-lens.tsx:49 activeKey starts null, so the first visible section isn't highlighted on load; initialize to groups[0]?.key ?? null

Changeset note (non-blocking)

Two @ent-mcp/client: minor changesets (library-page.md + library-lens-routes.md) for the same feature. CLAUDE.md: "one logical change per file." Consider collapsing into one.


Summary: fix the two blocking items (design doc for library feature, design doc sync for counts deletion) and this is ready.

Comment thread apps/client/src/features/library/lib/types.ts Outdated
Comment thread apps/client/src/shared/components/route-tabs.tsx Outdated
Comment thread apps/client/src/features/library/components/lenses/az-lens.tsx
Comment thread .changeset/drop-counts-server.md
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

PR adds the Library page (five browse lenses: A→Z, Timeline, Collections, Servers, Quality) with faceted filters + URL-persisted state, plus removes the counts subsystem server-side (no-display-counts redesign) and unifies the watchlist/library tab chrome into a shared RouteTabs component.

Large diff but tightly scoped. Filtering logic, grouping, URL round-trip, and schema coercion are all unit-tested. Design docs for watchlist (rev 10) and media pipeline consolidation are updated in the same commit.


BLOCKING — Design doc for Library feature

PR description links epic #491 but no design document. Per Rule 13 and review checklist item 6:

Each subsystem needs its own docs/ entry. The PR must link it; code must stay in sync.

The Library page is a new subsystem: five distinct lenses, dedicated sub-routes, its own query-key factory, facet engine, search, and URL schema. That warrants a design doc. The counts-removal side is covered by the two updated docs, but the Library surface has no spec.

Required before merge: create docs/<date>-library-design.md (even a stub that describes the five lenses, the filter model, the URL schema, and the API-swap point for #491) and link it from the PR description.


Code Quality

  • Counts deletion — clean, complete. Every call-site (client hooks, server endpoint, shared type, query-key alias, tests) removed or updated. No orphaned references.
  • i18n refactorhome_card_kind/watchlist_kind consolidated to shared media_kind namespace. Three call-sites updated; existing ICU-variant test updated with explanatory comment. Good.
  • RouteTabs/RouteTab — solid shared component. includeSearch: false prevents search-param changes from dropping the active mark (carries V.WL9 regression guard forward). See inline comment on aria-selected semantics.
  • Library filteringes-toolkit's countBy/uniq used correctly. Facet intersection short-circuits on empty selection. Correct.
  • URL schema coercion — single-string → array coercion is tested; drop-on-invalid-value is tested. Test comments explain the why. Good.

Security

No concerns. All user-supplied input (search term, filter params) stays client-side. librarySearchSchema sanitises URL params with Zod before use.

Performance

  • Each lens sub-route calls queryClient.ensureQueryData(libraryDataQueryOptions()). React Query deduplicates by key — only the first navigation pays the fetch. Correct.
  • groupByLetter, groupByDecade, computeFacetCounts are gated by useMemo. Fine at mock scale; worth revisiting when real API lands with large catalogs.
  • AzLens IntersectionObserver disconnected on cleanup — no leak.

Testing

  • 11 unit tests in library-data.test.ts cover filtering, grouping, stats, facet counts.
  • library-search.test.ts covers URL round-trip, coercion, and invalid-value degradation.
  • library-route-error.test.tsx covers route-level error component.
  • bucket-tabs.test.tsx carries V.WL9 regression forward to the new component.
  • No test for AzLens scroll-spy (IntersectionObserver) — acceptable given JSDOM limits, but add a note if behaviour ever regresses.

Documentation

  • Watchlist design doc rev 10 and media pipeline supersession note: in PR. OK.
  • Library feature: no doc — blocking, see above.

Fallow Baseline

.fallow/dead-code-baseline.json not touched. New client-feat-library zone registered in .fallowrc.json with correct boundary rules. OK.

Multiple Changesets

Four changesets. Each covers a distinct logical change (library page, lens routes, tab unification, counts drop) per CLAUDE.md. No issue.


Summary

Solid implementation. Filtering, grouping, URL state, i18n, and the shared tab component are all well-executed. One hard blocker: add a Library design doc before merge. Three non-blocking notes posted as inline comments (LibraryFacetCounts.kinds type, ARIA semantics on RouteTab, DOM id coupling in AzLens).

Omid Astaraki added 4 commits June 2, 2026 11:33
Group items without a year into a trailing "unknown" bucket instead of
dropping them, so a yearless-only filter set renders rather than leaving
the /library/timeline route blank. Addresses Codex review.
Make MediaRowCard/MediaCardLink href optional and skip the anchor when it
is undefined, so a null detail href no longer renders a clickable href="#"
that jumps to the page top. Library cards now pass undefined on a format
mismatch. Addresses review.
…lter cast

Initialize the A-Z rail's active letter to the first group so it highlights
on load, and document the intentionally-inert collection card and the
strict:false search cast. Addresses review nits.
- use-library-filters: re-validate search via schema safeParse instead of an
  unchecked cast, so out-of-route mounts degrade explicitly to EMPTY_FILTERS.
- lens-tabs: document the search-carry axis list as a maintenance point.
- az-lens: explain the scroll-spy rootMargin insets.
- grouping: note QUALITY_TIERS should grow with new API tiers.
- library-error-fallback: explain the fire-and-forget resetQueries.
- tests: add a watched-axis filter integration test.

Addresses review nits.
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #541: Library page + counts endpoint removal

Overall

Solid, well-scoped PR. The library feature is cleanly isolated in features/library/, the design-doc is updated (rev 10 in watchlist-sections-design.md + RISK-A2-counts in media-resource-unification-design.md), fallow baseline is unchanged, and changesets cover all three logical changes. No blocking issues found.

Design-doc sync ✅

Linked doc: docs/2026-05-30-media-resource-unification-design.md. Rev 10 added to docs/2026-05-23-watchlist-sections-design.md. The counts removal is documented at §A2 (⊥ /api/media/counts, RISK-A2-counts). Code matches the spec.

Fallow baseline ✅

.fallow/dead-code-baseline.json not touched. New client-feat-library zone registered in .fallowrc.json with correct boundaries.

Security ✅

All URL-sourced facet values go through the Zod schema (z.enum(MEDIA_TYPES), z.enum(WATCHED_STATES), z.string()). Free-form string axes (genres, qualities, servers) are used only for filtering and React rendering — no SQL or shell interpolation. No credentials in mock data.

Testing ✅

library-data.test.ts (11/11): filtering, watched-state classification, facet counts, all 4 grouping strategies. library-search.test.ts: URL round-trip, coercion, validation. library-route-error.test.tsx: error boundary render. bucket-tabs.test.tsx replaces the deleted bucket-chips.test.tsx and covers the V.WL9 regression.

Non-blocking suggestions (inline)

See inline comments for specific line-level notes. All are suggestions, none are blockers.

Comment thread apps/client/src/features/library/lib/filtering.ts
Comment thread apps/client/src/features/library/components/library-lens-tabs.tsx
Comment thread apps/client/src/features/library/lib/grouping.ts
Comment thread apps/client/src/shared/components/route-tabs.tsx
Comment thread apps/client/src/features/library/lib/grouping.ts
- decadeOf: guard with `year == null` so a literal year 0 isn't swept into
  the unknown bucket by the falsy check.
- RouteTabs: require `aria-label` at the type level so each navigation
  landmark is named (both callers already pass it).
- lens-tabs: expand the search-carry comment to note why the codec round-trip
  can't replace the manual axis list.

Addresses third review pass.
Comment thread apps/client/src/features/library/components/library-lens-tabs.tsx
Comment thread apps/client/src/features/library/lib/grouping.ts
Comment thread apps/client/src/features/library/components/lenses/az-lens.tsx
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

Ports the Nama library prototype into a real /library feature: five lenses (A→Z, Timeline, Collections, Servers, Quality), faceted URL-serialized filters, shared RouteTabs component, and a full removal of the GET /api/media/counts endpoint and all its plumbing. ~3000 additions, ~900 deletions.


✅ Design-doc sync — PASS

  • docs/2026-05-23-watchlist-sections-design.md updated with rev 10 (counts removal, RouteTabs unification) in the same PR. Code matches the documented changes exactly.
  • docs/2026-05-30-media-resource-unification-design.md (§A5, §A2 RISK) updated to reflect /counts drop.
  • Both docs updated in the same PR as the code. No stale passages found.

✅ Fallow baseline — PASS

.fallow/dead-code-baseline.json not touched. New feature zone added to .fallowrc.json with correct boundary declarations (client-feat-library allowed to reach client-shared-* and shared-pkg only). countBuckets / getCounts / BucketChips deletions reduce the surface, not expand it.


Code quality

Strong:

  • LensPage guard centralizes empty-state wiring in one place; every lens route is a one-liner.
  • RouteTab/RouteTabs extraction is correct: aria-current="page" for navigation links, not role=tab/aria-selected (which requires a tabpanel). Comment in the component explains the ARIA rationale.
  • URL-serialized filters with strict: false on useSearch is the right approach for a shared layout hook above child routes.
  • librarySearchSchema coerces single-value params (?kinds=movie["movie"]) and degrades bogus values to open axis — solid defensive parse.
  • useFilteredLibraryItems memoizes the filtered array in one place; both the header count and the lens pages derive from it without duplicating the applyLibraryFilters call.
  • computeFacetCounts counts from the full unfiltered catalog — facet badge counts stay stable as pills are toggled.
  • Error recovery: LibraryErrorFallback.handleRetry correctly calls resetQueries before resetErrorBoundary to avoid re-throwing the cached failure.
  • BACKDROP_VARS/POSTER_VARS promoted out of two feature files into scroll-row.tsx — right call.
  • media_kind message correctly promoted from home and watchlist namespaces to a shared media namespace; all three call sites updated.
  • Changeset files correctly scoped (drop-counts-server.md lists both @ent-mcp/server and @ent-mcp/client; library-page.md lists only @ent-mcp/client).

Observations (all non-blocking):

  • Four inline comments posted above covering: collection card hover affordance on non-interactive element, LibraryLensTabs search-reducer manual sync risk, indexLetter article handling vs. server parity, and AzLens rootMargin magic number.
  • libraryKeys roots under ["library"], independent of mediaKeys.root. This means the watchlist mutation sweep (invalidateQueries({ queryKey: mediaKeys.root })) won't flush library data — correct for now (library comes from a separate mock/future endpoint), but worth documenting when the real API lands so it's wired into the sweep or a deliberate independent stale-time policy.

Testing

Unit tests cover filtering (each axis independently + intersection), facet counting, grouping (A→Z, decade, quality, server), watched-state classification, URL round-trip, and single-value coercion. LibraryRouteError render test is minimal but correct. One gap: no test verifying that filter axes survive a lens switch (the search-reducer path). See inline comment on library-lens-tabs.tsx.

Security / Performance

No security issues. No unbounded allocations — groupBy/sortBy from es-toolkit on a client-side in-memory array. IntersectionObserver disconnected on cleanup. LibraryCard correctly memoized. libraryDataQueryOptions has a 5-minute staleTime to avoid refetching on lens switch.


Verdict: approve after addressing the CollectionCard hover affordance (or explicitly deferring it in a comment). All other findings are non-blocking suggestions.

…th a test

- indexLetter/titleSort: drop a leading The/A/An so "The Amber Room" files
  under A and sorts by "Amber", matching Plex/Jellyfin/Emby letter nav.
- add a regression test asserting the lens-tab search reducer carries every
  librarySearchSchema axis, so a new axis can't silently drop on lens switch.
- az-lens: extract the scroll-spy top inset to a named constant.

Addresses fourth review pass.
Comment thread apps/client/src/features/library/components/library-layout.tsx Outdated
Comment thread apps/client/src/features/library/lib/grouping.ts Outdated
Comment thread apps/client/src/features/library/components/lenses/az-lens.tsx Outdated
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #541: Library page with five browse lenses

Blocking checks

Check Result
Design-doc sync Pass — three docs updated in same PR: watchlist-sections rev 10, pipeline-consolidation §G superseded note, media-resource-unification §A2/A5/A6/A7 all reflect counts removal
Fallow baseline Pass — .fallow/dead-code-baseline.json untouched; only .fallowrc.json gains the new client-feat-library zone with correct isolation rules
Changesets Pass — three properly formed files: library-page.md (minor client), drop-counts-server.md (minor server + client), unified-route-tabs.md (patch client)

Overview

Substantial, well-scoped feature: ~2500 LOC adding a flat features/library/ folder with five URL-driven lenses (A-Z, Timeline, Collections, Servers, Quality), URL-serialised faceted filters, Suspense + error boundary shells, and parallel Farsi i18n. The counts endpoint removal is clean — docs, server code, client hook, query key, and route-loader prefetch all deleted together with no dangling references.


What works well

  • Architecture — filters/grouping/labels strictly separated; each lens composes the shared filtered dataset independently; memoisation throughout.
  • URL state — Zod schema coerces and validates all params; round-trip lossless; empty axes omitted (clean bare /library URL).
  • Tests — five test files covering filter logic per-axis, multi-axis intersection, grouping edge cases (yearless titles, article stripping, quality tier order), URL round-trips, lens-tab filter carry, and error fallback render.
  • Accessibility — aria-label on letter rail, aria-current="true" on active rail button, aria-hidden on empty letters, keyboard-navigable filter popover.
  • Security — no raw interpolation; all user-facing strings from i18n or API payload; URL params validated before use; route under /_authenticated.

Issues (all non-blocking)

Four inline comments posted — summaries below.

1. Tailwind style inconsistency (library-layout.tsx:19, library-skeleton.tsx:7)
max-w-400 (v4 numeric) and the watchlist's max-w-[100rem] (bracket arbitrary) both equal 100 rem in Tailwind v4. Not a bug, but pick one form. Suggestion on the inline.

2. Collection count badge vs visible poster count (collections-lens.tsx:113)
collection.itemIds.length is the total collection size; when filters are active the card fans only the filtered subset but the badge still says the full count. Either count the filtered intersection or add a comment explaining the intent.

3. localeCompare() without locale (grouping.ts:47, also line 125)
Falls back to the runtime locale. On Farsi browsers the sort order will differ from the intended A-Z English collation. Pass "en" explicitly. Same fix on the second call.

4. SCROLL_SPY_TOP_INSET = "-100px" vs top-24 (96 px) (az-lens.tsx:23)
4 px gap between the rail position and the spy trigger band. Harmless now, but derives from two magic numbers with no shared token. Align to -96px or leave a TODO to derive both from the same token when the layout constant lands.


Performance note (informational, no action needed now)

A-Z, Collections, Servers, and Quality lenses render all items without virtualisation. With the 30-item mock this is invisible, but real catalogs at 500+ titles will be noticeably slower for those lenses. Timeline reuses the existing ScrollRow virtualisation. Worth noting for the epic #491 follow-up when real data lands.


Mock fetcher (informational)

lib/fetchers.ts intentionally resolves SAMPLE_LIBRARY — clearly documented and the PR test plan checkbox is unchecked. Fine for this pass; the swap point is well-marked.


Summary: four small non-blocking issues, no blocking problems. Design docs in sync, fallow baseline clean, changesets correct. Approve after the localeCompare locale fix (highest real-world risk given the Farsi locale shipping with the app); the rest can land as is or as a follow-up.

…inset

- grouping: pass "en" to localeCompare so the A–Z rail keeps English
  collation on the fa locale build.
- layout/skeleton: use max-w-[100rem] to match the watchlist sibling form.
- az-lens: align the scroll-spy top inset to the rail's top-24 (96px).
- collections: note the count badge is the full curated size by design.

Addresses fifth review pass.
@electather electather merged commit d4b2228 into main Jun 2, 2026
14 of 15 checks passed
@electather electather deleted the feat/library-page branch June 2, 2026 11:57
@github-actions github-actions Bot removed the status: ready-for-review Ready for reviewer attention label Jun 2, 2026

/** The quality tiers and servers a facet can offer, derived from the item set. */
export function qualitiesOf(item: LibraryItem): string[] {
return item.tags ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking — semantic coupling to tags

qualitiesOf maps item.tags directly to the quality facet. types.ts documents the assumption (tags = quality tiers only), but once the real API ships, tags may carry non-quality metadata (content warnings, network, format flags). Polluted tags would surface as spurious quality pills in the filter popover.

When the fetchLibrary mock is swapped (#491), either:

  • verify the API schema ensures tags contains only quality tiers, or
  • rename the field or filter by a known allowlist before passing to this function.

Flagging now so it's in scope for the API-swap PR rather than a silent regression.

@@ -0,0 +1,12 @@
import { SAMPLE_LIBRARY } from "../__fixtures__/library-items.fixture";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking — fixture ships in the production bundle

SAMPLE_LIBRARY (~488 lines) is imported at a production code path. The fixture won't be tree-shaken because the import lives in fetchers.ts, not under a test guard. When the real API swap happens, the import will be replaced so the fixture becomes unreachable — but until then it inflates the client bundle.

If bundle size is a concern before the swap, wrapping behind a process.env.NODE_ENV === 'development' guard (and a matching lazy import) would keep it out of production builds. Low priority for a mock-first pass; just make sure the API-swap PR removes this import entirely.

// round-trip would cover this automatically, but TanStack's search
// reducer typing rejects the helper's return — so it's a manual list.)
search={(prev) => ({
kinds: prev.kinds,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking — manual sync risk

The comment names the hazard clearly: a new axis added to librarySearchSchema must be mirrored here manually or it silently drops on lens switch. The fix is also in the comment (a codec round-trip), but TanStack's types block it.

One enforcement option that doesn't fight the router typing: add a compile-time exhaustiveness check using satisfies:

search={(prev) => {
  // Exhaustiveness: if librarySearchSchema grows a new axis, this
  // object will fail to satisfy LibrarySearch and surface as a
  // type error rather than a silent runtime drop.
  const next: LibrarySearch = {
    kinds: prev.kinds,
    genres: prev.genres,
    qualities: prev.qualities,
    servers: prev.servers,
    watched: prev.watched,
  };
  return next;
}}

LibrarySearch already exists (search.ts exports it). Makes the coupling explicit to the type system at zero runtime cost. Up to you whether the extra line is worth it.

// Intentionally inert for this pass: the card carries hover affordance but no
// click target yet. Collection drill-down (a pre-filtered `/library` view) is
// out of scope here and lands with the collections detail route — don't wire a
// half-finished handler onto the hover state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking — hover affordance on a non-interactive element

CollectionCard applies hover:-translate-y-1 hover:border-primary/40 hover:shadow-hero but has no onClick or href. Users (and AT) will see a <article> that visually reacts to pointer but does nothing on activation. Two options:

  1. Reduce the affordance until the drill-down route lands — keep the from-card to-card/55 gradient and shadow but drop the translate/border-color transition. The "it looks clickable" cue stays off until it IS clickable.
  2. Add cursor-default and aria-disabled to be explicit that the hover is cosmetic.

The comment explains the intent correctly; this is a UX/accessibility heads-up for the first real users hitting the page.

key={group.key}
id={anchorId(group.key)}
data-letter={group.key}
className="scroll-mt-28"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking — scroll-mt-28 (112 px) vs SCROLL_SPY_TOP_INSET (−96 px)

scroll-mt-28 = 7rem = 112 px scroll margin; SCROLL_SPY_TOP_INSET = 96 px observer top inset. The two values serve different purposes so they don't need to be identical, but the comment on SCROLL_SPY_TOP_INSET says it "matches the rail's top-24 (96px)" — top-24 is the sticky nav offset, and scroll-mt-28 should also match the nav height (so jumped-to sections clear the nav). If the nav is truly 96 px, scroll-mt-24 (96 px) would be the precise match. The 16 px extra in scroll-mt-28 is effectively padding above each jumped-to heading — fine aesthetically, but the two constants now describe three different heights (nav = 96px, scroll-margin = 112px, observer top = 96px). Consider making this intentional padding explicit in a comment, or aligning scroll-mt-24 with the nav token.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Code Review

What PR does

Ports the Nama Library prototype into a real /library route with five grouping lenses (A→Z, Timeline, Collections, Servers, Quality). As a side effect, removes the GET /api/media/counts endpoint — the watchlist header no longer needs per-bucket count pips after migrating to the shared RouteTabs design. All filtering, grouping, and facet-count logic is real and wired to React Query; only the data source is mocked (fetchLibrary → fixture).


✅ Fallow baseline — PASS

.fallow/dead-code-baseline.json is not in the diff. Only .fallowrc.json is updated (new client-feat-library zone registered in the boundary matrix). No baseline entries added.


✅ Design-doc sync — PASS

Three design docs updated in the same PR, all consistent with the code:

Doc Change
2026-05-30-media-resource-unification-design.md GET /api/media/counts struck from endpoint table; §A2/§A5/§A6/§A7 updated; RISK-A2-counts added
2026-05-23-watchlist-sections-design.md Rev 10 entry documents deletion of useCounts, fetchCounts, watchlistKeys.counts, route-loader prefetch, server getCounts, countBuckets, WatchlistCounts
2026-05-26-media-pipeline-consolidation-design.md §G superseded notice added for /counts

No public-surface extension without a matching doc edit. ✓


Code quality

Solid overall. The feature follows the established flat feature-folder layout, query-keys factory, Suspense read pattern, and URL-as-state for filters. A few things worth noting:

useLibraryView vs useLibraryContent — double filter pass

LibraryHeader calls useLibraryView and each lens page calls useLibraryContent; both internally call useFilteredLibraryItems(data, filters). Since they live in separate component subtrees, useMemo won't deduplicate — two O(n) filter scans per state change. Fine against the mock fixture, but when a real 5k-item catalog lands this will be noticeable. Consider lifting filtered into a context or passing it as a prop from the layout to both the header and the lens pages.

WATCHED_STATES duplicated between shared and feature-local

packages/shared/src/watchlist/types.ts already has WATCHLIST_BUCKETS. WATCHED_STATES and WatchedState are declared locally in library/lib/types.ts. If watched-progress buckets ever need to align with the server's WatchlistBucket enum, keeping two definitions will drift. Low risk for a client-only facet, but worth noting.


Security

Nothing concerning. URL search params go through librarySearchSchema.safeParse with .catch(undefined) graceful degradation. No new server surface. Removed server endpoint (getCounts) was properly authenticated and its deletion is clean — all call sites removed.


Performance

  • Memoization strategy is correct (computeFacetCounts and collectFacetValues memoized on data.items, not on filters, so they stay stable while the user toggles pills).
  • IntersectionObserver in AzLens is properly torn down in the effect cleanup.
  • Fixture in production bundle flagged inline — see fetchers.ts comment.

Testing

Good unit coverage for the logic-heavy parts: filtering (all axes + intersection), watched-state classification, facet counts, all four grouping strategies including the yearless-timeline edge case. library-search.test.ts covers URL round-trips. bucket-tabs.test.tsx correctly replaces bucket-chips.test.tsx with the V.WL9 active-state invariant.

Gaps (non-blocking):

  • useLibraryFilters hook — the strict: false fallback (filters fall back to EMPTY_FILTERS when mounted outside route family) is untested.
  • CollectionsLens — no render test; the fan choreography in FAN could silently break if entries are reordered.

Changesets

drop-counts-server.md bumps both @ent-mcp/server and @ent-mcp/client as minor. Removing a publicly-typed endpoint (GET /api/media/counts, WatchlistCounts) is technically a breaking change for any external consumer. The description says "pre-stable" — if @ent-mcp/server hasn't reached 1.0 yet, minor is fine per semver; confirm this is the intent.


Inline comments

Five non-blocking comments posted on specific lines:

  1. filtering.ts:6qualitiesOf maps item.tags directly; will need attention at the API-swap PR if tags carries non-quality metadata.
  2. fetchers.ts:1 — fixture ships in the production bundle until the API swap; flag the import for removal.
  3. library-lens-tabs.tsx:35 — manual search param list can be enforced at compile time via satisfies LibrarySearch.
  4. collections-lens.tsx:93 — hover affordance on an intentionally inert <article> looks interactive; consider cursor-default or reducing the translate/border-color effect until the drill-down route lands.
  5. az-lens.tsx:124scroll-mt-28 (112 px) vs SCROLL_SPY_TOP_INSET (96 px) are subtly inconsistent; worth an explicit comment or alignment.

No blocking issues. Approve when the changeset version bump is confirmed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants