diff --git a/README.md b/README.md
index 7918e06..7c0a305 100644
--- a/README.md
+++ b/README.md
@@ -169,7 +169,8 @@ control:
- `src/app/robots.ts` allows public crawling from `/` and explicitly
disallows operator-only dashboard surfaces: `/admin`, `/api-keys`,
`/webhooks`, and `/settings`.
-- Both metadata routes derive absolute URLs from
+- `src/app/manifest.ts` serves the PWA web app manifest with name, description, branding colors matching the dark/light palette in `src/app/globals.css`, and `favicon.ico` icon entry to enable installability as a PWA.
+- Both `sitemap.ts` and `robots.ts` derive absolute URLs from
`NEXT_PUBLIC_AGENTPAY_SITE_ORIGIN`, defaulting to `http://localhost:3000` for
local development rather than hard-coding a production domain.
diff --git a/docs/components.md b/docs/components.md
index 61df192..0912a22 100644
--- a/docs/components.md
+++ b/docs/components.md
@@ -37,6 +37,23 @@ Use `Header` once in the app shell. It already exposes the nav with
```
+### `PageShell`
+
+| Prop | Type | Required | Notes |
+| --- | --- | --- | --- |
+| `children` | `ReactNode` | yes | Inner content of the page layout wrapper. |
+| `maxWidth` | `"xl" \| "2xl" \| "3xl" \| "4xl" \| "5xl" \| "6xl" \| "7xl" \| string` | no | Suffix of the max-width Tailwind class (e.g. `"3xl"` sets `"max-w-3xl"`). Defaults to `"3xl"`. |
+| `gap` | `"4" \| "6" \| "8" \| "12" \| string` | no | Suffix of the gap Tailwind class (e.g. `"6"` sets `"gap-6"`). Defaults to `"6"`. |
+| `className` | `string` | no | Additional style classes to append. |
+
+PageShell wraps pages inside the `` accessible landmark, providing consistent focus indicators for accessibility skip-links, min-height formatting, and horizontal auto-centering.
+
+```tsx
+
+ Page Title
+
+```
+
### `PageHeading`
| Prop | Type | Required | Notes |
diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx
index 36282ba..3d35e8a 100644
--- a/src/app/docs/page.tsx
+++ b/src/app/docs/page.tsx
@@ -1,3 +1,5 @@
+import { PageShell } from "@/components/PageShell";
+
export const metadata = { title: "Docs — AgentPay" };
const sections = [
@@ -25,11 +27,7 @@ const sections = [
export default function DocsPage() {
return (
-
+
API documentation
Companion to{" "}
@@ -57,6 +55,6 @@ export default function DocsPage() {
))}
-
+
);
}
diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx
index 7713367..5221ec5 100644
--- a/src/app/layout.test.tsx
+++ b/src/app/layout.test.tsx
@@ -1,4 +1,4 @@
-import { metadata } from "./layout";
+import { metadata, viewport } from "./layout";
describe("root layout metadata", () => {
it("keeps the home route on the default AgentPay title", () => {
@@ -7,4 +7,18 @@ describe("root layout metadata", () => {
template: "%s — AgentPay",
});
});
+
+ it("configures the manifest and apple-touch icon in metadata", () => {
+ expect(metadata.manifest).toBe("/manifest.webmanifest");
+ expect(metadata.icons).toMatchObject({
+ apple: "/favicon.ico",
+ });
+ });
+
+ it("configures the themeColor in viewport", () => {
+ expect(viewport.themeColor).toEqual([
+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
+ ]);
+ });
});
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e68e2a4..e831c77 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,4 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/Header";
@@ -16,6 +16,13 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
+export const viewport: Viewport = {
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
+ ],
+};
+
export const metadata: Metadata = {
title: {
default: "AgentPay",
@@ -24,6 +31,10 @@ export const metadata: Metadata = {
description: "Machine-to-machine payment protocol on Stellar",
applicationName: "AgentPay",
authors: [{ name: "AgentPay" }],
+ manifest: "/manifest.webmanifest",
+ icons: {
+ apple: "/favicon.ico",
+ },
openGraph: {
title: "AgentPay",
description: "Pay-per-request billing for AI agents and APIs on Stellar.",
diff --git a/src/app/manifest.test.ts b/src/app/manifest.test.ts
new file mode 100644
index 0000000..eedf254
--- /dev/null
+++ b/src/app/manifest.test.ts
@@ -0,0 +1,27 @@
+import manifest from "./manifest";
+
+describe("manifest metadata route", () => {
+ it("returns a valid manifest configuration", () => {
+ const config = manifest();
+
+ // Required fields assertion
+ expect(config.name).toBe("AgentPay");
+ expect(config.short_name).toBe("AgentPay");
+ expect(config.description).toBe("Machine-to-machine payment protocol on Stellar");
+ expect(config.start_url).toBe("/");
+ expect(config.display).toBe("standalone");
+
+ // Colors matching globals.css dark/light palette
+ const allowedColors = ["#0a0a0a", "#ffffff"];
+ expect(allowedColors).toContain(config.background_color);
+ expect(allowedColors).toContain(config.theme_color);
+
+ // Icon entries check
+ expect(config.icons).toBeDefined();
+ expect(config.icons!.length).toBeGreaterThan(0);
+ const hasFavicon = config.icons!.some(
+ (icon) => icon.src === "/favicon.ico"
+ );
+ expect(hasFavicon).toBe(true);
+ });
+});
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
new file mode 100644
index 0000000..2923e33
--- /dev/null
+++ b/src/app/manifest.ts
@@ -0,0 +1,20 @@
+import type { MetadataRoute } from "next";
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "AgentPay",
+ short_name: "AgentPay",
+ description: "Machine-to-machine payment protocol on Stellar",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#0a0a0a",
+ theme_color: "#0a0a0a",
+ icons: [
+ {
+ src: "/favicon.ico",
+ sizes: "any",
+ type: "image/x-icon",
+ },
+ ],
+ };
+}
diff --git a/src/app/services/new/page.tsx b/src/app/services/new/page.tsx
index 7f438fb..2c46adb 100644
--- a/src/app/services/new/page.tsx
+++ b/src/app/services/new/page.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { apiPost } from "@/lib/apiClient";
+import { PageShell } from "@/components/PageShell";
export default function NewServicePage() {
const router = useRouter();
@@ -31,11 +32,7 @@ export default function NewServicePage() {
};
return (
-
+
New service
)}
-
+
);
}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index c404327..dddb03d 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1,14 +1,11 @@
import { ThemeToggle } from "@/components/ThemeToggle";
+import { PageShell } from "@/components/PageShell";
export const metadata = { title: "Settings — AgentPay" };
export default function SettingsPage() {
return (
-
+
Settings
Appearance
@@ -17,6 +14,6 @@ export default function SettingsPage() {
-
+
);
}
diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx
new file mode 100644
index 0000000..86b2e9e
--- /dev/null
+++ b/src/components/PageShell.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+
+export interface PageShellProps {
+ children: React.ReactNode;
+ /**
+ * Tailwind max-width suffix (e.g. 'xl', '2xl', '3xl', '4xl').
+ * Defaults to '3xl'.
+ */
+ maxWidth?: "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "7xl" | string;
+ /**
+ * Tailwind gap suffix (e.g. '6', '8').
+ * Defaults to '6'.
+ */
+ gap?: "4" | "6" | "8" | "12" | string;
+ /**
+ * Additional className to append/merge.
+ */
+ className?: string;
+}
+
+/**
+ * PageShell component consolidates the repeated main wrapper layout
+ * used across pages to ensure consistent accessibility (skip link target,
+ * focus styling, page spacing) and design language.
+ */
+export function PageShell({
+ children,
+ maxWidth = "3xl",
+ gap = "6",
+ className = "",
+}: PageShellProps) {
+ // Map standard maxWidth keys to class names safely to prevent dynamic class interpolation issues
+ const maxWidthClass = {
+ xl: "max-w-xl",
+ "2xl": "max-w-2xl",
+ "3xl": "max-w-3xl",
+ "4xl": "max-w-4xl",
+ "5xl": "max-w-5xl",
+ "6xl": "max-w-6xl",
+ "7xl": "max-w-7xl",
+ }[maxWidth] || `max-w-${maxWidth}`;
+
+ // Map gap keys to class names safely
+ const gapClass = {
+ "4": "gap-4",
+ "6": "gap-6",
+ "8": "gap-8",
+ "12": "gap-12",
+ }[gap] || `gap-${gap}`;
+
+ const baseClasses = `mx-auto flex min-h-[60vh] ${maxWidthClass} flex-col ${gapClass} p-8 focus:outline-none`;
+ const mergedClasses = className ? `${baseClasses} ${className}` : baseClasses;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/__tests__/PageShell.test.tsx b/src/components/__tests__/PageShell.test.tsx
new file mode 100644
index 0000000..06cdaf0
--- /dev/null
+++ b/src/components/__tests__/PageShell.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { PageShell } from "../PageShell";
+import DocsPage from "@/app/docs/page";
+import SettingsPage from "@/app/settings/page";
+
+const mockMatchMedia = (matches: boolean) => {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ writable: true,
+ value: jest.fn().mockImplementation((query: string) => ({
+ matches,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+};
+
+describe("PageShell Component", () => {
+ beforeEach(() => {
+ mockMatchMedia(false);
+ });
+
+ it("renders a main landmark with default options", () => {
+ render(Test Content);
+
+ const main = screen.getByRole("main");
+ expect(main).toBeInTheDocument();
+ expect(main).toHaveAttribute("id", "main-content");
+ expect(main).toHaveAttribute("tabIndex", "-1");
+ expect(main.className).toContain("mx-auto");
+ expect(main.className).toContain("min-h-[60vh]");
+ expect(main.className).toContain("max-w-3xl");
+ expect(main.className).toContain("gap-6");
+ expect(screen.getByText("Test Content")).toBeInTheDocument();
+ });
+
+ it("handles custom maxWidth values", () => {
+ const { rerender } = render(Content);
+ expect(screen.getByRole("main").className).toContain("max-w-xl");
+
+ rerender(Content);
+ expect(screen.getByRole("main").className).toContain("max-w-2xl");
+
+ rerender(Content);
+ expect(screen.getByRole("main").className).toContain("max-w-7xl");
+
+ rerender(Content);
+ expect(screen.getByRole("main").className).toContain("max-w-custom-500");
+ });
+
+ it("handles custom gap values", () => {
+ const { rerender } = render(Content);
+ expect(screen.getByRole("main").className).toContain("gap-8");
+
+ rerender(Content);
+ expect(screen.getByRole("main").className).toContain("gap-12");
+
+ rerender(Content);
+ expect(screen.getByRole("main").className).toContain("gap-16");
+ });
+
+ it("merges custom className prop", () => {
+ render(Content);
+ const main = screen.getByRole("main");
+ expect(main.className).toContain("custom-class-123");
+ expect(main.className).toContain("max-w-3xl"); // preserves default
+ });
+
+ it("renders migrated DocsPage content inside PageShell", () => {
+ render();
+ const main = screen.getByRole("main");
+ expect(main).toHaveAttribute("id", "main-content");
+ expect(main.className).toContain("max-w-3xl");
+ expect(screen.getByRole("heading", { name: /API documentation/i })).toBeInTheDocument();
+ });
+
+ it("renders migrated SettingsPage content inside PageShell", () => {
+ render();
+ const main = screen.getByRole("main");
+ expect(main).toHaveAttribute("id", "main-content");
+ expect(main.className).toContain("max-w-2xl");
+ expect(screen.getByRole("heading", { name: /Settings/i })).toBeInTheDocument();
+ });
+});