- {/* 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". */}
-
+
);
}
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 (
+