V1 Web PWA · Surface walkthrough

Notifications center

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

walked
8sections11 minread

On this page

Context. surface customer · domain shell-routing · route /notifications · auth signed-in · source apps/oshun/web/src/app/notifications/page.tsx

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

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 chainpage.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 WorkspaceControlGroups 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#

  • 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.
  • 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).
  • Populated (short) — 1–5 items: cards render under "Action needed" / "Recent updates" sections; stat tiles and "{N} visible" reflect counts.
  • 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.
  • 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.
  • 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.
  • Offlinefetch 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.
  • Gatedproxy.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#

  • 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-wraps; button stays on its row.
    • Disabled when: counts.unread === 0 || markingAllReaddisabled 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.
  • 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.
  • 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.kinddomainrouter.push(buildWebDomainRoutePath(domain, { origin })) or buildHydratedWebDomainRoutePath(domain, path, { origin }) when a path is set; profile/adminrouter.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.
  • 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.
  • 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/05-notifications.md (toasts / push / in-app feed), ../../shell/04-auth-session.md
  • Sibling routes:
    • activity.md — the breadcrumb parent (active="activity")
    • 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:
  • Feature spec: V1/features.md
  • Architecture: 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.