---
path: /profile/notifications
surface: customer
domain: account
auth: signed-in
source: apps/oshun/web/src/app/profile/notifications/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'
  '2026-06-25 focused deepening — added and verified the read-only summary →
  `/profile?path=notifications` delivery-editor handoff with real BFF data in
  `profile-notifications-index`; route doc reconciled with shipped quiet-hours
  editor location.'
  '2026-06-25 focused mobile deepening — channel grid changed from fixed 2-col
  to auto-fit minmax columns and verified at 390px viewport with no horizontal
  overflow in `profile-notifications-index`.'
  '2026-06-27 focused route-state deepening — real-BFF channel/category enabled,
  disabled, address, cadence, subscribed/off variants, footer href/navigation,
  anonymous redirect, and 44px footer tap targets verified in
  `profile-notifications-index`.'
  '2026-06-29 focused PWA/offline deepening — `/profile/notifications` promoted
  to an exact service-worker shell navigation and verified with standalone
  mobile launch plus real service-worker cached-document replay in
  `profile-notifications-index`.'
  '2026-06-29 focused footer telemetry deepening — all four footer actions and
  the rendered footer action container verified to expose no telemetry data
  hooks in `profile-notifications-index`.'
  '2026-06-30 focused real-projection deepening — `/v1/profile/notifications`
  moved from a static guarded fixture to an authenticated profile/preferences
  projection; `profile-notifications-index` now plants the real access token,
  asserts the live BFF summary, flips the real push preference through
  `/v1/preferences`, verifies the route readback, and covers the fail-closed
  empty copy with an invalid account bearer.'
  '2026-06-30 focused Arete weekly-review deepening — `/v1/profile/notifications`
  now projects an `arete-weekly-review` category from the real Arete domain
  reminder preference; `profile-notifications-index` patches
  `/v1/notifications/preferences/domains/arete`, reloads this page, and verifies
  the weekly category turns off while general reminders remain on.'
---

# Profile · Notifications

## Purpose

The "how Lilith reaches you" surface — "the quiet defaults." Renders delivery
channels (with their configured addresses and on/off state) and notification
categories (each with a cadence and subscription state), sourced from
`/v1/profile/notifications`. That BFF route is now an authenticated projection
of the customer profile store, notification-preferences store, and Telegram link
store rather than a static fixture. The page header states OSHUN never sends
marketing, and channel/category states render as static reads. The mutable
delivery surface lives in the Profile settings panel at
`/profile?path=notifications`; this page now provides the explicit "Change
delivery settings" handoff into that editor.

## Entry points

