Phase (b) of the qbit-alerts notifications design (qbit-alerts/docs/NOTIFICATIONS.md §8, API §8.1): the Alerts bell becomes a notification center. Gating from #85 is unchanged: no bell and zero requests unless metaData.apps.alerts is present (unit and e2e tests assert it).
Badge (main bundle): unread count (99+), red when highestUnreadSeverity is CRITICAL, label "Notifications, N unread". Polls GET /alerts/inbox/summary every pollSeconds (60), exponential backoff to 5 min on errors, paused while the tab is hidden, refetched when it shows.
Popover (lazy import()): newest first, severity icon (INFO, SUCCESS, WARNING, ERROR, CRITICAL), title, source, relative time, link (relative app path, or https in a new tab; opening marks it read), per-item mark read/unread and dismiss, footer "Mark all read" and "View all". Focus-trapped; Escape closes.
Page /app/notifications (lazy, next/dynamic): a fixed route under /app like /app/search, so the static export emits it and the dashboard layout wraps it. Filters All/Unread, category, severity and the offset live in the URL; paging with offset/limit (25); markdown bodies rendered through the existing MarkdownView (no raw HTML; it now also links relative app paths in the same tab); same actions.
Banners: the summary's banners[] show above the page content until dismissed (POST /inbox/dismiss). The banner UI loads only when there is a banner.
Actions: POST /inbox/read|unread|dismiss with {id}/{all:true}; optimistic on the summary and every loaded page, rolled back with a toast on failure, then both refetched.
Older backends: items without id (0.1.0-RC.1: alertId, group, status) render with group as the source, per-item buttons hidden, and opening marks read by {alertId}. A notificationsHref that is not an /app/... path (the backend currently sends /notifications) falls back to /app/notifications.
Tests
Unit (vitest + msw): gating, badge/99+/critical, pollDelay backoff, hidden-tab pause and refetch on visible, popover actions/focus trap/Escape/axe, optimistic update and rollback, legacy items, banner dismiss, page filters/URL/paging/markdown safety/empty/error, API normalization.
E2E (tests/e2e/notifications.spec.ts, mocked): no request without the app; bell, popover, banner and page at 390 and 1440 px in light and dark with no horizontal scrolling; axe; paging, Unread filter, mark read and banner dismiss. The acceptance suite has no bell coverage, so no acceptance spec was added.
## What
Phase (b) of the qbit-alerts notifications design (`qbit-alerts/docs/NOTIFICATIONS.md` §8, API §8.1): the Alerts bell becomes a notification center. Gating from #85 is unchanged: no bell and **zero requests** unless `metaData.apps.alerts` is present (unit and e2e tests assert it).
- **Badge** (main bundle): unread count (99+), red when `highestUnreadSeverity` is CRITICAL, label "Notifications, N unread". Polls `GET /alerts/inbox/summary` every `pollSeconds` (60), exponential backoff to 5 min on errors, paused while the tab is hidden, refetched when it shows.
- **Popover** (lazy `import()`): newest first, severity icon (INFO, SUCCESS, WARNING, ERROR, CRITICAL), title, source, relative time, link (relative app path, or https in a new tab; opening marks it read), per-item mark read/unread and dismiss, footer "Mark all read" and "View all". Focus-trapped; Escape closes.
- **Page `/app/notifications`** (lazy, `next/dynamic`): a fixed route under `/app` like `/app/search`, so the static export emits it and the dashboard layout wraps it. Filters All/Unread, category, severity and the offset live in the URL; paging with `offset/limit` (25); markdown bodies rendered through the existing `MarkdownView` (no raw HTML; it now also links relative app paths in the same tab); same actions.
- **Banners**: the summary's `banners[]` show above the page content until dismissed (`POST /inbox/dismiss`). The banner UI loads only when there is a banner.
- **Actions**: `POST /inbox/read|unread|dismiss` with `{id}`/`{all:true}`; optimistic on the summary and every loaded page, rolled back with a toast on failure, then both refetched.
- **Older backends**: items without `id` (0.1.0-RC.1: `alertId`, `group`, `status`) render with `group` as the source, per-item buttons hidden, and opening marks read by `{alertId}`. A `notificationsHref` that is not an `/app/...` path (the backend currently sends `/notifications`) falls back to `/app/notifications`.
## Tests
- Unit (vitest + msw): gating, badge/99+/critical, `pollDelay` backoff, hidden-tab pause and refetch on visible, popover actions/focus trap/Escape/axe, optimistic update and rollback, legacy items, banner dismiss, page filters/URL/paging/markdown safety/empty/error, API normalization.
- E2E (`tests/e2e/notifications.spec.ts`, mocked): no request without the app; bell, popover, banner and page at 390 and 1440 px in light and dark with no horizontal scrolling; axe; paging, Unread filter, mark read and banner dismiss. The acceptance suite has no bell coverage, so no acceptance spec was added.
## Bundle (local static export, vs main)
| | main | this PR |
|---|---|---|
| `/app/_/_` first load | 348.5 KB | 349.2 KB (cap 350) |
| other `/app` routes | | +0.7 KB each |
| `/app/notifications` | | 339.5 KB (new) |
| total JS | 1092.9 KB | 1098.0 KB (cap 1100) |
| CSS | 23.2 KB | 23.2 KB |
| lazy `notificationCenter` | | 15.2 KB in 3 chunks (popover 4.3, banners 4.3, page 6.7), budget 16.5 KB |
No cap was raised; `perf-budget.json` gains the `notificationCenter` lazy-chunk entry with the measurements in its `$comment`.
## Screenshots
Taken from the mocked e2e run (light and dark, 390 and 1440 px: popover, page, banner); not attached here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Phase (b) of the qbit-alerts notifications design (
qbit-alerts/docs/NOTIFICATIONS.md§8, API §8.1): the Alerts bell becomes a notification center. Gating from #85 is unchanged: no bell and zero requests unlessmetaData.apps.alertsis present (unit and e2e tests assert it).highestUnreadSeverityis CRITICAL, label "Notifications, N unread". PollsGET /alerts/inbox/summaryeverypollSeconds(60), exponential backoff to 5 min on errors, paused while the tab is hidden, refetched when it shows.import()): newest first, severity icon (INFO, SUCCESS, WARNING, ERROR, CRITICAL), title, source, relative time, link (relative app path, or https in a new tab; opening marks it read), per-item mark read/unread and dismiss, footer "Mark all read" and "View all". Focus-trapped; Escape closes./app/notifications(lazy,next/dynamic): a fixed route under/applike/app/search, so the static export emits it and the dashboard layout wraps it. Filters All/Unread, category, severity and the offset live in the URL; paging withoffset/limit(25); markdown bodies rendered through the existingMarkdownView(no raw HTML; it now also links relative app paths in the same tab); same actions.banners[]show above the page content until dismissed (POST /inbox/dismiss). The banner UI loads only when there is a banner.POST /inbox/read|unread|dismisswith{id}/{all:true}; optimistic on the summary and every loaded page, rolled back with a toast on failure, then both refetched.id(0.1.0-RC.1:alertId,group,status) render withgroupas the source, per-item buttons hidden, and opening marks read by{alertId}. AnotificationsHrefthat is not an/app/...path (the backend currently sends/notifications) falls back to/app/notifications.Tests
pollDelaybackoff, hidden-tab pause and refetch on visible, popover actions/focus trap/Escape/axe, optimistic update and rollback, legacy items, banner dismiss, page filters/URL/paging/markdown safety/empty/error, API normalization.tests/e2e/notifications.spec.ts, mocked): no request without the app; bell, popover, banner and page at 390 and 1440 px in light and dark with no horizontal scrolling; axe; paging, Unread filter, mark read and banner dismiss. The acceptance suite has no bell coverage, so no acceptance spec was added.Bundle (local static export, vs main)
/app/_/_first load/approutes/app/notificationsnotificationCenterNo cap was raised;
perf-budget.jsongains thenotificationCenterlazy-chunk entry with the measurements in its$comment.Screenshots
Taken from the mocked e2e run (light and dark, 390 and 1440 px: popover, page, banner); not attached here.
🤖 Generated with Claude Code