Context. surface customer · domain account · route /profile/persona · auth signed-in · source apps/oshun/web/src/app/profile/persona/page.tsx
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
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.tsno-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— leftProfile · 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 suppliespreviewUrlor the honest "Preview sample is not available from the voice registry yet" state. - Footer / actions: three
LBtns — "← Back to profile" (ghost), "Telegram" (ghost), "Data rights" (primary)
States#
- Loading —
bffGet('/v1/persona/voice')blocks server render; no client skeleton - Empty —
voices.length === 0from a real no-consent member orbffGetnull fallback → dashed card explaining that voice/avatar consent unlocks eligible launch voices. - 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.
- Active voice —
voice.voiceId === activeVoiceId→ accent border, italic label, accent "● active" badge - Inactive voice — rule border, normal label, muted "○ available" badge
- Provider / provenance state surfaced — caption and metadata expose
providerId,engineId,voiceProfileId, locales, watermark algorithm, and spoken-disclosure requirement from the real registry assignment. - Error (recoverable) — N/A:
bffGetreturns null on failure → empty fallback (quiet empty) - Offline — the real service worker treats
/profile/personaas an exact shell-navigation document, warms the BFF-backed populated voice catalogue while online, and replays the same active voice readback whilenavigator.onLine === false; the generic offline fallback card/marker is absent - Gated / anonymous —
proxy.tsredirect to/welcomebefore render - 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. - ← Back to profile (LBtn ghost, href
/profile) → account hub - Telegram (LBtn ghost, href
/profile/telegram) → Telegram bridge - 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.tsverifies 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 nodata-telemetry-event,data-analytics-event, ordata-eventhooks in the normal browser route, mobile viewport, and standalone PWA launch.
- Keyboard: footer tab order; Enter activates; rendered as
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/selectin the/personaspicker flow, and this route reads it back throughactiveVoiceId. - Realtime: none
- Caching: authenticated
bffGetusescache: 'no-store';/profile/personais an exact shell navigation inpublic/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.tsprotected-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 - Architecture:
V1/ARCHITECTURE.md#account - Sibling routes:
- Component sources:
apps/oshun/web/src/app/profile/persona/page.tsxapps/oshun/web/src/design-system/lilith/shells.tsxapps/oshun/web/src/lib/server/bff-fetch.ts(bffGet)
Open questions / known gaps#
- The stale "approved" masthead count was corrected to
${voices.length} available. - The stale "switch" badge was corrected to non-actionable "available";
switching belongs to
/personas. - 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.
- No telemetry on footer navigation:
profile-persona-readbackasserts the footer action container, all three footer links, and footer descendants expose nodata-telemetry-event,data-analytics-event, ordata-eventhooks.