---
path: /profile/persona
surface: customer
domain: account
auth: signed-in
source: apps/oshun/web/src/app/profile/persona/page.tsx
status: walked
last_walked:
  '2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data
  (2xx), console/page-errors, expected content, screenshot verified; live
  screen-reader, touch, offline, and telemetry-delivery checks pending a manual
  AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md'
last_verified:
  '2026-06-25 real-dev-infra route journey — `profile-persona-readback.spec.ts`
  seeds customer sessions through the dev auth helper, grants real voice consent
  through `/v1/consent/voice/grant`, selects a live persona through
  `/v1/personas/select`, compares the server-rendered route with
  `/v1/persona/voice`, asserts anonymous redirect and mobile touch/no-overflow,
  and runs the shared axe gate.'
  '2026-06-27 focused footer deepening — `profile-persona-readback.spec.ts`
  now reopens `/profile/persona` and follows every footer link to the sibling
  account route markers for `/profile`, `/profile/telegram`, and
  `/profile/data` under a seeded real-dev session.'
  '2026-06-29 focused PWA/offline deepening — `/profile/persona` promoted to an
  exact service-worker shell navigation and verified with standalone mobile
  launch plus real service-worker cached-document replay in
  `profile-persona-readback.spec.ts`.'
  '2026-06-29 focused footer telemetry deepening — the rendered footer action
  container and all three footer links verified to expose no telemetry data
  hooks in `profile-persona-readback.spec.ts`.'
---

# Profile · Persona voice

## Purpose

The "how OSHUN sounds" readback surface — the catalogue of eligible persona
voices the current member can use. It lists each voice from `/v1/persona/voice`
with registry-derived provider, engine, locale, voice-profile, and provenance
metadata, and marks the active one. Voice switching is performed by the
`/personas` picker flow; this route verifies what the account currently reads
back from the same BFF state.

## Entry points

- Cross-domain footer button from `/profile/notifications` ("Persona voice")
- Cross-domain footer button from `/profile/telegram` (via Notifications) and
  "Back to profile" navigation from `/profile`
- Direct URL / bookmark — yes; auth required (`proxy.ts` no-session →
  `/welcome`)

## Layout regions

Async server component in `<LWebShell top={<LCustomerNav active="library" />}>`,
`maxWidth: 880`. A vertical grid of voice cards below the masthead.

- **Header**: `LCustomerNav` (`active="library"`)
- **Masthead**: `LMasthead` — left `Profile · voice`, right dynamic
  `${voices.length} available`, kicker "How OSHUN sounds", italic title "The
  _voice_ that reads."
- **Intro**: `LEyebrow` "How this opens" + registry-backed / consent-gated
  account-state explanation.
- **Main**: grid of voice cards. Each card: provider/kind/default-locale mono
  caption, serif label, active/available badge, italic description, metadata
  grid (`Family`, `Engine`, `Profile`, `Locales`, `Provenance`), and either a
  future `<audio>` preview if the BFF supplies `previewUrl` or the honest
  "Preview sample is not available from the voice registry yet" state.
- **Footer / actions**: three `LBtn`s — "← Back to profile" (ghost), "Telegram"
  (ghost), "Data rights" (primary)

## States

- [x] **Loading** — `bffGet('/v1/persona/voice')` blocks server render; no
      client skeleton
- [x] **Empty** — `voices.length === 0` from a real no-consent member or
      `bffGet` null fallback → dashed card explaining that voice/avatar consent
      unlocks eligible launch voices.
- [x] **Populated (short)** — real voice consent returns eligible registry voice
      cards in a vertical grid.
- [ ] **Populated (long)** — many voices: plain stacked grid, no pagination.
      Current launch roster does not expose enough default-surface voices to
      prove a long list without test-only registry injection.
- [x] **Active voice** — `voice.voiceId === activeVoiceId` → accent border,
      italic label, accent "● active" badge
- [x] **Inactive voice** — rule border, normal label, muted "○ available" badge
- [x] **Provider / provenance state surfaced** — caption and metadata expose
      `providerId`, `engineId`, `voiceProfileId`, locales, watermark algorithm,
      and spoken-disclosure requirement from the real registry assignment.
- [x] **Error (recoverable)** — N/A: `bffGet` returns null on failure → empty
      fallback (quiet empty)
- [x] **Offline** — the real service worker treats `/profile/persona` as an
      exact shell-navigation document, warms the BFF-backed populated voice
      catalogue while online, and replays the same active voice readback while
      `navigator.onLine === false`; the generic offline fallback card/marker is
      absent
- [x] **Gated / anonymous** — `proxy.ts` redirect to `/welcome` before render
- [x] **Standalone PWA** — 390 px standalone launch verifies display-mode, live
      BFF voice catalogue rows, active voice metadata, unavailable preview row,
      44 px footer/preview targets, and no horizontal overflow

