From a5cb8cea469f1d23a17339e17fa99f4c97740bc2 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 15:19:17 +0800 Subject: [PATCH] refactor(web): extract PageShell and apply to every tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single component now owns the page chrome — wrapper width, padding, vertical rhythm, and the page-header row (hidden-on-mobile H1 + an optional right-aligned action slot). Dashboard, Accounts, Reminders, Activity, and Settings all use it, replacing five copies of the same \`
\` markup. Settings was previously \`max-w-2xl\` and \`container mx-auto\`; it now matches the other tabs at 5xl so the chrome stays consistent. Covered by 5 SSR tests (header order, responsive justify utilities, wrapper class, action-optional path). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/activity/page.tsx | 18 +++--- apps/web/src/app/page.tsx | 8 +-- apps/web/src/app/reminders/page.tsx | 14 ++--- apps/web/src/app/settings/page.tsx | 8 +-- .../web/src/components/accounts-list-view.tsx | 14 ++--- apps/web/src/components/page-shell.test.tsx | 59 +++++++++++++++++++ apps/web/src/components/page-shell.tsx | 33 +++++++++++ 7 files changed, 121 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/components/page-shell.test.tsx create mode 100644 apps/web/src/components/page-shell.tsx diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 8ebc06a..187da1c 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -30,6 +30,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PageShell } from "@/components/page-shell"; import { getSeededOperator } from "@/lib/operator"; import { listActivityRuns } from "@/lib/queries"; import { @@ -182,11 +183,10 @@ export default async function ActivityPage({ searchParams }: PageProps) { const hasAny = runs.length > 0; return ( -
-
- {/* Hidden on mobile — the top header already shows "Activity". */} -

Activity

- {hasAny && !showingArchived && ( +
- + ) : undefined + } + > {/* Six tabs (All / Success / Partial / Failed / Skipped / Archived) packed into a phone-width row left every label squeezed to ~50px. Wrap the list in an overflow-x scroller so each tab @@ -417,6 +417,6 @@ export default async function ActivityPage({ searchParams }: PageProps) { )} -
+ ); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 044d595..159d1df 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -38,6 +38,7 @@ import { import { Badge } from "@/components/ui/badge"; import { getSeededOperator } from "@/lib/operator"; import { getDashboardStats } from "@/lib/queries"; +import { PageShell } from "@/components/page-shell"; // --------------------------------------------------------------------------- // Time helpers (no external dep, server-safe) @@ -168,10 +169,7 @@ export default async function DashboardPage() { const hasRuns = stats.recentRuns.length > 0; return ( -
- {/* Hidden on mobile — the top header already shows "Dashboard". */} -

Dashboard

- + {/* Stat cards — click to drill into the corresponding tab */}
)} -
+
); } diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index 2e7e5ab..0688c57 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PageShell } from "@/components/page-shell"; import { getSeededOperator } from "@/lib/operator"; import { listAccounts, listReminders } from "@/lib/queries"; import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; @@ -180,10 +181,9 @@ export default async function RemindersPage({ searchParams }: PageProps) { const hasAnyFilter = Boolean(sp.q || sp.accountId); return ( -
-
- {/* Hidden on mobile — the top header already shows "Reminders". */} -

Reminders

+ New Reminder -
- + } + > ({ id: a.id, label: a.label }))} /> @@ -357,6 +357,6 @@ export default async function RemindersPage({ searchParams }: PageProps) { )} -
+ ); } diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 8a23359..bf54258 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -3,14 +3,12 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { Separator } from "@/components/ui/separator"; import { ThemeToggle } from "@/components/theme-toggle"; import { NotificationsToggle } from "@/components/notifications-toggle"; +import { PageShell } from "@/components/page-shell"; export default async function SettingsPage() { const op = await getSeededOperator(); return ( -
- {/* Hidden on mobile — the top header already shows "Settings". */} -

Settings

- + Operator @@ -53,7 +51,7 @@ export default async function SettingsPage() {

cm WhatsApp Bot · self-hosted

-
+ ); } diff --git a/apps/web/src/components/accounts-list-view.tsx b/apps/web/src/components/accounts-list-view.tsx index 2ed8aa6..2342eff 100644 --- a/apps/web/src/components/accounts-list-view.tsx +++ b/apps/web/src/components/accounts-list-view.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { PageShell } from "@/components/page-shell"; import { Card, CardContent, @@ -29,10 +30,9 @@ interface AccountsListViewProps { */ export function AccountsListView({ accounts }: AccountsListViewProps) { return ( -
-
- {/* Hidden on mobile — the top header already shows "Accounts". */} -

Accounts

+ Add Account -
- + } + > {accounts.length > 0 ? (
)} -
+ ); } diff --git a/apps/web/src/components/page-shell.test.tsx b/apps/web/src/components/page-shell.test.tsx new file mode 100644 index 0000000..63cd7a2 --- /dev/null +++ b/apps/web/src/components/page-shell.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PageShell } from "./page-shell"; + +describe("PageShell", () => { + it("renders the title in an H1 hidden on mobile, visible on desktop", () => { + const html = renderToStaticMarkup( + +

body

+
, + ); + expect(html).toMatch(/]*hidden sm:block[^>]*>Accounts<\/h1>/); + }); + + it("renders the action slot to the right of the H1", () => { + const html = renderToStaticMarkup( + Add}> +

body

+
, + ); + // H1 must come before the action button in document order. + const h1Idx = html.indexOf(" { + const html = renderToStaticMarkup( + cta}> +

+ , + ); + // The header row needs both responsive utilities so the action + // button sits on the right when the H1 is hidden. + expect(html).toMatch(/justify-end sm:justify-between/); + }); + + it("renders children inside the standard wrapper width and padding", () => { + const html = renderToStaticMarkup( + +

hello

+
, + ); + expect(html).toMatch(/max-w-5xl/); + expect(html).toMatch(/px-4 py-6 sm:px-6 sm:py-8/); + expect(html).toContain('data-testid="body"'); + }); + + it("works without an action slot — only the H1 + children render", () => { + const html = renderToStaticMarkup( + +

just a card

+
, + ); + expect(html).toContain("Settings"); + expect(html).toContain("just a card"); + }); +}); diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx new file mode 100644 index 0000000..ad022fc --- /dev/null +++ b/apps/web/src/components/page-shell.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; + +interface PageShellProps { + /** Title shown in the desktop H1. The mobile top bar already shows + * the same string, so the H1 is hidden below `sm:`. */ + title: string; + /** Optional right-aligned action slot — usually a primary CTA + * button. When omitted, the header row collapses to just the H1 + * on desktop and renders nothing on mobile. */ + action?: ReactNode; + children: ReactNode; +} + +/** + * Standard chrome for every top-level tab. Owns the wrapper width, + * page padding, vertical rhythm, and the page-header row (hidden H1 + * on mobile + optional action button on the right). Every tab uses + * this so the header pattern stays consistent without each page + * repeating the same wrapper markup. + */ +export function PageShell({ title, action, children }: PageShellProps) { + return ( +
+
+

+ {title} +

+ {action} +
+ {children} +
+ ); +}