---
path: /personas
surface: customer
domain: lilith
auth: signed-in
source: apps/oshun/web/src/app/personas/page.tsx
status: walked
last_walked:
  '2026-06-25 by Codex (loading/retry/empty states, proxy gate, and tap targets covered), against commit HEAD'
  '2026-06-22 by Codex (drafted from source), against commit HEAD'
---

# Your guides · persona picker

## Purpose

The consumer-facing persona session picker: browse the guides available to the
member by family, see eligibility and consent blockers, and select (or clear)
the active guide for sessions. The server page is a thin Lilith shell; the
`PersonaPicker` client island reads the consumer persona routes (`/v1/personas`)
where the BFF resolves member context (tier, consent, surface) server-side from
the session.

## Entry points

- **Direct URL / bookmark** — `/personas` (auth required; the picker shows a
  sign-in prompt rather than the catalogue when unauthenticated)
- **Shell nav** — the page renders `LCustomerNav active="explore"`
  (`apps/oshun/web/src/app/personas/page.tsx:19`), so it presents as part of the
  Explore surface; the nav itself does not contain a `/personas` link (the three
  customer nav items are `Explore`/`Activity`/`Library`,
  `apps/oshun/web/src/design-system/lilith/shells.tsx:82-86`)
- **Inbound links from other surfaces** — not enumerated here; see Open
  questions / known gaps.

## Layout regions

`page.tsx` is a server component: `LWebShell` with
`top={<LCustomerNav active="explore" />}`, a centered responsive container
(`maxWidth: 1080`, `padding: '32px 56px 80px'`), an `LMasthead`, then the
`PersonaPicker` island.

- **Header**: shell header — `LCustomerNav active="explore"` (`shells.tsx:87`),
  with the `OSHUN` wordmark home link and a `← Home`
  `aria-label="Back to OSHUN home"` link.
- **Masthead**: `LMasthead`
  (`apps/oshun/web/src/design-system/lilith/primitives.tsx`) with
  `left="Your guides"`, `kicker="Personas"`, and title "Choose your _guide_."
  (`page.tsx:21-29`).
- **Main**: `<PersonaPicker />` (`page.tsx:31`,
  `apps/oshun/web/src/components/personas/PersonaPicker.tsx`) — a
  `data-persona-picker` grid containing, top-to-bottom:
  - **Active-guide banner** (`data-persona-active={personaId}`) — only when an
    active guide is set; shows "Your active guide is **{displayName}**." and a
    **Clear** button (`PersonaPicker.tsx:122-145`).
  - **Status message** (`data-persona-message`) — transient text after a
    select/clear action, rendered as a polite `role="status"` live region.
  - **Empty catalogue notice** (`data-persona-empty`) — only when the BFF
    returns no eligible, blocked, or grouped cards at all.
  - **No-eligible notice** (`data-persona-none-eligible`) — italic copy "No
    guides are available to you yet — grant the consents below to unlock them."
    when no card across any family is eligible (`PersonaPicker.tsx:153-160`).
  - **Family groups** (`data-persona-family={familyId}`) — assembled from
    `browse.byFamily[]` plus top-level `browse.blocked[]` cards so the BFF's
    real blocked personas are visible; an uppercase family label and a
    responsive card grid
    (`gridTemplateColumns: repeat(auto-fill, minmax(240px, 1fr))`,
    `PersonaPicker.tsx:162-209`).
  - **Persona cards** (`data-persona-card={personaId}`) — display name, summary,
    and either a **select button** (eligible) or a **blockers list**
    (`data-persona-blockers`, ineligible).
- **Footer / sticky bottom**: _None._ (no footer rendered by this page).

## States

- [x] **Loading** — `data-persona-loading` "Loading guides…" renders inside a
      fixed-height status panel with `role="status"` / `aria-live="polite"`; no
      empty or no-eligible copy renders while the first browse is pending.
- [x] **Empty** — no eligible, blocked, or grouped cards (`families = []`) now
      renders a distinct `data-persona-empty` notice: "No guide catalogue is
      available right now." The consent-blocked state remains separate via
      `data-persona-none-eligible`.
- [x] **Populated (short)** — 1–5 cards across one or more `data-persona-family`
      groups; expected grid layout.
- [ ] **Populated (long)** — 50+ cards: the `auto-fill` CSS grid reflows; there
      is **no** pagination, virtualization, or infinite scroll in the component
      (`PersonaPicker.tsx:167`) — all cards render at once.
