From 8b96efdbf66148f5f5d7d9d1fa23a2cb7cc29c2b Mon Sep 17 00:00:00 2001
From: Alex Soffronow Pagonidis
<237136924+alex-clickhouse@users.noreply.github.com>
Date: Sun, 9 Aug 2026 17:11:46 +0000
Subject: [PATCH] Stop the list pages scrolling sideways on a phone
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every list page had copy-pasted the same header: one non-wrapping flex row
holding an icon, a title, one or two runs of filter pills, a search box and
the page's action buttons. Measured at 412px that row reaches ~750px, so
the *page itself* scrolled horizontally and every filter past the third was
unreachable — not clipped-but-scrollable, genuinely unreachable.
/notifications 708px overflow "Answered" unreachable
/tasks 696px overflow "Done" unreachable
/plans 110px overflow
Extract it as a PageHeader and migrate the three worst pages. All three now
measure 0px of page overflow.
Below `lg` the row breaks into title + actions / filters / search, and the
filter strip becomes a horizontal scroller bled to both screen edges, so a
half-visible pill reads as "swipe for more" rather than as a broken layout.
The single row comes back at `lg` rather than `md` because the desktop
shell also spends 56px on the nav rail: a 768px viewport leaves ~664px of
content, and the widest header (Notifications — nine pills plus two
actions) does not fit in it. It does not fit in `lg` either, which is why
the strip keeps `overflow-x-auto` at every width instead of reverting to
`overflow-visible`. A header that outgrows its container scrolls inside
itself; before, it spilled over the controls after it.
Three details worth the extra lines:
- nothing is reordered with CSS. Below `lg` the actions render 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 moves
boxes but not the tab sequence, which would have left the keyboard
stepping through every filter and the search box before reaching the
buttons sitting beside the title.
- the bled strip is `calc(100% + 2rem)` wide, not `w-full`. The negative
margins are 2rem in total, so a full-width strip would only have been
shifted left — reaching the left edge but stopping 32px short of the
right, which is the edge the half-pill affordance is for.
- with an icon, desktop keeps the 16px icon-to-title gap and the 24px
run-out to the filters that /plans and /notifications already had; the
shared wrapper would otherwise have tightened them to 8px and 16px.
/tasks has no icon and keeps its 16px title-to-filter gap either way.
The buttons whose labels collapse to icons at `sm` now carry title and
aria-label, so they do not become unnamed icon buttons.
/notifications gains a divider between its status and type pills: `ml-1`
had been enough to separate the two groups only while they sat on a roomy
row, and read as one undifferentiated run once they shared a scroller.
/memory and /cron use the same header shape and are not migrated here —
they need their multi-pane bodies stacked in the same pass.
Refs #271
---
web/src/components/ui/PageHeader.tsx | 75 ++++++++++++++++
web/src/pages/NotificationsPage.tsx | 121 ++++++++++++-------------
web/src/pages/PlansPage.tsx | 37 ++++----
web/src/pages/TasksPage.tsx | 127 +++++++++++++++------------
4 files changed, 226 insertions(+), 134 deletions(-)
create mode 100644 web/src/components/ui/PageHeader.tsx
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 && }