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 chain —
page.tsxmountsShellLayout active="activity"with breadcrumbsHome → Activity → Notifications(WEB_SHELL_ROUTE_PATHS.home,.activity, then a non-linkedNotificationscrumb). - Primary shell navigation state —
/notificationsis 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.jsonshortcuts[]likewise has no/notificationsshortcut. - Global header notification drawer — the shell header bell
(
[data-notification-bell] [aria-label="Notifications"]) opens[data-shell-notifications-drawer]rendering the sameNotificationsCenterPanel variant="full"(ShellLayout.tsxlines 598–702); the drawer is the dominant in-app entry, the/notificationsroute 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 (seehome.md). - Direct URL / bookmark — yes, auth required.
page.tsxhas no auth guard of its own; it relies on the customer shell.metadata.robotsis{ index: false, follow: false }(noindex). - Shell state preview —
?shellState=<mode>swaps the live panel forShellSurfaceStatePreview surface="notifications"viaresolveShellRouteSurfaceContent({ surface: 'notifications' })(shellRouteState.tsx); onlyshellState=liverenders 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 whencounts.unread > 0); and a right-aligned "Mark all read" button[data-notifications-mark-all](<CheckCheck>icon). Invariant="preview"the button is replaced by a "Home preview" pill[data-notifications-preview-badge]— not reachable on this route, which is alwaysvariant="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 threeWorkspaceControlGroups wrapping segmentedFilterTabs:- 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
- Inbox (
- Outage notice (
[data-notifications-unavailable],role="status"): rendered whenfeedState === '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 notunavailable. - 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, whilepagination.hasMore, a[data-notifications-load-more]button that fetches the BFFnextCursor. - 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'(itemsempty, fetch in-flight) renders[data-notifications-loading]withrole="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" requestsGET /v1/notifications?cursor=<nextCursor>&limit=30, appends unique notifications without replacing page 1, updates the status to the full total, and removes the button whenpagination.hasMore === false. - Error (recoverable) — fetch
!res.okor throws →feedState='unavailable',items=[], and the[data-notifications-unavailable]role="status"notice renders with a[data-notifications-retry]button. Clicking retry reissues the same liveGET /v1/notificationsrequest, 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
unavailabledisclosure (no distinct 403/500 branch inNotificationsCenterPanel.tsx). A 401/403 from the BFF surfaces as the generic unavailable notice. TheShellSurfaceStatePreview 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 —
fetchrejects → 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. - Gated —
proxy.tsredirects anonymous/notificationsrequests to/welcome?redirect=%2Fnotifications, while signed-in requests pass through. In-panel BFF auth failures still collapse to the unavailable notice (401missing_auth_context/ 403domain_scope_missing). - Standalone PWA — no PWA-specific branch in
page.tsxor the panel; relies on the shell.metadatasetsrobots: noindex, nofollow.
Interactions#
Header#
- Mark all read (button,
[data-notifications-mark-all])- Function: optimistically flips every unread item to read, staggered ~80 ms
apart via
setTimeout; setsmarkingAllReadtrue during the stagger; fires onePATCH …/v1/notifications-writewith{ 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 || markingAllRead→disabledattribute, 0.5 opacity,cursor: default. No tooltip/aria text. - Offline behavior: local state still flips; the
fetchrejects and is.catch(console.error)'d (no queue, no user feedback). - Telemetry: dispatches
notification_center_mark_all_readonoshun-analytics:eventwithorigin,variant,unreadCount, and normalizednotificationIds.
- Function: optimistically flips every unread item to read, staggered ~80 ms
apart via
Inbox controls#
- Inbox filter tabs (
WorkspaceSegmentRail,[data-filter-tabs="inbox"])- Function:
[data-filter-btn="all|unread|priority"]setinboxFilter;unreadhides read items,prioritykeeps onlypriority === 'high'. Active tab carries[data-filter-active]. - Keyboard:
WorkspaceSegmentRailsemantics (segmented control); ariaLabel "inbox filters". - Screen reader: announces each option label (All / Unread / Priority).
- Telemetry: dispatches
notification_center_filter_changedwithfilter="inbox", previous/next values,visibleCount, anditemCount.
- Function:
- Lane filter tabs (
[data-filter-tabs="lane"])- Function:
[data-filter-btn="all|mixed-domain|admin|support|study|grounding|privacy|safety"]setlaneFilter; filters byitem.lane. (Note the data-id for "Mixed" ismixed-domain, notmixed.) ariaLabel "lane filters". - Telemetry: dispatches
notification_center_filter_changedwithfilter="lane"and the same normalized count payload.
- Function:
- Domain filter tabs (
[data-filter-tabs="domain"])- Function:
[data-filter-btn="all|tara|veritas|nyx|arete|nisaba|metis"]setdomainFilter; keeps items whosedomains[]includes the id. ariaLabel "domain filters". - Telemetry: dispatches
notification_center_filter_changedwithfilter="domain"and the same normalized count payload.
- Function:
- Reset filters (button,
[data-notifications-filter-reset])- Function: resets
inboxFilter,laneFilter,domainFilterall toall. - 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_resetwith previous inbox/lane/domain values plus the restoredvisibleCount.
- Function: resets
- "{N} visible" badge (
WorkspaceStatusBadge,[data-notifications-count]) — non-interactive; showsfilteredItems.length. - Load more (button,
[data-notifications-load-more])- Function: rendered only when the BFF pagination envelope reports another
page; clicking fetches the current
nextCursorwith the same pagelimitand 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_requestedbefore the fetch andnotification_center_page_loadedafter a successful append.
- Function: rendered only when the BFF pagination envelope reports another
page; clicking fetches the current
Notification card#
- Open (button,
[data-notification-open])- Function: routes per
item.action.kind—domain→router.push(buildWebDomainRoutePath(domain, { origin }))orbuildHydratedWebDomainRoutePath(domain, path, { origin })when a path is set;profile/admin→router.push(action.href)(WEB_SHELL_ROUTE_PATHS.profileorWEB_ADMIN_ROUTE_PATHS.home).originis"activity"on this route. CallsonNavigate?.()(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_openedwithorigin,variant, normalized notification metadata, anddestinationKind.
- Function: routes per
- Toggle read (button,
[data-notification-toggle-read])- Function: flips
item.readlocally; firesPATCH …/v1/notifications-writewith{ action: read ? 'mark_unread' : 'mark_read', id }. Icon<Check>when read,<CheckCheck>when unread. - Keyboard: native button with
tabIndex=0in full mode; Enter/Space activate. - Screen reader:
aria-labeltoggles "Mark unread" (read) / "Mark read" (unread). - Touch target: 44×44 px; covered by the mobile route spec.
- Offline behavior: local flip persists; the
fetchrejects,.catch'd. - Telemetry: dispatches
notification_center_notification_read_toggledwith normalized notification metadata,writeAction, andnextRead.
- Function: flips
- Dismiss (button,
[data-notification-dismiss])- Function: removes the item from
items(after a 250 ms slide-out unlessdisableAnimation); firesPATCH …/v1/notifications-writewith{ action: 'dismiss', id }. - Keyboard: native button with
tabIndex=0in 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;
fetchrejects,.catch'd. - Telemetry: dispatches
notification_center_notification_dismissedwith normalized notification metadata.
- Function: removes the item from
Data & contracts#
- Reads:
GET {bff}/v1/notifications(built bybuildOshunBffUrl('/v1/notifications'),Authorization: Bearer <token>fromresolveBffAuthToken()). Handler:apps/oshun/bff/src/routes/notifications.tsregisterNotificationRoutes(registered at both/notificationsand/v1/notifications, lines 284–297). Returns{ notifications: NotificationItem[] }(plusgeneratedAt,userId, filters,counts,pagination). The client requestslimit=30, maps each item, deriveslane(isNotificationLaneelseresolveLaneFromSurface),surface(resolveSurfaceForLane),domain/domains(getNotificationDomains), and anactiondiscriminated union (domain|profile|admin). Whenpagination.hasMoreis true, the client requests the next page withcursor=pagination.nextCursorand appends unique item IDs. The BFF default is alsolimit=30, max100. - Writes: the panel issues
PATCH {bff}/v1/notifications-writewith 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 atPATCH /notifications-writeandPATCH /v1/notifications-write, plus the legacy mark-read routesPOST /notifications/:notificationId/read,POST /v1/notifications/:notificationId/read,POST /notifications/read/bulk, andPOST /v1/notifications/read/bulk. Read/unread/dismiss state is held in the BFF read-state store and affects subsequentGET /v1/notificationsresponses 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:eventevents throughnotificationCenterTelemetry.ts. A successful feed read emitsnotification_center_opened; retry emitsnotification_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
useEffectis tied to the currentfetchNotificationscallback. The panel intentionally does not mirror the unread count intodocument.title(see the in-source comment re: App Router<title>tug-of-war / WCAG). - Auth/role check: no
middleware.tsexists inapps/oshun/web; the route relies on the customer shell for sign-in gating. The BFF enforces auth:createAuthPreHandler→ 401missing_auth_contextwhen unauthenticated, 403domain_scope_missingwhen no authorized shell domains (notifications.tslines 113–130). The token comes fromresolveBffAuthToken(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 thevariant="preview"panelsettings.md//profile/notifications— notification preferences (a different surface:PATCH /v1/notifications/preferences)
- Component sources:
apps/oshun/web/src/app/notifications/page.tsxapps/oshun/web/src/components/NotificationsCenterPanel.tsxapps/oshun/web/src/app/shellRouteState.tsxapps/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— smoke for/notifications([data-shell-entry-route="notifications"])../../../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— exercises the/notificationsroute directly (Activity-owned desktop and mobile nav state, no/notificationsmanifest 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 - 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.