diff --git a/src/components/chrome/NotificationToasts.svelte b/src/components/chrome/NotificationToasts.svelte
new file mode 100644
index 0000000..93f40dc
--- /dev/null
+++ b/src/components/chrome/NotificationToasts.svelte
@@ -0,0 +1,160 @@
+
+
+
diff --git a/src/components/chrome/NotificationToasts.test.ts b/src/components/chrome/NotificationToasts.test.ts
new file mode 100644
index 0000000..ff90eca
--- /dev/null
+++ b/src/components/chrome/NotificationToasts.test.ts
@@ -0,0 +1,74 @@
+import { act, render } from '@testing-library/svelte'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { APP_SERVICES } from '$lib/app-services'
+import type { Notification } from '$lib/api/types'
+import { account, session, status, testServices } from '$test/fixtures'
+import NotificationToasts from './NotificationToasts.svelte'
+
+function notification(overrides: Partial
= {}): Notification {
+ return {
+ id: 'notification-1',
+ type: 'mention',
+ created_at: '2026-01-01T00:00:00.000Z',
+ account: account({ id: 'bob', display_name: 'Bob', acct: 'bob' }),
+ status: status({ id: 'mentioned-entry', content: 'A new mention
' }),
+ ...overrides,
+ }
+}
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('NotificationToasts', () => {
+ it('polls quietly, shows only new notifications, then dismisses them', async () => {
+ vi.useFakeTimers()
+ const oldNotification = notification({ id: 'old-notification' })
+ const newNotification = notification({ id: 'new-notification' })
+ const fetchNotifications = vi
+ .fn()
+ .mockResolvedValueOnce({ items: [oldNotification], links: {} })
+ .mockResolvedValueOnce({
+ items: [newNotification, oldNotification],
+ links: {},
+ })
+ const services = testServices({
+ session: session({
+ token: 'token',
+ me: account(),
+ signedIn: true,
+ }),
+ endpoints: { fetchNotifications },
+ })
+ const view = render(NotificationToasts, {
+ props: { pollIntervalMs: 1_000, dismissAfterMs: 500 },
+ context: new Map([[APP_SERVICES, services]]),
+ })
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+ expect(fetchNotifications).toHaveBeenCalledOnce()
+ expect(view.queryByText('A new mention')).not.toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ })
+
+ expect(fetchNotifications).toHaveBeenCalledTimes(2)
+ expect(fetchNotifications.mock.calls[1][1]).toMatchObject({
+ since_id: 'old-notification',
+ limit: 20,
+ })
+ expect(view.getByRole('link', { name: /Bob mentioned you in an entry/ })).toHaveAttribute(
+ 'href',
+ '#/blog/mentioned-entry',
+ )
+ expect(view.getByText('A new mention')).toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(500)
+ })
+ expect(view.queryByText('A new mention')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/lib/notifications.test.ts b/src/lib/notifications.test.ts
new file mode 100644
index 0000000..23c3fc0
--- /dev/null
+++ b/src/lib/notifications.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { account, status } from '$test/fixtures'
+import type { Notification } from './api/types'
+import { NotificationTracker, presentNotification } from './notifications'
+
+function notification(overrides: Partial = {}): Notification {
+ return {
+ id: 'notification-1',
+ type: 'mention',
+ created_at: '2026-01-01T00:00:00.000Z',
+ account: account({ display_name: 'Bob', acct: 'bob' }),
+ status: status({ id: 'mentioned-entry', content: 'Hello there
' }),
+ ...overrides,
+ }
+}
+
+describe('NotificationTracker', () => {
+ it('uses the first page as a quiet baseline and emits only unseen items later', () => {
+ const tracker = new NotificationTracker()
+ const baseline = notification({ id: 'old' })
+ const fresh = notification({ id: 'new' })
+
+ expect(tracker.ingest([baseline])).toEqual([])
+ expect(tracker.cursor).toBe('old')
+ expect(tracker.ingest([fresh, baseline])).toEqual([fresh])
+ expect(tracker.cursor).toBe('new')
+ expect(tracker.ingest([fresh, baseline])).toEqual([])
+ })
+})
+
+describe('presentNotification', () => {
+ it('links status notifications to the relevant entry', () => {
+ expect(presentNotification(notification())).toEqual({
+ actor: 'Bob',
+ message: 'mentioned you in an entry',
+ excerpt: 'Hello there',
+ href: '#/blog/mentioned-entry',
+ })
+ })
+
+ it('links friend requests to their manager', () => {
+ expect(
+ presentNotification(notification({ type: 'follow_request', status: null })).href,
+ ).toBe('#/mail/requests')
+ })
+})
diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts
new file mode 100644
index 0000000..0afe3df
--- /dev/null
+++ b/src/lib/notifications.ts
@@ -0,0 +1,90 @@
+import type { Notification } from './api/types'
+import { toPlainText } from './util/html'
+import { displayNameOf, profilePath } from './util/profile'
+
+export const NOTIFICATION_POLL_INTERVAL_MS = 45_000
+export const NOTIFICATION_DISMISS_AFTER_MS = 7_000
+
+/** Keeps polling incremental and prevents overlapping server pages from
+ * producing the same toast twice. The first page is baseline only. */
+export class NotificationTracker {
+ private initialized = false
+ private known = new Set()
+ private knownOrder: string[] = []
+
+ cursor: string | undefined
+
+ reset(): void {
+ this.initialized = false
+ this.known = new Set()
+ this.knownOrder = []
+ this.cursor = undefined
+ }
+
+ ingest(items: Notification[]): Notification[] {
+ const fresh = this.initialized
+ ? items.filter((notification) => !this.known.has(notification.id))
+ : []
+
+ for (const notification of items) this.remember(notification.id)
+ // Both Mastodon and Pleroma return notification pages newest-first.
+ if (items[0]) this.cursor = items[0].id
+ this.initialized = true
+ return fresh
+ }
+
+ private remember(id: string): void {
+ if (this.known.has(id)) return
+ this.known.add(id)
+ this.knownOrder.push(id)
+
+ // A long-running tab should not retain an unbounded notification history.
+ while (this.knownOrder.length > 200) {
+ const oldest = this.knownOrder.shift()
+ if (oldest) this.known.delete(oldest)
+ }
+ }
+}
+
+export interface NotificationPresentation {
+ actor: string
+ message: string
+ excerpt: string
+ href: string
+}
+
+const MESSAGE: Record = {
+ mention: 'mentioned you in an entry',
+ status: 'posted a new entry',
+ reblog: 'reposted your entry',
+ follow: 'added you as a friend',
+ follow_request: 'sent you a friend request',
+ favourite: 'gave your entry kudos',
+ poll: 'has a poll that just ended',
+ update: 'edited an entry',
+ 'pleroma:emoji_reaction': 'reacted to your entry',
+ 'admin.sign_up': 'joined the server',
+ 'admin.report': 'was included in a report',
+ 'pleroma:report': 'was included in a report',
+}
+
+export function presentNotification(notification: Notification): NotificationPresentation {
+ const status = notification.status ?? null
+ const excerpt = status
+ ? toPlainText(status.spoiler_text || status.content).slice(0, 100)
+ : ''
+
+ const href =
+ notification.type === 'follow_request'
+ ? '#/mail/requests'
+ : status
+ ? `#/blog/${status.id}`
+ : profilePath(notification.account)
+
+ return {
+ actor: displayNameOf(notification.account),
+ message: MESSAGE[notification.type] ?? 'sent you a notification',
+ excerpt,
+ href,
+ }
+}
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte
index ed3799f..d9d41dc 100644
--- a/src/routes/Settings.svelte
+++ b/src/routes/Settings.svelte
@@ -41,6 +41,7 @@
['.module-body', 'Its contents'],
['.module--band', 'The peach-bar variant used in the main column'],
['.section-heading', '“About me:” style orange headings'],
+ ['.notification-toast-stack, .notification-toast', 'Background notification toasts'],
],
},
{
diff --git a/src/styles/chrome.css b/src/styles/chrome.css
index 9645628..9bf9afd 100644
--- a/src/styles/chrome.css
+++ b/src/styles/chrome.css
@@ -236,6 +236,75 @@
text-align: center;
}
+/* -------------------------------------------------- notification toasts */
+
+.notification-toast-stack {
+ position: fixed;
+ z-index: 1000;
+ top: 8px;
+ right: 8px;
+ width: min(310px, calc(100vw - 16px));
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ pointer-events: none;
+}
+
+.notification-toast {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ padding: 7px;
+ color: var(--ms-page-fg);
+ background: var(--ms-module-bg);
+ border: 1px solid var(--ms-input-border);
+ border-radius: 0;
+ box-shadow: none;
+ text-decoration: none;
+ pointer-events: auto;
+}
+
+.notification-toast:visited {
+ color: var(--ms-page-fg);
+}
+
+.notification-toast:hover,
+.notification-toast:focus-visible {
+ color: var(--ms-page-fg);
+ background: var(--ms-table-stripe-bg);
+ text-decoration: none;
+}
+
+.notification-toast-avatar {
+ flex: 0 0 auto;
+ width: 32px;
+ height: 32px;
+}
+
+.notification-toast-body,
+.notification-toast-message,
+.notification-toast-excerpt {
+ display: block;
+ min-width: 0;
+}
+
+.notification-toast-body {
+ flex: 1;
+}
+
+.notification-toast-message {
+ font-size: var(--ms-font-size);
+}
+
+.notification-toast-excerpt {
+ margin-top: 2px;
+ color: var(--ms-muted-fg);
+ font-size: var(--ms-font-size-small);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
@media (max-width: 640px) {
.site-header-inner {
gap: 6px;
@@ -257,4 +326,10 @@
.site-account-links {
margin-left: auto;
}
+
+ .notification-toast-stack {
+ top: 6px;
+ right: 6px;
+ width: calc(100vw - 12px);
+ }
}