5.7.5 The bell + unread badge + notification drawer in the shell header — live count, deep links, mark-read on open (no whole-tree refresh)
Estimate: 35m · Depends on: 5.7.1, 5.7.4
The user-facing surface: the bell + badge + drawer in the shell header. Design-gated (5.7.1) and built on the read/mark API (5.7.4).
Build (in app/(authed)/_components/): a NotificationBell mounted in TopNav (desktop) + the SidebarHeader/mobile affordance — the bell IconButton + the unread badge (count from getUnreadCount, 99+ cap, zero = no badge). A bounded poll refreshes the count on an interval while mounted + on navigation (the documented no-realtime decision — no realtime substrate, the 5.1 live-comments precedent). Opening the bell renders a NotificationDrawer (the popover/drawer; sheet on mobile): the Direct / Watching segmented filter (Watching disabled — the 5.4 seam), the overflow "Mark all as read", and the cursor-paged feed (newest 20 + "Show more" driving the 5.7.4 list read — never load-all). Each row is one clickable target that deep-links to the issue (/issues/[key]) AND marks itself read on open. Loading skeleton, empty state, ErrorState.
Inline-edit memory (load-bearing). Mark-read and mark-all-read are inline mutations: the success response IS the confirmation — the badge + row state update from the mutation's OWN returned unreadCount / row, NOT a router.refresh() / revalidatePath whole-tree fan-out (the refresh fan-out is what CAUSED the revert bug; the cell trusts its own success). Opening the badge marks "seen" (clears the badge count) per the Jira mirror, distinct from per-row read.
A11y: the bell is a labelled button announcing the unread count (e.g. "Notifications, 3 unread"); the drawer is a keyboard-navigable feed/list (the Linear J/K + U model, surfaced via standard controls); read state is conveyed as text + the dot, not colour alone; focus returns to the bell on close; no nested interactive elements inside a row (the portal-popover / nested-button lessons). The strict axe sweep covers the header with the drawer open.
Acceptance criteria
- The bell renders in the header with the unread badge (matching
design/notifications/{bell,drawer}.mock.htmlpanel-for-panel: badge states, row grammar, unread-vs-read, Direct default + Watching-disabled seam, overflow mark-all); the count refreshes on poll + navigation. - Opening the drawer marks "seen" (badge clears); clicking a row deep-links to the issue AND marks that row read with the unread count decrementing from the mutation response, with no
router.refresh()/revalidatePath; "Mark all as read" clears every dot in one request and survives reload. - The feed pages (newest 20 + "Show more"), never an unbounded read; empty / loading / error states match the design; colour/shape only through
--el-*/element tokens. - The header axe sweep with the drawer open is clean (labelled bell announcing the count, keyboard-navigable feed, text-conveyed read state, focus return, no nested interactive elements).
Context refs
design/notifications/{bell,drawer}.mock.html+ design-notes (5.7.1) — THE layout authorityapp/(authed)/_components/TopNav.tsx/SidebarHeader.tsx/SidebarNav.tsx— the shell header components the bell mounts in; the existing header-control + popover patternsnotificationsService+ routes (5.7.4) — the read/mark API; the unread-count poll endpoint- The inline-edit-no-whole-tree-refresh memory (no
router.refreshfan-out on the mark-read success path) + the portal-popover / nested-button memories - Story 5.7 description — the no-realtime (poll) decision + the seen-vs-read split