feat(web): notifications bell, unread badge and inbox panel in the header (#5342)

- The bell sits between search and the user menu. Its badge is
  parchment on obsidian, not the accent, which the house style keeps
  off general chrome. It counts up to 9, then shows 9+.
- The panel lists the server-rendered title, body and relative time,
  newest first, with unread rows marked. Clicking a row marks it read
  and opens its link. "Mark all read" appears while anything is unread,
  and an empty inbox says "Nothing waiting for you."
- createNotificationsQuery and createUnreadCountQuery poll every 60s
  while the tab is visible. The `notification.created` live event
  invalidates ['notifications'] so the badge and list refresh promptly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:23:32 -04:00
co-authored by Claude Opus 5.5
parent 1e9408c835
commit 956058d4a0
7 changed files with 412 additions and 1 deletions
@@ -0,0 +1,148 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import { Bell } from 'lucide-svelte';
import {
badgeLabel,
createNotificationsQuery,
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
import { relativeTime } from '$lib/utils/relativeTime';
import { pushToast } from '$lib/stores/toast.svelte';
// The notifications inbox (M489): a bell in the header with an unread badge,
// opening a panel of what happened while you were away.
const client = useQueryClient();
const unreadStore = createUnreadCountQuery();
const listStore = createNotificationsQuery();
const unread = $derived(($unreadStore.data as number | undefined) ?? 0);
const items = $derived((($listStore.data?.items ?? []) as NotificationItem[]));
const badge = $derived(badgeLabel(unread));
let open = $state(false);
let panelRef: HTMLElement | undefined = $state();
let buttonRef: HTMLElement | undefined = $state();
// Same outside-click shape as the user menu in Shell: no stopPropagation on
// the panel, so SvelteKit's link interception keeps working inside it.
function handleWindowClick(e: MouseEvent) {
if (!open) return;
const target = e.target as Node | null;
if (!target) return;
if (panelRef?.contains(target) || buttonRef?.contains(target)) return;
open = false;
}
function refresh() {
client.invalidateQueries({ queryKey: ['notifications'] });
}
async function openItem(item: NotificationItem) {
open = false;
if (!item.read_at) {
try {
await markNotificationRead(item.id);
} catch {
// Opening the subject matters more than the read mark; the next
// refresh shows it unread again, which is the honest state.
}
refresh();
}
if (item.link) await goto(item.link);
}
async function markAll() {
try {
await markAllNotificationsRead();
refresh();
} catch {
pushToast("Couldn't mark them read. Try again in a moment.", 'error');
}
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
<div class="relative flex-shrink-0">
<button
bind:this={buttonRef}
type="button"
class="relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
aria-haspopup="dialog"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Bell size={20} strokeWidth={1.75} aria-hidden="true" />
{#if badge}
<span
data-testid="notification-badge"
class="absolute right-1 top-1 min-w-[18px] rounded-full bg-text-primary px-1 text-center text-xs leading-[18px] text-background"
aria-hidden="true"
>
{badge}
</span>
{/if}
</button>
{#if open}
<div
bind:this={panelRef}
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow"
role="dialog"
aria-label="Notifications"
>
<div class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-sm font-medium text-text-primary">Notifications</span>
{#if unread > 0}
<button
type="button"
class="rounded px-2 py-1 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
onclick={markAll}
>
Mark all read
</button>
{/if}
</div>
{#if items.length === 0}
<p class="px-3 py-6 text-center text-sm text-text-secondary">Nothing waiting for you.</p>
{:else}
<ul class="overflow-y-auto">
{#each items as item (item.id)}
<li class="border-b border-border last:border-b-0">
<button
type="button"
data-testid="notification-item"
data-unread={item.read_at ? undefined : 'true'}
class="flex w-full gap-2 px-3 py-2 text-left hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
onclick={() => openItem(item)}
>
<span
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
aria-hidden="true"
></span>
<span class="min-w-0 flex-1">
<span
class="block text-sm {item.read_at ? 'text-text-secondary' : 'font-medium text-text-primary'}"
>
{item.title}
{#if !item.read_at}<span class="sr-only"> (unread)</span>{/if}
</span>
{#if item.body}
<span class="block text-sm text-text-secondary">{item.body}</span>
{/if}
<span class="block text-xs text-text-muted">{relativeTime(item.created_at)}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>