Context. surface customer · domain lilith · route /personas · auth signed-in · source apps/oshun/web/src/app/personas/page.tsx
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
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/personaslink (the three customer nav items areExplore/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 theOSHUNwordmark home link and a← Homearia-label="Back to OSHUN home"link. - Masthead:
LMasthead(apps/oshun/web/src/design-system/lilith/primitives.tsx) withleft="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) — adata-persona-pickergrid 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 politerole="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 frombrowse.byFamily[]plus top-levelbrowse.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).
- Active-guide banner (
- Footer / sticky bottom: None. (no footer rendered by this page).
States#
- Loading —
data-persona-loading"Loading guides…" renders inside a fixed-height status panel withrole="status"/aria-live="polite"; no empty or no-eligible copy renders while the first browse is pending. - Empty — no eligible, blocked, or grouped cards (
families = []) now renders a distinctdata-persona-emptynotice: "No guide catalogue is available right now." The consent-blocked state remains separate viadata-persona-none-eligible. - Populated (short) — 1–5 cards across one or more
data-persona-familygroups; expected grid layout. - Populated (long) — 50+ cards: the
auto-fillCSS grid reflows; there is no pagination, virtualization, or infinite scroll in the component (PersonaPicker.tsx:167) — all cards render at once. - Error (recoverable) — load failure surfaces
data-persona-error"Could not load personas right now." withrole="alert"and adata-persona-error-retry"Try again" button. The retry re-runsbrowse()+getActive()and can recover to the catalogue without a page reload. - 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 —
personasClientcalls go through@/lib/api-client, whose network failures raiseApiRequestErrorwithstatus: 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. - Gated — two distinct gates:
- Sessionless route access:
proxy.tsredirects anonymous/personasrequests 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-blockerslist with humanized reasons (see Interactions); the blocker is shown, never silently hidden.
- Sessionless route access:
- 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#
- Clear (
button,data-persona-clear)- Function: calls
personasClient.clearActive()→DELETE /v1/personas/active; on success clears localactiveand 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-busywhile 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.
- Function: calls
Persona card — eligible#
- Choose this guide / Selecting… / Active (
button,data-persona-select={personaId})- Function: calls
personasClient.select({ personaId, surface? })→POST /v1/personas/select. Onresult.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-labelincludes the persona display name;aria-busyis set during selection andaria-disabledmirrors 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.
- Function: calls
Persona card — ineligible#
- Eligibility blockers (
ul,data-persona-blockers={personaId})- Function: presentational list of
card.eligibilityBlockers[]humanized byblockerLabel()(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.
- Function: presentational list of
Shell nav (header)#
- OSHUN wordmark / ← Home (
Links inLCustomerNav,shells.tsx) — both navigate to/;← Homecarriesaria-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 — seeshell/01-app-shell.md.
Data & contracts#
- Reads:
personasClient.browse()→GET /v1/personas[?surface&locale]→ returnsPersonaBrowseResponseDto(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/selectacceptsPersonaSelectRequestDto(personaId, optionalsurface,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:
apiGET 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 reply401 { error: 'unauthorized', reason: 'missing_auth_context' }when unauthenticated (apps/oshun/bff/src/routes/personas-consumer.ts:65,88). There is no Next.jsmiddleware.tsinapps/oshun/web/srcgating 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/04-auth-session.md - Sibling persona surfaces:
profile-persona.md—/profile/persona(voice catalogue)lilith-studio-personas.md—/lilith-studio/personas(studio authoring)
- Journeys:
../../journeys/persona-voice-avatar-approval-workflow.md - Component sources:
apps/oshun/web/src/app/personas/page.tsxapps/oshun/web/src/components/personas/PersonaPicker.tsxapps/oshun/web/src/personas/personasClient.tsapps/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
/personasfrom other surfaces are not enumerated — grep for inboundhref="/personas"during a live walk. - DTOs are inlined in
personasClient.tsrather thanlibs/contracts/; track the "promote to a shared contract later" note.