- [x] **Error (recoverable)** — load failure surfaces `data-persona-error`
      "Could not load personas right now." with `role="alert"` and a
      `data-persona-error-retry` "Try again" button. The retry re-runs
      `browse()` + `getActive()` and can recover to the catalogue without a page
      reload.
- [x] **Error (unrecoverable)** — N/A because the component maps every load
      failure to one of two recoverable text messages (401 → sign-in prompt,
      else → "Could not load personas right now.") with no 500/403 dead-end
      screen.
- [ ] **Offline** — `personasClient` calls go through `@/lib/api-client`, whose
      network failures raise `ApiRequestError` with `status: 0`; that is not a
      401, so the picker shows "Could not load personas right now." There is no
      offline banner and no queued-write handling for select/clear — logged
      under gaps.
- [x] **Gated** — two distinct gates:
  - **Sessionless route access**: `proxy.ts` redirects anonymous `/personas`
    requests to `/welcome?redirect=%2Fpersonas`.
  - **Unauthenticated**: a 401 from the BFF maps to `data-persona-error` "Sign
    in to choose a persona." (`PersonaPicker.tsx:46-50`).
  - **Consent/tier/surface ineligible**: eligible-but-blocked personas render a
    `data-persona-blockers` list with humanized reasons (see Interactions); the
    blocker is shown, never silently hidden.
- [ ] **Standalone PWA** — view renders inside the standard `LWebShell`; no
      PWA-specific affordances in this page's source. Verify safe-area/URL-bar
      behavior during a live walk.

## Interactions

### Active-guide banner

- [x] **Clear** (`button`, `data-persona-clear`)
  - Function: calls `personasClient.clearActive()` →
    `DELETE /v1/personas/active`; on success clears local `active` and sets
    message "Cleared your active persona."; on throw sets "Could not clear your
    active persona."
  - Keyboard: native `<button>` — Enter/Space activate; tab order follows DOM
    (after the banner text).
  - Screen reader: `aria-label="Clear active persona"`; `aria-busy` while the
    DELETE is in flight.
  - Touch target: `minHeight: 44`; covered in the real browser route spec.
  - Mobile (≤ 640 px): banner is `flexWrap: 'wrap'`, so the button wraps below
    the text.
  - Disabled when: the clear request is in flight.
  - Offline behavior: not queued; the DELETE will reject and the catch sets the
    failure message.
  - Telemetry: _none_ — no telemetry attributes/events in source.

### Persona card — eligible

- [x] **Choose this guide / Selecting… / Active** (`button`,
      `data-persona-select={personaId}`)
  - Function: calls `personasClient.select({ personaId, surface? })` →
    `POST /v1/personas/select`. On `result.ok && active`, sets the active guide
    and message "{displayName} is now your guide."; otherwise sets "Could not
    select {displayName}: {humanized blockers or reason}"; on throw, "Could not
    select {displayName}." (`PersonaPicker.tsx:60-84,188-196`).
  - Label is state-dependent: **Active** when this card is the active guide,
    **Selecting…** while `busyId === personaId`, else **Choose this guide**.
  - Keyboard: native `<button>` — Enter/Space activate.
  - Screen reader: explicit `aria-label` includes the persona display name;
    `aria-busy` is set during selection and `aria-disabled` mirrors the active /
    busy disabled states.
  - Touch target: `minHeight: 44`; covered in the real browser route spec.
  - Mobile (≤ 640 px): cards reflow to a single column via `auto-fill` /
    `minmax(240px, 1fr)`.
  - Disabled when: `busyId === card.personaId` (in-flight select) **or** the
    card is already active (`PersonaPicker.tsx:191`); `cursor: 'wait'` while
    busy.
  - Offline behavior: not queued; the POST rejects and the catch sets the
    failure message.
  - Telemetry: _none_ in source.

### Persona card — ineligible

- [ ] **Eligibility blockers** (`ul`, `data-persona-blockers={personaId}`)
  - Function: presentational list of `card.eligibilityBlockers[]` humanized by
    `blockerLabel()` (`PersonaPicker.tsx:14-26,198-202`). Known mappings:
    `requires-voice-avatar-consent` / `voice-avatar-consent-required` →
    "Requires voice/avatar account consent"; `tier-locked` → "Available on a
    higher plan"; `surface-unavailable` → "Not available on this surface";
    anything else → the raw blocker with hyphens replaced by spaces.
  - Keyboard: non-interactive (no select button is rendered for ineligible
    cards).
  - Screen reader: announced as a list of static items.
  - Telemetry: _none_.

### Shell nav (header)

