The details screen (src/app/details/[id].tsx) renders cast/crew as static PersonCards. Goal: pressing a person card opens a person route showing who they are plus their previous work in horizontal rows grouped by role (Acting, Directing, Writing, …). Additionally (mid-session request): pressing the poster on the details page — and the headshot on the new person page — zooms the image via @nandorojo/galeria.
User decisions:
- TMDB is the single source of truth for the person route (bio + credits). The route is keyed by TMDB person id only — no
anilist-person-*/trakt-person-*union handling in the view. - AniList-sourced people (anime voice actors/staff, which carry no TMDB id) resolve by name search on TMDB when tapped.
- Galeria zoom on the details poster and the person headshot.
TMDB here is a metadata source, not a tracker provider (like lib/providers/mapping/anizip.ts): no ProviderId widening, no registry/session changes.
- Auth via
EXPO_PUBLIC_TMDB_TOKEN(TMDB v4 Read Access Token,Authorization: Bearerheader) — builder-supplied env var, same model asEXPO_PUBLIC_TRAKT_CLIENT_ID(src/lib/providers/trakt/config.ts). A BYO in-app settings field is a later follow-up, not in scope. - When the token is empty, person cards render non-pressable (feature dark, nothing errors).
- Per Web & CORS convention: probe
api.themoviedb.orgwith a browserOriginheader (curl) and record findings indocs/solutions/web-cors-tmdb.mdbefore relying on the web path. (TMDB is expected to pass — it sendsaccess-control-allow-origin: *.)
Mirror the Trakt module's shape (deps injection, no Effect Layers; tagged errors from lib/providers/errors.ts):
config.ts—TMDB_API_BASE_URL(https://api.themoviedb.org/3),tmdbToken()from env, image URL helpers overhttps://image.tmdb.org/t/p/(w342posters,w185grid headshots,originalfor the zoomable headshot).deps.ts—TmdbDeps { fetch: HttpFetch; token: string }.api.ts—tmdbRequest<T>(deps, path): GET + Bearer header → JSON, mapped into the existing taggedProviderErrors (followtrakt/api.tsincl. its rate-limit/backoff handling).reads.ts:getPerson(deps, { tmdbId })→ one request:/person/{id}?append_to_response=combined_credits→{ person: NormalizedPerson; rows: PersonCreditRow[] }.searchPerson(deps, { query })→/search/person?query=…(for the name-lookup path).
normalize.ts(+normalize.test.ts, bun:test):- Person: name, headshot, biography, birthday/deathday, birthplace,
knownForDepartment. - Credits → rows:
castarray becomes the "Acting" row;crewentries group by theirdepartmentfield into one row each. Row order:known_for_departmentrow first, then by item count. Within a row: dedupe by media id (merge character/job strings — TMDB repeats a show per role), sort by release/first-air date desc, undated (upcoming) first. - Each credit →
NormalizedMediaItem: idtmdb-movie-{id}/tmdb-tv-{id}, typeMOVIE/TV,coverImagefrom poster path (or''),externalIds.tmdb,currentProgress: 0,lastUpdatedfrom injected now-ISO (Clock pattern as intrakt/reads.ts). pickPersonMatch(results, name)— exact case-insensitive name match preferred, else highest-popularity hit, null on empty; unit-tested (the people analog ofpick-movie-match.ts, perdocs/solutions/trakt-text-search-wrong-movie-match.md).
- Person: name, headshot, biography, birthday/deathday, birthplace,
- Add
NormalizedPersonandPersonCreditRow { role: string; items: NormalizedMediaItem[] }. - Add
tmdbId?: numbertoNormalizedCastMemberandNormalizedCrewMember; populate it intrakt/normalize.ts(normalizeCastEntry,normalizeCrew) fromperson.ids.tmdb. AniList people keep it absent → they take the name-lookup path.
Effect runs only at the boundary (Effect.runPromise in queryFn), matching state/queries/trakt.ts:
tmdbQueryKeys = { all, person(id), personSearch(name) }.tmdbDeps()wiringhttpFetch+tmdbToken().useSuspenseTmdbPersonQuery({ tmdbId }),useSuspenseTmdbPersonSearchQuery({ name }). LongstaleTime(person data barely churns).
src/lib/routes.ts: add person: (tmdbId) => /person/${tmdbId} and personLookup: (name) => /person/lookup?name=${encodeURIComponent(name)}.
src/app/person/[id].tsx— the person screen:- Header: Galeria-zoomable headshot (initials fallback as on
PersonCard), name, meta line (known-for dept · birthday · birthplace), biography with the Read-more clamp. - Extract the existing
Overviewcomponent out ofdetails/[id].tsxintosrc/components/expandable-text.tsxand reuse it in both screens (it's a measured two-line clamp worth keeping single-sourced). - Credit rows reuse
components/media-carousel.tsx(MediaCarousel) under aSuspenseSectionwith a rail skeleton;onItemPress→router.push(routes.details(item.id)).collapseKeyper role (e.g.person-acting) so collapse prefs are per-role, not per-person. Headtitle, back button, and route-levelErrorBoundaryexport as on the details screen.
- Header: Galeria-zoomable headshot (initials fallback as on
src/app/person/lookup.tsx— name resolution: readsnamesearch param, suspends onuseSuspenseTmdbPersonSearchQuery,pickPersonMatch→<Redirect>to/person/{id}; on miss renders a "Person not found" view (same style as the details Not-found state).
PersonCardbecomes pressable (PresstableScaleper the pressto rule):tmdbId != null→routes.person(tmdbId), else →routes.personLookup(name). Not pressable whentmdbToken()is empty.- Credit → details resolution: add
findInTmdbCreditsCache(queryClient, id)alongsidefindInSearchCache— scans cachedtmdbQueryKeysperson queries' rows so items opened from a filmography resolve without plan-0007 provider-fetch. - Trakt identity backfill: credit items carry a tmdb id but no trakt id, so seasons/people/studios wouldn't render. Add
useTraktIdentityQuery(item)instate/queries/mapping.ts— enabled whenexternalIds.trakt == null && externalIds.tmdb != null, runs the existinglookupByExternalId(trakt/reads.ts) under the existingmappingQueryKeys.traktLookup('tmdb', id, kind)key (kind fromitem.type), merged via the existingmergeCatalogueMetadata. (useMovieCatalogueQuerystays as-is for the Letterboxd title+year path.)
bun add @nandorojo/galeria. Native module → rebuild required (bun ios.clean/bun android.clean); no Expo Go. New Architecture requirement is met (Expo 57 / RN 0.86). Galeria needs iOS deployment target ≥ 16.4 — if prebuild rejects it, addexpo-build-propertiestoapp.jsonplugins withios.deploymentTarget: "16.4"(config change → also rebuild).- Wrap once, per convention:
src/components/zoomable-image.tsx— composes<Galeria urls={[uri]}><Galeria.Image>around the app's existingcomponents/image(expo-image is supported by galeria); renders a plainImagewhen the uri is empty. Screens never import galeria directly (add tono-restricted-importsin.oxlintrc.json, same pattern as the List/expo-image rules). - Use it for: the details-screen poster (
artwork.coverImage) and the person-screen headshot. Galeria's web support is single-image only — exactly our usage, so web works too.
bun test— new tmdb normalize +pickPersonMatchtests pass alongside the suite.bun lint— includes the new galeria import rule.- CORS probe:
curl -si -H 'Origin: http://localhost:8081' 'https://api.themoviedb.org/3/person/500?append_to_response=combined_credits' -H 'Authorization: Bearer …'(+ OPTIONS preflight) → writedocs/solutions/web-cors-tmdb.md. - Manual, with
EXPO_PUBLIC_TMDB_TOKENin.env(web first — no rebuild needed for everything except galeria):- Movie/TV details → tap a cast member (has tmdbId) → person page: bio + Acting/Directing rows, newest first.
- Anime details (AniList credits) → tap a voice actor →
/person/lookupresolves by name → person page. - Tap a filmography card → details page resolves (via tmdb-credits cache) and backfills Trakt identity (seasons/cast/studios render).
- Poster tap on details + headshot tap on person → galeria zoom (web single-image; native after
bun ios.clean).
- BYO TMDB key settings UI (env-only for now).
- Logging directly from person-page cards (cards navigate; logging happens on details).