---
path: /notifications
surface: customer
domain: shell-routing
auth: signed-in
source: apps/oshun/web/src/app/notifications/page.tsx
status: walked
last_walked:
  '2026-06-27 by Codex (Activity-owned entry-route intent covered across desktop/mobile nav and manifest), against commit HEAD'
  '2026-06-25 by Codex (loading/empty states, proxy gate, and tap targets covered), against commit HEAD'
  '2026-06-24 by Codex (bulk/dismiss write paths covered), against commit HEAD'
---

# Notifications center

## Purpose

The full-page notification inbox: mixed-domain reminders/updates/events, support
replies, study reminders, grounding updates, privacy alerts, and admin
escalations, triaged by urgency, lane, and domain. It is the standalone-route
counterpart to the global header drawer (`ShellLayout` renders the same
`NotificationsCenterPanel`), and it routes each item to its follow-up surface (a
domain hub, `/profile`, or `/studio`).

## Entry points

- **Breadcrumb chain** — `page.tsx` mounts `ShellLayout active="activity"` with
  breadcrumbs `Home → Activity → Notifications` (`WEB_SHELL_ROUTE_PATHS.home`,
  `.activity`, then a non-linked `Notifications` crumb).
- **Primary shell navigation state** — `/notifications` is intentionally an
  Activity-owned entry route, not a top-level shell destination. The desktop
  sidebar and mobile bottom nav keep `[data-nav-id="activity"]` active and do
  not render a `[data-nav-id="notifications"]` item; `manifest.json`
  `shortcuts[]` likewise has no `/notifications` shortcut.
- **Global header notification drawer** — the shell header bell
  (`[data-notification-bell] [aria-label="Notifications"]`) opens
  `[data-shell-notifications-drawer]` rendering the same
  `NotificationsCenterPanel variant="full"` (`ShellLayout.tsx` lines 598–702);
  the drawer is the dominant in-app entry, the `/notifications` route is its
  full-page sibling.
- **Home preview** —
  `[data-notifications-panel][data-notifications-variant="preview"]` on `/`
  renders the calm preview variant; its items deep-link onward, not to this
  route directly (see `home.md`).
- **Direct URL / bookmark** — yes, auth required. `page.tsx` has no auth guard
  of its own; it relies on the customer shell. `metadata.robots` is
  `{ index: false, follow: false }` (noindex).
- **Shell state preview** — `?shellState=<mode>` swaps the live panel for
  `ShellSurfaceStatePreview surface="notifications"` via
  `resolveShellRouteSurfaceContent({ surface: 'notifications' })`
  (`shellRouteState.tsx`); only `shellState=live` renders the real panel.

## Layout regions

`page.tsx` is a thin server component: `ShellLayout active="activity"` wrapping
a `<section data-shell-entry-route="notifications" style={maxWidth:1040}>` that
renders `<NotificationsCenterPanel variant="full" origin="activity" />`. All
substantive UI lives in
`apps/oshun/web/src/components/NotificationsCenterPanel.tsx` (the `'use client'`
panel).

- **Header** (`[data-notifications-header]`): `<Bell>` icon + "Notifications"
  `<h2>`; an unread count badge `[data-notifications-badge]` (shown only when
  `counts.unread > 0`); and a right-aligned **"Mark all read"** button
  `[data-notifications-mark-all]` (`<CheckCheck>` icon). In `variant="preview"`
  the button is replaced by a "Home preview" pill
  `[data-notifications-preview-badge]` — not reachable on this route, which is
  always `variant="full"`.
- **Stats row** (`[data-notifications-stats]`): five `[data-stat-tile]` tiles —
  **Unread, Priority, Support, Admin, Privacy/Safety** — each a mono-font count
  over a label.
- **Inbox controls** (`WorkspaceToolbar`, `[data-notifications-control-strip]`):
  summary copy ("Inbox controls" / "Triage by urgency, lane, and domain…"); a
  `[data-notifications-count]` "{N} visible" status badge; a
  `[data-notifications-filter-reset]` "Reset filters" button (only when filters
  are active); and three `WorkspaceControlGroup`s wrapping segmented
  `FilterTabs`:
  - **Inbox** (`[data-filter-tabs="inbox"]`): All · Unread · Priority
  - **Lane** (`[data-filter-tabs="lane"]`): All lanes · Mixed · Admin · Support
    · Study · Grounding · Privacy · Safety
  - **Domain** (`[data-filter-tabs="domain"]`): All domains · Tara · Veritas ·
    Nyx · Arete · Nisaba · Metis