- [ ] **OSHUN wordmark / ← Home** (`Link`s in `LCustomerNav`, `shells.tsx`) —
      both navigate to `/`; `← Home` carries `aria-label="Back to OSHUN home"`.
      **Explore / Activity / Library** nav links route to `/explore`,
      `/activity`, `/library`; `active="explore"` tints Explore. These belong to
      the shell, not this page — see
      [`shell/01-app-shell.md`](../../shell/01-app-shell.md).

## Data & contracts

- **Reads**:
  - `personasClient.browse()` → `GET /v1/personas[?surface&locale]` → returns
    `PersonaBrowseResponseDto` (`generatedAt`, `surface`, `tier`, `eligible[]`,
    `blocked[]`, `byFamily[]`)
    (`apps/oshun/web/src/personas/personasClient.ts:82-83`).
  - `personasClient.getActive()` → `GET /v1/personas/active` →
    `{ active: ActivePersonaDto | null }` (`personasClient.ts:86-87`).
- **Writes**:
  - `personasClient.select(...)` → `POST /v1/personas/select` accepts
    `PersonaSelectRequestDto` (`personaId`, optional `surface`,
    `requestedModalities`, `locale`) → `PersonaSelectResponseDto` (`result.ok`,
    `result.reason`, `result.blockers[]`, `active`) (`personasClient.ts:84-85`).
  - `personasClient.clearActive()` → `DELETE /v1/personas/active` →
    `{ cleared: boolean }` (`personasClient.ts:88-89`).
- **Realtime**: _None._
- **Caching**: `api` GET requests are deduped (5s TTL) and retried with backoff;
  no SWR/ISR/SW persistence specific to this view
  (`apps/oshun/web/src/lib/api-client.ts`).
- **Auth/role check**: enforced server-side by the BFF — the consumer persona
  routes run `createAuthPreHandler()` (+ `createAbuseProtectionPreHandler()`)
  and reply `401 { error: 'unauthorized', reason: 'missing_auth_context' }` when
  unauthenticated (`apps/oshun/bff/src/routes/personas-consumer.ts:65,88`).
  There is **no** Next.js `middleware.ts` in `apps/oshun/web/src` gating this
  route; the client surfaces the 401 as "Sign in to choose a persona."

Contracts: types are inline DTOs in `personasClient.ts` (the file notes "Mirror
of the BFF `PersonaBrowseCard` (promote to a shared contract later)") — not yet
in `libs/contracts/`.

## Cross-references

- Shell: [`shell/01-app-shell.md`](../../shell/01-app-shell.md),
  [`shell/04-auth-session.md`](../../shell/04-auth-session.md)
- Sibling persona surfaces:
  - [`profile-persona.md`](../09-account/profile-persona.md) —
    `/profile/persona` (voice catalogue)
  - [`lilith-studio-personas.md`](./lilith-studio-personas.md) —
    `/lilith-studio/personas` (studio authoring)
- Journeys:
  [`../../journeys/persona-voice-avatar-approval-workflow.md`](../../journeys/persona-voice-avatar-approval-workflow.md)
- Component sources:
  - `apps/oshun/web/src/app/personas/page.tsx`
  - `apps/oshun/web/src/components/personas/PersonaPicker.tsx`
  - `apps/oshun/web/src/personas/personasClient.ts`
  - `apps/oshun/web/src/design-system/lilith/shells.tsx` (LWebShell,
    LCustomerNav), `…/primitives.tsx` (LMasthead)
- BFF route: `apps/oshun/bff/src/routes/personas-consumer.ts`
- Existing E2E: `apps/oshun/web/e2e/personas-picker.spec.ts` — page-DOM coverage
  including a live browser→BFF flow for pre-consent blocked roster rendering,
  real voice consent unlock, real select, active read-back after reload, and
  clear persistence, with measured select/clear tap targets and ARIA labels;
  deterministic route-stubbed branches cover loading, recoverable load retry,
  true empty catalogue, BFF 401, and select 500 UI states.
  `apps/oshun/web/e2e/persona-session-picker.spec.ts` — pure BFF round-trip for
  consent gate → grant voice → browse → select → active → clear plus
  unauthenticated 401s.

## Open questions / known gaps

- [ ] No offline banner / queued writes; offline load maps to the generic error
      message and select/clear simply reject.
- [ ] No telemetry attributes/events on the select / clear / card interactions
      (contrast with Tara's `data-telemetry-*` attributes).
- [ ] Entry points into `/personas` from other surfaces are not enumerated —
      grep for inbound `href="/personas"` during a live walk.
- [ ] DTOs are inlined in `personasClient.ts` rather than `libs/contracts/`;
      track the "promote to a shared contract later" note.
