V1 Web PWA · Surface walkthrough

Your guides · persona picker

A per-surface walkthrough of the V1 Web PWA customer surface: layout, states, interactions, data, and cross-references.

walked
8sections8 minread

On this page

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 /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#

  • Loadingdata-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.
  • 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.
  • 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.
  • 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.
  • 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.
  • OfflinepersonasClient 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.
  • 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#

  • 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#

  • 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 (Links 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.

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/04-auth-session.md
  • Sibling persona surfaces:
  • Journeys: ../../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.