- **Outage notice** (`[data-notifications-unavailable]`, `role="status"`):
  rendered when `feedState === 'unavailable'` — "Notifications are unavailable
  right now — the live feed could not be reached. Nothing is shown rather than
  showing you example alerts."
- **Empty state** (`[data-notifications-empty]`): `<Bell size={48}>` icon, "All
  caught up!" `<h3>`, and "No notifications match your current filters." — shown
  when there are no grouped items and the feed is not `unavailable`.
- **Notification sections** (`[data-notifications-sections]`): two grouped
  sections in full mode — `[data-section-label="Action needed"]` (unread or
  high-priority items) and `[data-section-label="Recent updates"]` (the rest),
  each with a `[data-section-heading]` and a column of `[data-notification]`
  cards. Empty sections are dropped.
- **Pagination footer** (`[data-notifications-pagination]`): only shown when the
  BFF reports more than one page. It includes `[data-notifications-page-status]`
  ("{loaded} of {total} loaded") and, while `pagination.hasMore`, a
  `[data-notifications-load-more]` button that fetches the BFF `nextCursor`.
- **Footer**: shell footer (legal / status links) via `ShellLayout`.

### Notification card (`[data-notification]`)

Each card carries `data-notification-id`, `-read`, `-domain`, `-surface`,
`-lane`, `-variant`. Body: a full-width `[data-notification-open]` button
wrapping the domain icon, a `LaneIcon`, a lane pill
(`[data-notification-lane-pill]`), an optional
`[data-notification-domain-label]`, a `[data-notification-domain-count]` "+N"
when `domains.length > 1`, a "Priority" badge `[data-notification-priority]`
(high priority only), an unread dot `[data-notification-unread-dot]`, a relative
timestamp `[data-notification-time]`, a `[data-notification-title]` `<h4>`, a
`[data-notification-body]` `<p>`, a `[data-notification-kind]` eyebrow, and a
"Next: {action.label}" hint `[data-notification-action-label]`. On hover/focus
in full mode, an actions cluster `[data-notification-actions]` exposes
**toggle-read** and **dismiss** buttons.

## States

- [x] **Loading** — initial `feedState === 'loading'` (`items` empty, fetch
      in-flight) renders `[data-notifications-loading]` with `role="status"`,
      and the empty state stays hidden until the feed resolves.
- [x] **Empty** — fetch returns `{ notifications: [] }` (or all items
      filtered/dismissed): `[data-notifications-empty]` "All caught up!" with
      "No notifications match your current filters." (full-mode copy).
- [x] **Populated (short)** — 1–5 items: cards render under "Action needed" /
      "Recent updates" sections; stat tiles and "{N} visible" reflect counts.
- [x] **Populated (long)** — 50+ items: first BFF page renders with
      `[data-notifications-page-status]` ("30 of 55 loaded" in the route spec)
      and `[data-notifications-load-more]`. Clicking "Load more" requests
      `GET /v1/notifications?cursor=<nextCursor>&limit=30`, appends unique
      notifications without replacing page 1, updates the status to the full
      total, and removes the button when `pagination.hasMore === false`.
- [x] **Error (recoverable)** — fetch `!res.ok` or throws →
      `feedState='unavailable'`, `items=[]`, and the
      `[data-notifications-unavailable]` `role="status"` notice renders with a
      `[data-notifications-retry]` button. Clicking retry reissues the same live
      `GET /v1/notifications` request, returns to the loading state, and renders
      recovered cards when the feed succeeds.
