V1 Web PWA · Surface walkthrough

Profile · Persona voice

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

walked
9sections5 minread

On this page

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.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 LBtns — "← Back to profile" (ghost), "Telegram" (ghost), "Data rights" (primary)

States#

  • LoadingbffGet('/v1/persona/voice') blocks server render; no client skeleton
  • Emptyvoices.length === 0 from a real no-consent member or bffGet null 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 voicevoice.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: bffGet returns null on failure → empty fallback (quiet empty)
  • 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
  • Gated / anonymousproxy.ts redirect to /welcome before 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.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#

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-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.