diff --git a/web/src/components/ui/PageHeader.tsx b/web/src/components/ui/PageHeader.tsx
new file mode 100644
index 00000000..51d5108d
--- /dev/null
+++ b/web/src/components/ui/PageHeader.tsx
@@ -0,0 +1,75 @@
+import type { ReactNode } from 'react';
+
+/**
+ * The header strip every list page starts with: icon + title, filter pills,
+ * an optional search box and optional right-hand actions.
+ *
+ * It exists because the pattern had been copy-pasted per page as a single
+ * non-wrapping flex row. That row reaches ~750px once the pills and the
+ * search box are in it, so on a 412px viewport the *page itself* scrolled
+ * sideways and every filter past the third was unreachable.
+ *
+ * The single row returns at `lg`, not `md`. The desktop shell also spends 56px
+ * on the nav rail, so a 768px viewport leaves ~664px of content — less than the
+ * widest header needs (Notifications: nine pills plus two actions). Even `lg`
+ * is not enough for that one, which is why the filter strip keeps
+ * `overflow-x-auto` at every width: a header that outgrows its container
+ * scrolls inside itself instead of spilling into the controls after it.
+ *
+ * Nothing here is reordered with CSS. Below `lg` the actions are rendered in
+ * the title row and the desktop copy is dropped from the DOM (and vice versa),
+ * so tab order follows what is on screen at both sizes — a flex `order` swap
+ * would move them visually while leaving the keyboard to step through every
+ * filter and the search box first.
+ */
+export function PageHeader({ icon, title, filters, search, actions }: {
+ icon?: ReactNode;
+ title: ReactNode;
+ /** Filter pills. Laid out by the caller; scrolled horizontally here. */
+ filters?: ReactNode;
+ search?: ReactNode;
+ actions?: ReactNode;
+}) {
+ return (
+
+ {/* Row one below `lg`: the title, with the page's primary buttons pinned
+ opposite it. With an icon, desktop keeps the 16px icon-to-title gap
+ and the 24px run-out to the filters the per-page headers used. */}
+
+ {icon}
+
{title}
+ {actions && (
+
{actions}
+ )}
+
+
+ {filters && (
+ // The negative margins let the strip run to both screen edges, so a
+ // half-visible pill signals "there is more this way" instead of looking
+ // like a clipped layout. The width has to grow by the same 2rem the
+ // margins take back — `w-full` alone would only shift the strip left
+ // and leave it stopping 32px short of the right edge.
+
+
{filters}
+
+ )}
+
+ {search && (
+
{search}
+ )}
+
+ {/* Desktop copy of the actions: last in the DOM so `ml-auto` pins it to
+ the right edge without dragging the filters and search along with it. */}
+ {actions && (
+
+ {actions}
+
+ )}
+
+ );
+}
diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx
index e9acee93..bfdc892d 100644
--- a/web/src/pages/NotificationsPage.tsx
+++ b/web/src/pages/NotificationsPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, X, CheckCheck, EyeOff, Check, XCircle, Moon, BellOff, Trash2, Plus, RotateCw, Clock } from 'lucide-react';
import { useNotificationStore, type Notification, type Silence } from '../stores/notificationStore';
+import { PageHeader } from '../components/ui/PageHeader';
const STATUS_STYLES: Record = {
pending: 'bg-yellow-400/10 text-hue-yellow border-yellow-400/20',
@@ -450,68 +451,70 @@ export function NotificationsPage() {
return (
-
-
-
Notifications
-
- {/* Status filters */}
-
- {STATUS_FILTERS.map(f => (
-
- ))}
-
-
- {/* Type filters */}
-
- {TYPE_FILTERS.map(f => (
-
- ))}
-
-
- {/* Silences toggle */}
-
-
- {/* Dismiss All */}
- {pendingCount > 0 && (
+ }
+ title="Notifications"
+ filters={
+ <>
+ {/* Status filters */}
+ {STATUS_FILTERS.map(f => (
+
+ ))}
+ {/* Type filters — separated by a rule rather than the old ml-1,
+ which read as one undifferentiated run of pills once they
+ shared a scroller. */}
+
+ {TYPE_FILTERS.map(f => (
+
+ ))}
+ >
+ }
+ actions={
+ <>
- )}
-
-
- {/* Status pills are the list's filter; on the board every status
- is already a lane, so they'd only hide columns. */}
- {!isBoard && }
-
-
+ {/* Status pills are the list's filter; on the board every status
+ is already a lane, so they'd only hide columns. */}
+ {!isBoard && }
+ >
+ }
+ search={
+
)}
-
-
- {!isSearching && !isBoard && (
-
- )}
-
-
-
-
+ }
+ actions={
+ <>
+ {!isSearching && !isBoard && (
+ // The "Sort by" label costs more than it explains once space is
+ // tight; the select still names itself via title/aria-label.
+
+ )}
+
+
+ >
+ }
+ />
{isBoard && }