## Interactions

The voice cards are readback only. The inactive badge is text ("○ available"),
not a switch control. The route's interactive elements are the three footer
links; preview media remains a product gap because the registry/BFF has no
sample URL.

- [ ] **Voice preview player** — future path only. The BFF now explicitly
      returns `previewUrl: null`; the page renders a 44px "preview unavailable"
      row instead of fabricating an audio source.
- [x] **← Back to profile** (LBtn ghost, href `/profile`) → account hub
- [x] **Telegram** (LBtn ghost, href `/profile/telegram`) → Telegram bridge
- [x] **Data rights** (LBtn primary, href `/profile/data`) → DSAR surface
  - Keyboard: footer tab order; Enter activates; rendered as `<a>`.
  - Touch target: route style forces the medium buttons to 44px; verified at
    390×844 with no horizontal overflow.
  - E2E: `profile-persona-readback.spec.ts` verifies every footer target href on
    mobile, then follows Back to profile, Telegram, and Data rights from the
    readback surface and waits for each sibling route marker. It also asserts
    the footer action container, all three footer links, and footer descendants
    expose no `data-telemetry-event`, `data-analytics-event`, or `data-event`
    hooks in the normal browser route, mobile viewport, and standalone PWA
    launch.

## Data & contracts

- **Reads**: `bffGet('/v1/persona/voice')` →
  `VoiceResponse { generatedAt, activeVoiceId?, voices: [{ voiceId, label, description, familyLabel, premiumCast, voiceProfileId, providerId, engineId, voiceKind, defaultLocale, supportedLocales, watermarkAlgorithm, requiresSpokenSynthesisDisclosure, previewUrl: null|string }] }`
- **Writes**: none from this page. Active voice is written by
  `POST /v1/personas/select` in the `/personas` picker flow, and this route
  reads it back through `activeVoiceId`.
- **Realtime**: none
- **Caching**: authenticated `bffGet` uses `cache: 'no-store'`;
  `/profile/persona` is an exact shell navigation in `public/sw.js`, so an
  authenticated online navigation writes the rendered document into the static
  SW cache and offline navigation replays that cached voice catalogue rather
  than the generic fallback. Anonymous or failed reads collapse to the quiet
  empty fallback.
- **Auth/role check**: `proxy.ts` protected-by-default; no-session → `/welcome`
- **Metadata**: `title: 'Profile · persona voice'`, description re provider /
  engine / locale / provenance, `alternates.canonical: '/profile/persona'`

## E2E coverage

- **Spec**: `apps/oshun/web/e2e/profile-persona-readback.spec.ts`
- **Depth**: partial. Covered: no-consent empty state, live consent grant,
  populated registry metadata, active-voice readback after real BFF selection,
  anonymous redirect, footer handoffs to `/profile`, `/profile/telegram`, and
  `/profile/data`, absence of footer telemetry data hooks, mobile 44px
  controls/no horizontal overflow, standalone display-mode launch, real
  service-worker cached-document replay while offline, no generic offline
  fallback marker, and shared axe gate. Not covered: long-list roster and actual
  preview playback because preview media is not yet represented in the real
  registry/BFF contract.

## Cross-references

- Feature spec: [`V1/features.md#account`](../../../V1/features.md#account)
- Architecture:
  [`V1/ARCHITECTURE.md#account`](../../../V1/ARCHITECTURE.md#account)
- Sibling routes:
  - [`profile.md`](./profile.md),
    [`profile-notifications.md`](./profile-notifications.md),
    [`profile-telegram.md`](./profile-telegram.md),
    [`profile-data.md`](./profile-data.md)
- Component sources:
  - `apps/oshun/web/src/app/profile/persona/page.tsx`
  - `apps/oshun/web/src/design-system/lilith/shells.tsx`
  - `apps/oshun/web/src/lib/server/bff-fetch.ts` (`bffGet`)

## Open questions / known gaps

- [x] The stale "approved" masthead count was corrected to
      `${voices.length} available`.
- [x] The stale "switch" badge was corrected to non-actionable "available";
      switching belongs to `/personas`.
- [x] The stale audio player was removed for null preview URLs; the route now
      renders an explicit unavailable state instead of a broken control.
- [ ] The registry/BFF needs a real preview-media contract before this route can
      expose an actual audio player and before preview playback telemetry can be
      meaningfully tested.
- [x] No telemetry on footer navigation: `profile-persona-readback` asserts the
      footer action container, all three footer links, and footer descendants
      expose no `data-telemetry-event`, `data-analytics-event`, or `data-event`
      hooks.
