diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/(app)/approvals/page.tsx
similarity index 94%
rename from apps/web/app/approvals/page.tsx
rename to apps/web/app/(app)/approvals/page.tsx
index 2e3847c..6e497de 100644
--- a/apps/web/app/approvals/page.tsx
+++ b/apps/web/app/(app)/approvals/page.tsx
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
-import { ApprovalCard } from '../../components/approval-card';
-import { ApiUnavailableError, NotAuthenticatedError, fetchApprovals } from '../../lib/api';
+import { ApprovalCard } from '../../../components/approval-card';
+import { ApiUnavailableError, NotAuthenticatedError, fetchApprovals } from '../../../lib/api';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
new file mode 100644
index 0000000..82baa5a
--- /dev/null
+++ b/apps/web/app/(app)/layout.tsx
@@ -0,0 +1,19 @@
+import { BottomNav } from '../../components/bottom-nav';
+
+/**
+ * The signed-in app shell: a narrow reading column and the bottom nav.
+ *
+ * Marketing routes deliberately do not get this — a landing page inside a
+ * 672px app column under a tab bar reads as a broken deployment.
+ */
+export default function AppLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+ {/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */}
+
+ {children}
+
+
+ >
+ );
+}
diff --git a/apps/web/app/more/page.tsx b/apps/web/app/(app)/more/page.tsx
similarity index 94%
rename from apps/web/app/more/page.tsx
rename to apps/web/app/(app)/more/page.tsx
index 5eb2850..29b76c8 100644
--- a/apps/web/app/more/page.tsx
+++ b/apps/web/app/(app)/more/page.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { SignOutButton } from '../../components/sign-out-button';
-import { ApiUnavailableError, NotAuthenticatedError, fetchMe } from '../../lib/api';
+import { SignOutButton } from '../../../components/sign-out-button';
+import { ApiUnavailableError, NotAuthenticatedError, fetchMe } from '../../../lib/api';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/prospects/[id]/page.tsx b/apps/web/app/(app)/prospects/[id]/page.tsx
similarity index 97%
rename from apps/web/app/prospects/[id]/page.tsx
rename to apps/web/app/(app)/prospects/[id]/page.tsx
index 1077c77..71959a3 100644
--- a/apps/web/app/prospects/[id]/page.tsx
+++ b/apps/web/app/(app)/prospects/[id]/page.tsx
@@ -5,8 +5,8 @@ import {
NotAuthenticatedError,
fetchProspect,
relativeTime,
-} from '../../../lib/api';
-import type { ProspectDetail } from '../../../lib/types';
+} from '../../../../lib/api';
+import type { ProspectDetail } from '../../../../lib/types';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/prospects/page.tsx b/apps/web/app/(app)/prospects/page.tsx
similarity index 95%
rename from apps/web/app/prospects/page.tsx
rename to apps/web/app/(app)/prospects/page.tsx
index 319370f..b8e1310 100644
--- a/apps/web/app/prospects/page.tsx
+++ b/apps/web/app/(app)/prospects/page.tsx
@@ -1,8 +1,8 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { AddProspect } from '../../components/add-prospect';
-import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../lib/api';
-import type { ProspectRow } from '../../lib/types';
+import { AddProspect } from '../../../components/add-prospect';
+import { ApiUnavailableError, NotAuthenticatedError, fetchProspects } from '../../../lib/api';
+import type { ProspectRow } from '../../../lib/types';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/signals/page.tsx b/apps/web/app/(app)/signals/page.tsx
similarity index 98%
rename from apps/web/app/signals/page.tsx
rename to apps/web/app/(app)/signals/page.tsx
index d8f5c08..a1cc0ea 100644
--- a/apps/web/app/signals/page.tsx
+++ b/apps/web/app/(app)/signals/page.tsx
@@ -4,7 +4,7 @@ import {
NotAuthenticatedError,
fetchSignals,
relativeTime,
-} from '../../lib/api';
+} from '../../../lib/api';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/today/page.tsx b/apps/web/app/(app)/today/page.tsx
similarity index 97%
rename from apps/web/app/today/page.tsx
rename to apps/web/app/(app)/today/page.tsx
index 303f983..25468b5 100644
--- a/apps/web/app/today/page.tsx
+++ b/apps/web/app/(app)/today/page.tsx
@@ -1,6 +1,6 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
-import { VerifyBanner } from '../../components/verify-banner';
+import { VerifyBanner } from '../../../components/verify-banner';
import {
ApiUnavailableError,
NotAuthenticatedError,
@@ -8,7 +8,7 @@ import {
fetchMe,
fetchSignals,
relativeTime,
-} from '../../lib/api';
+} from '../../../lib/api';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx
new file mode 100644
index 0000000..1574954
--- /dev/null
+++ b/apps/web/app/(marketing)/layout.tsx
@@ -0,0 +1,9 @@
+/**
+ * Public pages: full-bleed, no app chrome.
+ *
+ * Sections manage their own horizontal padding so a band can paint edge to
+ * edge while its content stays on the same measure as everything else.
+ */
+export default function MarketingLayout({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/(marketing)/login/page.tsx
similarity index 88%
rename from apps/web/app/login/page.tsx
rename to apps/web/app/(marketing)/login/page.tsx
index 2aae927..acf4ed2 100644
--- a/apps/web/app/login/page.tsx
+++ b/apps/web/app/(marketing)/login/page.tsx
@@ -1,4 +1,4 @@
-import { LoginForm } from '../../components/login-form';
+import { LoginForm } from '../../../components/login-form';
export const metadata = { title: 'Sign in · OutreachGraph' };
diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx
new file mode 100644
index 0000000..394f4b1
--- /dev/null
+++ b/apps/web/app/(marketing)/page.tsx
@@ -0,0 +1,256 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+import { cookies } from 'next/headers';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata = {
+ title: 'OutreachGraph — you don’t need more contacts, you need a reason to talk',
+ description:
+ 'Find where your prospects are actually active, understand what they care about right now, and get the right moment to engage. Policy-aware, source-backed, human-approved.',
+};
+
+/**
+ * The public landing page (PRD §44).
+ *
+ * The hero is the product's actual output rather than a description of it: an
+ * approval card with a real quote, a source link and the recommended action.
+ * Everything this product claims — grounded personalisation, a visible reason
+ * to reach out, a human in the loop — is legible in that one artifact, and
+ * showing it is more convincing than asserting it.
+ *
+ * Signed-in visitors are sent to their inbox rather than shown marketing. The
+ * check is cookie presence only — a cheap test that avoids an API round trip
+ * on every visit. A stale cookie simply lands on /today, which redirects to
+ * /login itself, so nothing is trusted here beyond routing.
+ */
+export default async function LandingPage() {
+ const jar = await cookies();
+ if (jar.get('og_session')) redirect('/today');
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Content sits on one measure; bands paint full width around it. */
+function Band({ children, className = '' }: { children: React.ReactNode; className?: string }) {
+ return (
+
+
+ You don’t need more contacts. You need a reason to talk.
+
+
+
+ Apollo finds their email. OutreachGraph finds the thing they said forty minutes ago that
+ makes an email worth sending — and refuses to send anything the platform, the person, or
+ your own limits say it shouldn’t.
+
+
+
+
+ Find my first 100 prospects
+
+
+ Sign in
+
+
+
+
+ Free while in beta · No card · Starts from a GitHub handle
+
+
+
+
+
+
+ );
+}
+
+/**
+ * A static replica of the real approval card.
+ *
+ * Deliberately not the live component: this one must render for a logged-out
+ * stranger with no workspace, no prospect and no API call.
+ */
+function ApprovalCardSample() {
+ return (
+
+
+
+
Alex Chen
+
Staff Engineer · Loopwright
+
+
+
92
+
opportunity
+
+
+
+
+
Why now
+
+ “Anyone know a good alternative to Stripe for cross-border payouts? Fees are brutal and
+ settlement takes days.”
+
+
GitHub issue · 3 hours ago · source
+
+
+
+
+ Recommended
+
+
+ Reply in thread — he asked a direct question in
+ public and the thread is still open.
+
+
+
+
+
+ Approve
+
+
+ Edit
+
+
+ Skip
+
+
+
+ );
+}
+
+function EvidenceSection() {
+ const identities = [
+ ['github', '1.00', 'The handle you started from.'],
+ ['x', '0.96', 'Linked from his own GitHub profile field.'],
+ ['website', '0.92', 'Same domain in his bio and his commits.'],
+ ];
+
+ return (
+
+
+ Every claim traces back to something they published.
+
+
+ No stored evidence, no claim. If the draft can’t point at a source it isn’t shown — the card
+ keeps the prospect and the evidence, and you write the message yourself.
+
+ The policy engine is arithmetic, not judgement. No model is in the loop, an unknown
+ network is a deny, and LinkedIn automation isn’t discouraged — it’s unreachable. The
+ check runs again when you approve, not when the card was made.
+
+
+ );
+}
diff --git a/apps/web/app/verify/page.tsx b/apps/web/app/(marketing)/verify/page.tsx
similarity index 92%
rename from apps/web/app/verify/page.tsx
rename to apps/web/app/(marketing)/verify/page.tsx
index 93d795e..16c454d 100644
--- a/apps/web/app/verify/page.tsx
+++ b/apps/web/app/(marketing)/verify/page.tsx
@@ -1,4 +1,4 @@
-import { VerifyForm } from '../../components/verify-form';
+import { VerifyForm } from '../../../components/verify-form';
export const dynamic = 'force-dynamic';
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index d287b4c..330bdd7 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -8,6 +8,12 @@
* query.
*/
@theme {
+ /* Supplied by next/font in the root layout. The fallbacks matter: they are
+ what renders if the font request fails, and a metric-similar stack keeps
+ that from reflowing the page. */
+ --font-sans: var(--font-schibsted), ui-sans-serif, system-ui, sans-serif;
+ --font-mono: var(--font-plex-mono), ui-monospace, Menlo, monospace;
+
--color-surface: #ffffff;
--color-surface-raised: #f6f7f9;
--color-border: #e3e6ea;
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index f6cd3e9..4ce4afd 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,8 +1,31 @@
import type { Metadata, Viewport } from 'next';
-import { BottomNav } from '../components/bottom-nav';
+import { IBM_Plex_Mono, Schibsted_Grotesk } from 'next/font/google';
import { ServiceWorkerRegistrar } from '../components/service-worker-registrar';
import './globals.css';
+/**
+ * Self-hosted by `next/font` at build time, so there is no render-blocking
+ * request to Google and no layout shift when the face arrives.
+ */
+/*
+ * The variable names must differ from Tailwind's `--font-sans` / `--font-mono`.
+ * next/font sets its variable on , which is also `:root`, so reusing the
+ * theme's own name makes `--font-sans: var(--font-sans), …` a self-reference on
+ * one element — an invalid cycle that silently drops the font.
+ */
+const sans = Schibsted_Grotesk({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-schibsted',
+});
+
+const mono = IBM_Plex_Mono({
+ subsets: ['latin'],
+ weight: ['400', '500', '600'],
+ display: 'swap',
+ variable: '--font-plex-mono',
+});
+
export const metadata: Metadata = {
title: 'OutreachGraph',
description: 'Turn public intent signals into warm conversations.',
@@ -30,16 +53,20 @@ export const viewport: Viewport = {
],
};
+/**
+ * The root layout holds only what every route needs.
+ *
+ * The app shell — the narrow column and the bottom nav — lives in `(app)`,
+ * because wrapping the public landing page in a mobile app chrome capped at
+ * `max-w-2xl` made a marketing page render as a 672px strip under a nav bar
+ * for people who were not signed in.
+ */
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
- {/* pb-28 reserves room for the fixed bottom nav plus the home indicator. */}
-
- {children}
-
-
+ {children}
);
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
deleted file mode 100644
index a4ad0a6..0000000
--- a/apps/web/app/page.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-import Link from 'next/link';
-import { redirect } from 'next/navigation';
-import { cookies } from 'next/headers';
-
-export const dynamic = 'force-dynamic';
-
-export const metadata = {
- title: 'OutreachGraph — turn public intent signals into warm conversations',
- description:
- 'Find where your prospects are actually active, understand what they care about right now, and get the right moment to engage. Policy-aware, source-backed, human-approved.',
-};
-
-/**
- * The public landing page (PRD §44).
- *
- * Signed-in visitors are sent to their inbox rather than shown marketing. The
- * check is cookie presence only — a cheap test that avoids an API round trip
- * on every visit. A stale cookie simply lands on /today, which redirects to
- * /login itself, so nothing is trusted here beyond routing.
- */
-export default async function LandingPage() {
- const jar = await cookies();
- if (jar.get('og_session')) redirect('/today');
-
- return (
-
- Find where your prospects actually spend time online, understand what they care about right
- now, and let the system recommend the right moment to engage.
-
-
-
-
- Find my first 100 prospects
-
-
- Sign in
-
-
-
-
- Apollo finds their contact info. OutreachGraph finds where they actually live online — and
- tells you how to start the conversation.
-
- The same human is a GitHub handle, an X account, a personal domain and a job title.
- OutreachGraph links them with stored evidence and a confidence score — and refuses to merge
- on a guess.
-
-
-
- {networks.map((network) => (
-
- {network}
-
- ))}
-
-
-
-
- A real resolution
-
-
-
-
-
-
-
- Linked from what she published herself — not from a name match.
-
- );
-}
-
-function WhyNowSection() {
- const quotes = [
- 'Anyone know a good alternative to…',
- 'We just migrated off…',
- 'This API is killing us.',
- 'We’re hiring a payments engineer.',
- 'What do you use for…?',
- ];
-
- return (
-
-
- Public activity becomes structured intent. Every recommendation names the signal that
- triggered it, with a link to the source.
-
- Nothing goes out without a human. Approval is the default, automation is opt-in, and the
- policy check runs again at the moment you approve — not when the card was created.
-
-
- );
-}
-
-function SafetySection() {
- const guarantees = [
- [
- 'Source-backed personalisation',
- 'No stored evidence, no claim. The system will not invent a shared experience.',
- ],
- [
- 'Platform-aware by construction',
- 'Actions a network prohibits are unreachable, not merely discouraged.',
- ],
- [
- 'Suppression that sticks',
- 'Opting out leaves a tombstone that survives deletion, so no provider can re-ingest someone.',
- ],
- [
- 'Precision over recall',
- 'A wrong-person message costs more than a missed match, so weak evidence never merges.',
- ],
- ];
-
- return (
-
-