- Cross-domain footer button from `/profile/data` ("Notifications")
- Cross-domain footer button from `/profile/telegram` ("Notifications")
- "Back to profile" / sub-route 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`. Two `<section>`s below the masthead.

- **Header**: `LCustomerNav` (`active="library"`)
- **Masthead**: `LMasthead` — left `Profile · notifications`, right dynamic
  `${enabledChannelCount} channel(s) on`, kicker "How OSHUN reaches you", italic
  title "The _quiet_ defaults."
- **Intro**: `LEyebrow` "How this opens" + "OSHUN never sends marketing…"
- **Channels section**: `LEyebrow` "Channels" + an auto-fit responsive grid of
  channel cards (label, on/off badge, address-or-"not configured"); desktop can
  show two columns, phone widths collapse to one column; empty readback renders
  `[data-profile-notifications-empty="channels"]`
- **Categories section**: `LEyebrow` "Categories" + an ordered list of category
  cards (cadence caption, label, subscribed/off badge, sub-description); empty
  readback renders `[data-profile-notifications-empty="categories"]`
- **Footer / actions**: four `LBtn`s — "← Back to profile" (ghost), "Persona
  voice" (ghost), "Change delivery settings" (primary,
  `/profile?path=notifications`), "Data rights" (ghost)

## States

- [ ] **Loading** — single `bffGet` blocks server render; no client skeleton
- [x] **Empty (both)** — `bffGet` null → fallback
      `{ channels: [], categories: [] }`; the masthead right reads "0 channel(s)
      on"; route root exposes `data-profile-notifications-read-state="empty"`,
      channel/category counts of `0`, and dedicated empty copy for both
      sections. E2E reaches this by preserving the web session but replacing the
      forwarded `oshun-access` bearer with an invalid token so the live BFF
      rejects the account-scoped read.
- [x] **Populated (short)** — a handful of channels/categories in their grids
      are rendered from the real authenticated `/v1/profile/notifications`
      projection.
- [ ] **Populated (long)** — many categories: plain ordered list, no pagination
- [x] **Channel enabled vs disabled** — enabled card: `L.paper` bg, italic
      label, accent "● on"; disabled: transparent bg, normal label, muted "○
      off". E2E flips `notifications.pushEnabled` through real
      `PATCH /v1/preferences`, then reloads `/profile/notifications` and sees
      the Push row switch to disabled with "not configured".
- [x] **Channel address present vs missing** — shows `channel.address` or italic
      "not configured"
- [x] **Category subscribed vs off** — accent "● subscribed" vs muted "○ off";
      cadence caption is one of `daily | weekly | monthly | on-event`
  - E2E: `profile-notifications-index.spec.ts` asserts all four real BFF
    channels (`email`, `push`, `sms`, `telegram`) and all five categories
    (`gentle-reminders`, `arete-weekly-review`, `coach-letters`,
    `crisis-checkins`, `product-changes`) with their enabled/off attributes,
    visible badges, addresses / `not configured`, and cadence labels.
- [x] **Error (recoverable)** — N/A: `bffGet` returns null on failure → empty
      fallback (quiet empty, no retry UI)
- [x] **Offline** — the real service worker treats `/profile/notifications` as
      an exact shell-navigation document, warms the BFF-backed HTML while
      online, and replays the same channel/category summary while
      `navigator.onLine === false`; the generic offline fallback card/marker is
      absent
- [x] **Gated / anonymous** — `proxy.ts` redirect to `/welcome` before render;
      E2E clears cookies and verifies
      `/welcome?redirect=%2Fprofile%2Fnotifications`.
- [x] **Standalone PWA** — 390 px standalone launch verifies display-mode,
      real-BFF channel/category rows, footer hrefs, 44 px footer links, and no
      horizontal overflow

## Interactions

The channel and category cards are **read-only** — no toggle inputs exist in the
JSX; on/off and subscribed/off are rendered as text/badge state only. The
interactive elements are footer navigation buttons, including the primary
handoff to the mutable notification-preferences editor.

- [x] **← Back to profile** (LBtn ghost, href `/profile`)
  - Function: navigates to the account hub
  - Screen reader: announces button text; rendered as `<a>`
- [x] **Persona voice** (LBtn ghost, href `/profile/persona`)
  - Function: navigates to the voice catalogue
- [x] **Change delivery settings** (LBtn primary, href
      `/profile?path=notifications`)
  - Function: navigates to `ProfileSettingsPanel` with the Notifications section
    open; that editor owns quiet-hours, digest cadence, and per-domain modality
    controls
  - Test hook: wrapper `[data-profile-notifications-edit-link]`
- [x] **Data rights** (LBtn ghost, href `/profile/data`)
  - Function: navigates to the DSAR / data-rights surface
  - Keyboard: in footer tab order; Enter activates
  - Touch target: route-local footer action style sets 44 px height/min-height;
    E2E verifies every footer link measures ≥ 44 px.
  - Mobile (≤ 640 px): footer row wraps (`flexWrap: 'wrap'`)
  - E2E: `profile-notifications-index.spec.ts` verifies the footer hrefs, then
    clicks Back to profile, Persona voice, and Data rights from the real route
    and waits for each sibling route marker. It also asserts the footer
    container and all four footer links expose no `data-telemetry-event`,
    `data-analytics-event`, or `data-event` hooks in the normal browser route
    and standalone PWA launch.

## Data & contracts

- **Reads**: `bffGet('/v1/profile/notifications')` →
  `NotificationsResponse { generatedAt, userId, channels: [{ id, label, enabled, address: string|null }], categories: [{ id, label, sub, enabled, cadence: 'daily'|'weekly'|'monthly'|'on-event' }] }`
  - `email` address comes from the authenticated profile record.
  - `push` enabled/address comes from `preferences.notifications.pushEnabled`.
  - `telegram` enabled/address comes from `telegramUserStateStore` when linked.
  - categories summarize the notification-preferences store; the
    `arete-weekly-review` category is backed by the Arete domain reminder kind
    and delivery modalities, while product-change marketing remains off by
    design.
- **Writes**: none from this page. The write surface is the client-side
  notification editor at `/profile?path=notifications`, backed by
  `GET/PATCH /v1/notifications/preferences` and
  `PATCH /v1/notifications/preferences/domains/:domainId`
- **Realtime**: none
- **Caching**: authenticated `bffGet` forwards `oshun-access` as a bearer and
  uses `cache: 'no-store'`; `/profile/notifications` 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 summary rather than the generic fallback
- **Auth/role check**: `proxy.ts` protected-by-default; no-session → `/welcome`
- **Metadata**: `title: 'Profile · notifications'`, description re channels /
  cadence / crisis check-ins honoring explicit asks,
  `alternates.canonical: '/profile/notifications'`

## Cross-references

- Feature spec: [`V1/features.md#account`](../../../V1/features.md#account)
- Architecture:
  [`V1/ARCHITECTURE.md#account`](../../../V1/ARCHITECTURE.md#account)
- Journeys:
  [`../../journeys/messages-quiet-hours-and-channel-binding.md`](../../journeys/messages-quiet-hours-and-channel-binding.md)
- Sibling routes:
  - [`profile.md`](./profile.md), [`profile-data.md`](./profile-data.md),
    [`profile-persona.md`](./profile-persona.md),
    [`profile-telegram.md`](./profile-telegram.md),
    [`profile-safety.md`](./profile-safety.md)
- Component sources:
  - `apps/oshun/web/src/app/profile/notifications/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 view is intentionally read-only; the user changes quiet-hours, digest
      cadence, and per-domain modality preferences in
      `/profile?path=notifications`, now reachable through "Change delivery
      settings" and covered by `profile-notifications-index`.
- [x] Quiet-hours UI lives in `/profile?path=notifications`, not this static
      index page; `profile-notifications-index` proves the handoff and reveals
      the two time inputs, while `profile-notification-preferences` and
      `notifications-preferences-api` cover the editor and BFF persistence.
- [x] Empty channels/categories render on-brand empty copy in both sections and
      preserve the delivery-settings handoff.
- [x] No telemetry on footer navigation: `profile-notifications-index` asserts
      the footer action container, all four footer links, and footer descendants
      expose no `data-telemetry-event`, `data-analytics-event`, or `data-event`
      hooks.
- [x] Channel grid collapse on `≤ 640 px` — changed to
      `repeat(auto-fit, minmax(min(240px, 100%), 1fr))` and covered by
      `profile-notifications-index` at a 390 px viewport with no horizontal
      overflow.
- [x] Arete weekly-review reminder category appears in the account notification
      read index and follows the real Arete domain reminder preference; the
      actual Sunday push/in-app scheduler remains covered as a separate Arete
      journey gap.

## E2E coverage

- `apps/oshun/web/e2e/profile-notifications-index.spec.ts` — real browser + real
  dev BFF. Plants the real access token cookie for the server-rendered BFF read,
  loads `/profile/notifications`, asserts the authenticated BFF summary and
  email address, follows "Change delivery settings" to
  `/profile?path=notifications` and reveals the quiet-hours time inputs, flips
  the real profile push preference through `/v1/preferences` and verifies Push
  disables on reload, patches the real Arete domain reminder preference through
  `/v1/notifications/preferences/domains/arete` and verifies the
  `arete-weekly-review` category turns off on reload, covers the invalid-bearer
  fail-closed empty copy, asserts every BFF-backed channel/category state
  variant, verifies footer hrefs, absence of footer telemetry data hooks,
  sibling-route navigation, anonymous redirect preservation, 44 px footer tap
  targets, then verifies the channel cards stack at a 390 px viewport without
  horizontal overflow. The spec also verifies standalone display-mode launch at
  390 px, repeats the footer no-telemetry assertion there, and proves
  real-service-worker cached-document replay while offline with the same
  BFF-backed rows visible and no generic offline fallback marker.
- Related editor coverage:
  `apps/oshun/web/e2e/profile-notification-preferences.spec.ts` and
  `apps/oshun/web/e2e/notifications-preferences-api.spec.ts`.
- BFF coverage:
  `apps/oshun/bff/src/__tests__/notifications-preferences-route.test.ts`
  verifies `/v1/profile/notifications` is authenticated and projects profile
  email plus notification preferences instead of serving a fixture.