- [x] **Error (unrecoverable)** — N/A because the panel coerces every non-OK
      response and every thrown error into the same `unavailable` disclosure (no
      distinct 403/500 branch in `NotificationsCenterPanel.tsx`). A 401/403 from
      the BFF surfaces as the generic unavailable notice. The
      `ShellSurfaceStatePreview surface="notifications"` error copy ("The
      notification center could not assemble its BFF snapshot…") is only
      reachable via `?shellState=error`, not from a live failure.
- [ ] **Offline** — `fetch` rejects → caught → `feedState='unavailable'`; same
      disclosure notice. The panel has **no** offline-specific banner, queue, or
      SW-cached read; writes are fire-and-forget and `.catch(console.error)`.
      Offline behavior is the generic unavailable notice. Logged as a gap.
- [x] **Gated** — `proxy.ts` redirects anonymous `/notifications` requests to
      `/welcome?redirect=%2Fnotifications`, while signed-in requests pass
      through. In-panel BFF auth failures still collapse to the unavailable
      notice (401 `missing_auth_context` / 403 `domain_scope_missing`).
- [ ] **Standalone PWA** — no PWA-specific branch in `page.tsx` or the panel;
      relies on the shell. `metadata` sets `robots: noindex, nofollow`.

## Interactions

### Header

- [x] **Mark all read** (button, `[data-notifications-mark-all]`)
  - Function: optimistically flips every unread item to read, staggered ~80 ms
    apart via `setTimeout`; sets `markingAllRead` true during the stagger; fires
    one `PATCH …/v1/notifications-write` with
    `{ action: 'mark_all_read', notificationIds: unreadIds }`.
  - Keyboard: native button; tab order follows DOM (after the header `<h2>`).
  - Screen reader: announces "Mark all read" (text label; `<CheckCheck>` icon
    decorative).
  - Touch target: `minHeight: 44`; covered by the mobile route spec.
  - Mobile (≤ 640 px): header `flex-wrap`s; button stays on its row.
  - Disabled when: `counts.unread === 0 || markingAllRead` → `disabled`
    attribute, 0.5 opacity, `cursor: default`. No tooltip/aria text.
  - Offline behavior: local state still flips; the `fetch` rejects and is
    `.catch(console.error)`'d (no queue, no user feedback).
  - Telemetry: dispatches `notification_center_mark_all_read` on
    `oshun-analytics:event` with `origin`, `variant`, `unreadCount`, and
    normalized `notificationIds`.

### Inbox controls

- [ ] **Inbox filter tabs** (`WorkspaceSegmentRail`,
      `[data-filter-tabs="inbox"]`)
  - Function: `[data-filter-btn="all|unread|priority"]` set `inboxFilter`;
    `unread` hides read items, `priority` keeps only `priority === 'high'`.
    Active tab carries `[data-filter-active]`.
  - Keyboard: `WorkspaceSegmentRail` semantics (segmented control); ariaLabel
    "inbox filters".
  - Screen reader: announces each option label (All / Unread / Priority).
  - Telemetry: dispatches `notification_center_filter_changed` with
    `filter="inbox"`, previous/next values, `visibleCount`, and `itemCount`.
- [ ] **Lane filter tabs** (`[data-filter-tabs="lane"]`)
  - Function:
    `[data-filter-btn="all|mixed-domain|admin|support|study|grounding|privacy|safety"]`
    set `laneFilter`; filters by `item.lane`. (Note the data-id for "Mixed" is
    `mixed-domain`, not `mixed`.) ariaLabel "lane filters".
  - Telemetry: dispatches `notification_center_filter_changed` with
    `filter="lane"` and the same normalized count payload.
- [ ] **Domain filter tabs** (`[data-filter-tabs="domain"]`)
  - Function: `[data-filter-btn="all|tara|veritas|nyx|arete|nisaba|metis"]` set
    `domainFilter`; keeps items whose `domains[]` includes the id. ariaLabel
    "domain filters".
  - Telemetry: dispatches `notification_center_filter_changed` with
    `filter="domain"` and the same normalized count payload.
- [x] **Reset filters** (button, `[data-notifications-filter-reset]`)
  - Function: resets `inboxFilter`, `laneFilter`, `domainFilter` all to `all`.
  - Disabled when: not rendered at all unless `hasActiveFilters` (any filter
    `!== 'all'`).
  - Touch target: `minHeight: 44`; covered by the mobile route spec.
  - Telemetry: dispatches `notification_center_filters_reset` with previous
    inbox/lane/domain values plus the restored `visibleCount`.
- [ ] **"{N} visible" badge** (`WorkspaceStatusBadge`,
      `[data-notifications-count]`) — non-interactive; shows
      `filteredItems.length`.
- [x] **Load more** (button, `[data-notifications-load-more]`)
  - Function: rendered only when the BFF pagination envelope reports another
    page; clicking fetches the current `nextCursor` with the same page `limit`
    and appends unique notification IDs. A failed next-page fetch leaves the
    existing page intact and shows `[data-notifications-load-more-error]`.
  - Keyboard: native button.
  - Screen reader: announces "Load more" / "Loading more..." while disabled.
  - Touch target: `minHeight: 44`; covered by the long-feed route spec.
  - Telemetry: dispatches `notification_center_load_more_requested` before the
    fetch and `notification_center_page_loaded` after a successful append.

### Notification card

- [ ] **Open** (button, `[data-notification-open]`)
  - Function: routes per `item.action.kind` — `domain` →
    `router.push(buildWebDomainRoutePath(domain, { origin }))` or
    `buildHydratedWebDomainRoutePath(domain, path, { origin })` when a path is
    set; `profile`/`admin` → `router.push(action.href)`
    (`WEB_SHELL_ROUTE_PATHS.profile` or `WEB_ADMIN_ROUTE_PATHS.home`). `origin`
    is `"activity"` on this route. Calls `onNavigate?.()` (only wired in the
    drawer, not here).
  - Keyboard: native button; Enter/Space activate; first focusable in the card.
  - Screen reader: announces the card title + body text inside the button; the
    "Next: {label}" hint is read too.
  - Touch target: full-width button; tall enough.
  - Telemetry: dispatches `notification_center_notification_opened` with
    `origin`, `variant`, normalized notification metadata, and
    `destinationKind`.
- [x] **Toggle read** (button, `[data-notification-toggle-read]`)
  - Function: flips `item.read` locally; fires `PATCH …/v1/notifications-write`
    with `{ action: read ? 'mark_unread' : 'mark_read', id }`. Icon `<Check>`
    when read, `<CheckCheck>` when unread.
  - Keyboard: native button with `tabIndex=0` in full mode; Enter/Space
    activate.
  - Screen reader: `aria-label` toggles "Mark unread" (read) / "Mark read"
    (unread).
  - Touch target: 44×44 px; covered by the mobile route spec.
  - Offline behavior: local flip persists; the `fetch` rejects, `.catch`'d.
  - Telemetry: dispatches `notification_center_notification_read_toggled` with
    normalized notification metadata, `writeAction`, and `nextRead`.
- [x] **Dismiss** (button, `[data-notification-dismiss]`)
  - Function: removes the item from `items` (after a 250 ms slide-out unless
    `disableAnimation`); fires `PATCH …/v1/notifications-write` with
    `{ action: 'dismiss', id }`.
  - Keyboard: native button with `tabIndex=0` in full mode; Enter/Space
    activate.
  - Screen reader: `aria-label="Dismiss notification"`; `<X>` icon decorative.
  - Touch target: 44×44 px; covered by the mobile route spec.
  - Offline behavior: local removal persists; `fetch` rejects, `.catch`'d.
  - Telemetry: dispatches `notification_center_notification_dismissed` with
    normalized notification metadata.

## Data & contracts

- **Reads**: `GET {bff}/v1/notifications` (built by
  `buildOshunBffUrl('/v1/notifications')`, `Authorization: Bearer <token>` from
  `resolveBffAuthToken()`). Handler:
  `apps/oshun/bff/src/routes/notifications.ts` `registerNotificationRoutes`
  (registered at both `/notifications` and `/v1/notifications`, lines 284–297).
  Returns `{ notifications: NotificationItem[] }` (plus `generatedAt`, `userId`,
  filters, `counts`, `pagination`). The client requests `limit=30`, maps each
  item, derives `lane` (`isNotificationLane` else `resolveLaneFromSurface`),
  `surface` (`resolveSurfaceForLane`), `domain`/`domains`
  (`getNotificationDomains`), and an `action` discriminated union (`domain` |
  `profile` | `admin`). When `pagination.hasMore` is true, the client requests
  the next page with `cursor=pagination.nextCursor` and appends unique item IDs.
  The BFF default is also `limit=30`, max `100`.
- **Writes**: the panel issues `PATCH {bff}/v1/notifications-write` with bodies
  `{ action: 'mark_read' | 'mark_unread' | 'dismiss', id }` and
  `{ action: 'mark_all_read', notificationIds }` (`handleToggleRead`,
  `handleDismiss`, `handleMarkAllRead`). The BFF registers the browser-facing
  compatibility endpoints at `PATCH /notifications-write` and
  `PATCH /v1/notifications-write`, plus the legacy mark-read routes
  `POST /notifications/:notificationId/read`,
  `POST /v1/notifications/:notificationId/read`,
  `POST /notifications/read/bulk`, and `POST /v1/notifications/read/bulk`.
  Read/unread/dismiss state is held in the BFF read-state store and affects
  subsequent `GET /v1/notifications` responses for that user.
- **Realtime**: _None — the feed fetches on mount for the current
  `origin`/`variant`; no polling, websocket, or refresh control on this route._
- **Telemetry**: the panel dispatches browser-local `oshun-analytics:event`
  events through `notificationCenterTelemetry.ts`. A successful feed read emits
  `notification_center_opened`; retry emits
  `notification_center_feed_retry_requested`; filter/reset/open/read/dismiss/
  mark-all/load-more interactions emit the event names listed above. Payloads
  include route context and normalized metadata only, not notification titles or
  body copy.
- **Caching**: client state only; no SWR/ISR; the `useEffect` is tied to the
  current `fetchNotifications` callback. The panel intentionally does **not**
  mirror the unread count into `document.title` (see the in-source comment re:
  App Router `<title>` tug-of-war / WCAG).
- **Auth/role check**: no `middleware.ts` exists in `apps/oshun/web`; the route
  relies on the customer shell for sign-in gating. The BFF enforces auth:
  `createAuthPreHandler` → 401 `missing_auth_context` when unauthenticated, 403
  `domain_scope_missing` when no authorized shell domains (`notifications.ts`
  lines 113–130). The token comes from `resolveBffAuthToken` (session token, or
  a non-prod dev fallback).

## Cross-references

- Shell: [`../../shell/01-app-shell.md`](../../shell/01-app-shell.md),
  [`../../shell/05-notifications.md`](../../shell/05-notifications.md) (toasts /
  push / in-app feed),
  [`../../shell/04-auth-session.md`](../../shell/04-auth-session.md)
- Sibling routes:
  - [`activity.md`](./activity.md) — the breadcrumb parent (`active="activity"`)
  - [`home.md`](./home.md) — hosts the `variant="preview"` panel
  - `settings.md` / `/profile/notifications` — notification _preferences_ (a
    different surface: `PATCH /v1/notifications/preferences`)
- Component sources:
  - `apps/oshun/web/src/app/notifications/page.tsx`
  - `apps/oshun/web/src/components/NotificationsCenterPanel.tsx`
  - `apps/oshun/web/src/app/shellRouteState.tsx`
  - `apps/oshun/web/src/components/ShellLayout.tsx` (drawer host, lines 598–702)
  - `apps/oshun/web/src/components/ShellSurfaceStatePreview.tsx` (preview modes)
  - `apps/oshun/web/src/lib/bff-endpoints.ts`, `…/lib/bff-auth.ts`
  - BFF: `apps/oshun/bff/src/routes/notifications.ts`,
    `…/routes/notifications-write.ts`
- E2E:
  - [`../../../apps/oshun/web/e2e/shared-shell-entry-routes.spec.ts`](../../../apps/oshun/web/e2e/shared-shell-entry-routes.spec.ts)
    — smoke for `/notifications` (`[data-shell-entry-route="notifications"]`)
  - [`../../../apps/oshun/web/e2e/notifications-center-mixed-context.spec.ts`](../../../apps/oshun/web/e2e/notifications-center-mixed-context.spec.ts)
    — exercises the same panel via the **header drawer** from `/`, not this
    route
  - [`../../../apps/oshun/web/e2e/notifications-route.spec.ts`](../../../apps/oshun/web/e2e/notifications-route.spec.ts)
    — exercises the `/notifications` route directly (Activity-owned desktop and
    mobile nav state, no `/notifications` manifest shortcut, control strip,
    inbox/lane/domain filter/reset, BFF cursor-backed load-more, dedicated
    loading and empty states, feed outage disclosure, retry recovery, mobile tap
    targets, support→`/profile`, live BFF write persistence, bulk mark-all-read
    and dismiss write payloads, normalized notification-center telemetry, and
    swallowed write-failure behavior)
- Feature spec: [`V1/features.md`](../../../V1/features.md)
- Architecture: [`V1/ARCHITECTURE.md`](../../../V1/ARCHITECTURE.md)

## Open questions / known gaps

- [ ] **Read-state durability.** Browser writes now persist through the BFF
      read-state store and survive route reloads for the same running process,
      but the store is in-memory dev infrastructure rather than durable
      cross-process persistence.
