[object Object]

← back to Homesonspec

consumer web app (search/detail/community/builder/contact) + admin app (review/sources/validation/snapshots) + publisher package extraction; both apps serving

6b477e66bce9c4d5dd158cae60df6692e3b3708a · 2026-07-22 11:37:04 -0700 · Steve Abrams

Files touched

Diff

commit 6b477e66bce9c4d5dd158cae60df6692e3b3708a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 11:37:04 2026 -0700

    consumer web app (search/detail/community/builder/contact) + admin app (review/sources/validation/snapshots) + publisher package extraction; both apps serving
---
 apps/admin/next-env.d.ts                       |   6 +
 apps/admin/next.config.ts                      |   1 +
 apps/admin/package.json                        |   3 +-
 apps/admin/src/app/api/review/route.ts         |  48 ++++
 apps/admin/src/app/corrections/page.tsx        |  34 +++
 apps/admin/src/app/layout.tsx                  |  31 +++
 apps/admin/src/app/leads/page.tsx              |  36 +++
 apps/admin/src/app/page.tsx                    |  86 +++++++
 apps/admin/src/app/review/ReviewActions.tsx    |  48 ++++
 apps/admin/src/app/review/page.tsx             |  88 +++++++
 apps/admin/src/app/snapshots/page.tsx          |  45 ++++
 apps/admin/src/app/sources/page.tsx            |  49 ++++
 apps/admin/src/app/validation/page.tsx         |  60 +++++
 apps/admin/src/middleware.ts                   |  23 ++
 apps/admin/tsconfig.json                       |  44 +++-
 apps/web/next-env.d.ts                         |   6 +
 apps/web/next.config.ts                        |   1 +
 apps/web/src/app/[state]/[city]/page.tsx       |  45 ++++
 apps/web/src/app/api/corrections/route.ts      |  21 ++
 apps/web/src/app/api/facets/route.ts           |  11 +
 apps/web/src/app/api/leads/route.ts            |  23 ++
 apps/web/src/app/api/search/route.ts           |  11 +
 apps/web/src/app/builders/[slug]/page.tsx      |  60 +++++
 apps/web/src/app/communities/[slug]/page.tsx   | 104 ++++++++
 apps/web/src/app/compare/page.tsx              |  54 +++++
 apps/web/src/app/contact/ContactClient.tsx     |  74 ++++++
 apps/web/src/app/contact/page.tsx              |  12 +
 apps/web/src/app/homes/[id]/CorrectionForm.tsx |  75 ++++++
 apps/web/src/app/homes/[id]/LeadForm.tsx       |  59 +++++
 apps/web/src/app/homes/[id]/page.tsx           | 181 ++++++++++++++
 apps/web/src/app/layout.tsx                    |  43 ++++
 apps/web/src/app/page.tsx                      |  63 +++++
 apps/web/src/app/plans/[id]/page.tsx           |  50 ++++
 apps/web/src/app/saved/page.tsx                |  32 +++
 apps/web/src/app/search/SearchClient.tsx       | 323 +++++++++++++++++++++++++
 apps/web/src/app/search/page.tsx               |  12 +
 apps/web/src/lib/parse-search.ts               |  45 ++++
 apps/web/tsconfig.json                         |  44 +++-
 apps/workers/package.json                      |  10 +-
 apps/workers/src/cli.ts                        |   2 +-
 apps/workers/src/index.ts                      |   2 +-
 apps/workers/src/pipeline.ts                   | 134 +---------
 collectors/common/src/index.ts                 |   2 +-
 collectors/meridian-homes/src/extract.test.ts  |   2 +-
 packages/publisher/package.json                |  14 ++
 packages/publisher/src/index.ts                | 136 +++++++++++
 packages/publisher/tsconfig.json               |   1 +
 packages/search/src/index.ts                   | 258 ++++++++++++++++++++
 packages/shared-ui/src/MapView.tsx             |  96 ++++++++
 packages/shared-ui/src/badges.tsx              |  49 ++++
 packages/shared-ui/src/index.ts                |   2 +
 packages/shared/src/canonical.test.ts          |   2 +-
 packages/shared/src/geo.test.ts                |   2 +-
 packages/shared/src/index.ts                   |   6 +-
 packages/validation/src/engine.ts              |   2 +-
 packages/validation/src/index.ts               |   6 +-
 packages/validation/src/rules.test.ts          |   6 +-
 packages/validation/src/rules.ts               |   2 +-
 pnpm-lock.yaml                                 |  25 ++
 tsconfig.base.json                             |  41 +++-
 60 files changed, 2576 insertions(+), 175 deletions(-)

diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/apps/admin/next-env.d.ts
@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts
index c63dd4e..9be57ea 100644
--- a/apps/admin/next.config.ts
+++ b/apps/admin/next.config.ts
@@ -12,6 +12,7 @@ const nextConfig: NextConfig = {
     "@spechomes/search",
     "@spechomes/shared",
     "@spechomes/shared-ui",
+    "@spechomes/publisher",
   ],
 };
 
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 06f4a39..1ff619d 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -20,7 +20,8 @@
     "next": "16.2.10",
     "react": "19.2.7",
     "react-dom": "19.2.7",
-    "zod": "^3.24.1"
+    "zod": "^3.24.1",
+    "@spechomes/publisher": "workspace:*"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4.0.0",
diff --git a/apps/admin/src/app/api/review/route.ts b/apps/admin/src/app/api/review/route.ts
new file mode 100644
index 0000000..a611829
--- /dev/null
+++ b/apps/admin/src/app/api/review/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@spechomes/database";
+import { publishStagedRecord } from "@spechomes/publisher";
+
+const actionSchema = z.object({
+  reviewItemId: z.string(),
+  stagedRecordId: z.string(),
+  action: z.enum(["approve", "reject"]),
+});
+
+/**
+ * Human review resolution. Approve = staged record becomes APPROVED and the
+ * publish stage (the only published-tables writer) runs. Reject = REJECTED,
+ * never published. Either way the ReviewItem records who/when.
+ */
+export async function POST(request: NextRequest) {
+  const parsed = actionSchema.safeParse(await request.json().catch(() => null));
+  if (!parsed.success) return NextResponse.json({ error: "invalid request" }, { status: 400 });
+  const { reviewItemId, stagedRecordId, action } = parsed.data;
+
+  const item = await prisma.reviewItem.findUnique({ where: { id: reviewItemId } });
+  if (!item || item.status !== "OPEN") {
+    return NextResponse.json({ error: "review item not open" }, { status: 409 });
+  }
+
+  if (action === "approve") {
+    await prisma.stagedRecord.update({ where: { id: stagedRecordId }, data: { status: "APPROVED" } });
+    try {
+      await publishStagedRecord(stagedRecordId);
+    } catch (error) {
+      return NextResponse.json({ error: `publish failed: ${String(error)}` }, { status: 500 });
+    }
+  } else {
+    await prisma.stagedRecord.update({ where: { id: stagedRecordId }, data: { status: "REJECTED" } });
+  }
+
+  await prisma.reviewItem.update({
+    where: { id: reviewItemId },
+    data: {
+      status: action === "approve" ? "APPROVED" : "REJECTED",
+      reviewer: "admin",
+      resolvedAt: new Date(),
+    },
+  });
+
+  return NextResponse.json({ ok: true });
+}
diff --git a/apps/admin/src/app/corrections/page.tsx b/apps/admin/src/app/corrections/page.tsx
new file mode 100644
index 0000000..81681de
--- /dev/null
+++ b/apps/admin/src/app/corrections/page.tsx
@@ -0,0 +1,34 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function CorrectionsPage() {
+  const reports = await prisma.correctionReport.findMany({ orderBy: { createdAt: "desc" }, take: 100 });
+
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Correction reports</h1>
+      <p className="mt-1 text-sm text-neutral-500">Consumer-submitted "report a problem" items.</p>
+      <div className="mt-4 space-y-3">
+        {reports.map((report) => (
+          <div key={report.id} className="rounded-xl border border-neutral-200 bg-white p-4 text-sm shadow-sm">
+            <div className="flex items-center justify-between">
+              <span className="font-mono text-xs text-neutral-500">
+                {report.entityType} · {report.entityId.slice(0, 10)}… {report.field ? `· ${report.field}` : ""}
+              </span>
+              <CreatedChip date={report.createdAt} />
+            </div>
+            <p className="mt-2">{report.message}</p>
+            {report.reporterEmail && <p className="mt-1 text-xs text-neutral-500">From: {report.reporterEmail}</p>}
+            <span className={`mt-2 inline-block rounded-full px-2 py-0.5 text-xs ${
+              report.status === "OPEN" ? "bg-amber-100 text-amber-800" : "bg-neutral-100 text-neutral-600"}`}>
+              {report.status}
+            </span>
+          </div>
+        ))}
+        {reports.length === 0 && <p className="text-neutral-500">No reports.</p>}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
new file mode 100644
index 0000000..5a01566
--- /dev/null
+++ b/apps/admin/src/app/layout.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "SpecHomes Admin",
+  robots: { index: false, follow: false },
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="en">
+      <body className="min-h-screen bg-neutral-50 text-neutral-900 antialiased">
+        <header className="border-b border-neutral-200 bg-white">
+          <nav className="mx-auto flex max-w-7xl items-center gap-6 px-4 py-3 text-sm">
+            <Link href="/" className="text-lg font-bold text-neutral-900">
+              SpecHomes <span className="text-teal-700">Admin</span>
+            </Link>
+            <Link href="/review" className="hover:text-teal-700">Review queue</Link>
+            <Link href="/sources" className="hover:text-teal-700">Sources</Link>
+            <Link href="/validation" className="hover:text-teal-700">Validation log</Link>
+            <Link href="/snapshots" className="hover:text-teal-700">Snapshots</Link>
+            <Link href="/corrections" className="hover:text-teal-700">Corrections</Link>
+            <Link href="/leads" className="hover:text-teal-700">Leads</Link>
+          </nav>
+        </header>
+        <main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
+      </body>
+    </html>
+  );
+}
diff --git a/apps/admin/src/app/leads/page.tsx b/apps/admin/src/app/leads/page.tsx
new file mode 100644
index 0000000..c9d8c4d
--- /dev/null
+++ b/apps/admin/src/app/leads/page.tsx
@@ -0,0 +1,36 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function LeadsPage() {
+  const leads = await prisma.lead.findMany({
+    orderBy: { createdAt: "desc" },
+    take: 100,
+    include: { home: { select: { street: true, city: true, state: true } } },
+  });
+
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Leads</h1>
+      <p className="mt-1 text-sm text-neutral-500">Builder inquiries recorded by the contact forms (delivery deferred in v1).</p>
+      <div className="mt-4 space-y-3">
+        {leads.map((lead) => (
+          <div key={lead.id} className="rounded-xl border border-neutral-200 bg-white p-4 text-sm shadow-sm">
+            <div className="flex items-center justify-between">
+              <span><span className="font-medium">{lead.name}</span> <span className="text-neutral-500">({lead.email}{lead.phone ? ` · ${lead.phone}` : ""})</span></span>
+              <CreatedChip date={lead.createdAt} />
+            </div>
+            {lead.home && (
+              <p className="mt-1 text-xs text-neutral-500">
+                Re: {lead.home.street}, {lead.home.city}, {lead.home.state}
+              </p>
+            )}
+            {lead.message && <p className="mt-2 text-neutral-700">{lead.message}</p>}
+          </div>
+        ))}
+        {leads.length === 0 && <p className="text-neutral-500">No leads yet.</p>}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
new file mode 100644
index 0000000..3a9cc97
--- /dev/null
+++ b/apps/admin/src/app/page.tsx
@@ -0,0 +1,86 @@
+import Link from "next/link";
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function AdminDashboard() {
+  const [openReviews, publishedHomes, staleHomes, sources, openCorrections, recentLeads, recentEvents] =
+    await Promise.all([
+      prisma.reviewItem.count({ where: { status: "OPEN" } }),
+      prisma.inventoryHome.count({ where: { status: "PUBLISHED" } }),
+      prisma.inventoryHome.count({ where: { freshness: { in: ["STALE", "REVIEW_REQUIRED"] } } }),
+      prisma.sourceRegistry.findMany({ orderBy: { key: "asc" } }),
+      prisma.correctionReport.count({ where: { status: "OPEN" } }),
+      prisma.lead.findMany({ orderBy: { createdAt: "desc" }, take: 5 }),
+      prisma.validationEvent.findMany({ where: { passed: false }, orderBy: { runAt: "desc" }, take: 5 }),
+    ]);
+
+  const stats: [string, number | string, string][] = [
+    ["Published homes", publishedHomes, "/validation"],
+    ["Open review items", openReviews, "/review"],
+    ["Stale / review-required", staleHomes, "/validation"],
+    ["Open correction reports", openCorrections, "/corrections"],
+  ];
+
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Operations dashboard</h1>
+      <div className="mt-4 grid gap-4 sm:grid-cols-4">
+        {stats.map(([label, value, href]) => (
+          <Link key={label} href={href} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm hover:border-teal-400">
+            <div className="text-3xl font-bold">{value}</div>
+            <div className="mt-1 text-sm text-neutral-500">{label}</div>
+          </Link>
+        ))}
+      </div>
+
+      <h2 className="mt-8 text-lg font-semibold">Source health</h2>
+      <div className="mt-3 grid gap-3 sm:grid-cols-3">
+        {sources.map((source) => (
+          <div key={source.id} className="rounded-lg border border-neutral-200 bg-white p-3 text-sm shadow-sm">
+            <div className="flex items-center justify-between">
+              <span className="font-semibold">{source.name}</span>
+              <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
+                source.health === "HEALTHY" ? "bg-emerald-100 text-emerald-800" :
+                source.health === "DEGRADED" ? "bg-amber-100 text-amber-800" :
+                "bg-red-100 text-red-800"}`}>
+                {source.health}
+              </span>
+            </div>
+            <div className="mt-1 text-xs text-neutral-500">{source.collectionMethod} · media rights: {source.mediaRights}</div>
+            <div className="mt-2"><CreatedChip date={source.createdAt} /></div>
+          </div>
+        ))}
+      </div>
+
+      <div className="mt-8 grid gap-6 lg:grid-cols-2">
+        <section>
+          <h2 className="text-lg font-semibold">Recent failed validations</h2>
+          <ul className="mt-2 space-y-2 text-sm">
+            {recentEvents.map((event) => (
+              <li key={event.id} className="rounded-lg border border-neutral-200 bg-white p-3 shadow-sm">
+                <span className="font-mono text-xs text-red-700">{event.ruleId}</span>
+                <p className="mt-0.5 text-neutral-600">{event.message}</p>
+                <div className="mt-1"><CreatedChip date={event.runAt} /></div>
+              </li>
+            ))}
+            {recentEvents.length === 0 && <li className="text-neutral-400">None.</li>}
+          </ul>
+        </section>
+        <section>
+          <h2 className="text-lg font-semibold">Recent leads</h2>
+          <ul className="mt-2 space-y-2 text-sm">
+            {recentLeads.map((lead) => (
+              <li key={lead.id} className="rounded-lg border border-neutral-200 bg-white p-3 shadow-sm">
+                <span className="font-medium">{lead.name}</span> <span className="text-neutral-500">({lead.email})</span>
+                <div className="mt-1"><CreatedChip date={lead.createdAt} /></div>
+              </li>
+            ))}
+            {recentLeads.length === 0 && <li className="text-neutral-400">None yet.</li>}
+          </ul>
+        </section>
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/review/ReviewActions.tsx b/apps/admin/src/app/review/ReviewActions.tsx
new file mode 100644
index 0000000..4954bec
--- /dev/null
+++ b/apps/admin/src/app/review/ReviewActions.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+export default function ReviewActions({
+  reviewItemId,
+  stagedRecordId,
+}: {
+  reviewItemId: string;
+  stagedRecordId: string;
+}) {
+  const router = useRouter();
+  const [busy, setBusy] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const act = async (action: "approve" | "reject") => {
+    setBusy(true);
+    setError(null);
+    const res = await fetch("/api/review", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ reviewItemId, stagedRecordId, action }),
+    });
+    setBusy(false);
+    if (!res.ok) {
+      setError((await res.json().catch(() => null))?.error ?? "action failed");
+      return;
+    }
+    router.refresh();
+  };
+
+  return (
+    <div className="mt-3 flex items-center gap-3">
+      <button onClick={() => act("approve")} disabled={busy}
+        className="rounded bg-emerald-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-emerald-800 disabled:opacity-50"
+        data-testid="review-approve">
+        Approve & publish
+      </button>
+      <button onClick={() => act("reject")} disabled={busy}
+        className="rounded bg-red-700 px-4 py-1.5 text-sm font-medium text-white hover:bg-red-800 disabled:opacity-50"
+        data-testid="review-reject">
+        Reject
+      </button>
+      {error && <span className="text-sm text-red-600">{error}</span>}
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/review/page.tsx b/apps/admin/src/app/review/page.tsx
new file mode 100644
index 0000000..5051809
--- /dev/null
+++ b/apps/admin/src/app/review/page.tsx
@@ -0,0 +1,88 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+import ReviewActions from "./ReviewActions";
+
+export const dynamic = "force-dynamic";
+
+/** Review queue: staged record + per-field evidence side-by-side, approve/reject. */
+export default async function ReviewQueuePage() {
+  const items = await prisma.reviewItem.findMany({
+    where: { status: "OPEN" },
+    orderBy: { createdAt: "asc" },
+    include: {
+      stagedRecord: {
+        include: {
+          evidence: { orderBy: { field: "asc" } },
+          events: { where: { passed: false } },
+          source: { select: { name: true, key: true } },
+        },
+      },
+    },
+  });
+
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Review queue</h1>
+      <p className="mt-1 text-sm text-neutral-500">
+        Records validators flagged for human judgment. Approving publishes; rejecting keeps them out.
+      </p>
+      {items.length === 0 && (
+        <p className="mt-8 rounded-lg border border-neutral-200 bg-white p-6 text-center text-neutral-500" data-testid="review-empty">
+          Queue is clear.
+        </p>
+      )}
+      <div className="mt-4 space-y-4">
+        {items.map((item) => {
+          const staged = item.stagedRecord;
+          const payload = (staged?.payload ?? {}) as Record<string, { value: unknown; raw: string | null }>;
+          return (
+            <div key={item.id} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm" data-testid="review-card">
+              <div className="flex flex-wrap items-center justify-between gap-2">
+                <div>
+                  <span className="font-semibold">{staged?.entityType ?? "—"}</span>
+                  <span className="ml-2 font-mono text-xs text-neutral-400">{staged?.canonicalKey.slice(0, 12)}…</span>
+                  <span className="ml-2 text-xs text-neutral-500">{staged?.source.name}</span>
+                </div>
+                {/* Standing rule: every admin card shows created date + time. */}
+                <CreatedChip date={item.createdAt} />
+              </div>
+              <p className="mt-2 rounded bg-amber-50 p-2 text-sm text-amber-900">{item.reason}</p>
+
+              <div className="mt-3 grid gap-4 md:grid-cols-2">
+                <div>
+                  <h3 className="text-xs font-semibold uppercase text-neutral-500">Extracted values</h3>
+                  <table className="mt-1 w-full text-xs">
+                    <tbody>
+                      {Object.entries(payload).map(([field, envelope]) => (
+                        <tr key={field} className="border-b border-neutral-100">
+                          <td className="py-1 pr-2 font-medium">{field}</td>
+                          <td className="py-1">{envelope.value === null ? <em className="text-neutral-400">null</em> : String(envelope.value)}</td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+                <div>
+                  <h3 className="text-xs font-semibold uppercase text-neutral-500">Source evidence</h3>
+                  <table className="mt-1 w-full text-xs">
+                    <tbody>
+                      {(staged?.evidence ?? []).map((row) => (
+                        <tr key={row.id} className="border-b border-neutral-100">
+                          <td className="py-1 pr-2 font-medium">{row.field}</td>
+                          <td className="py-1 text-neutral-600">{row.evidenceText ?? <em className="text-neutral-400">not stated</em>}</td>
+                          <td className="py-1 pl-2 text-neutral-400">{(row.confidence * 100).toFixed(0)}%</td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+
+              {staged && <ReviewActions reviewItemId={item.id} stagedRecordId={staged.id} />}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/snapshots/page.tsx b/apps/admin/src/app/snapshots/page.tsx
new file mode 100644
index 0000000..100cc52
--- /dev/null
+++ b/apps/admin/src/app/snapshots/page.tsx
@@ -0,0 +1,45 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function SnapshotsPage() {
+  const snapshots = await prisma.rawSnapshot.findMany({
+    orderBy: { retrievedAt: "desc" },
+    take: 100,
+    include: { source: { select: { name: true } }, _count: { select: { staged: true } } },
+  });
+
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Raw snapshots</h1>
+      <p className="mt-1 text-sm text-neutral-500">
+        Immutable fetched bodies (stored on disk) — the audit trail behind every extraction.
+      </p>
+      <div className="mt-4 overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm">
+        <table className="w-full text-left text-sm">
+          <thead className="border-b border-neutral-200 text-xs uppercase text-neutral-500">
+            <tr>
+              <th className="px-3 py-2">Source</th>
+              <th className="px-3 py-2">URL</th>
+              <th className="px-3 py-2">Hash</th>
+              <th className="px-3 py-2">Staged</th>
+              <th className="px-3 py-2">Retrieved</th>
+            </tr>
+          </thead>
+          <tbody>
+            {snapshots.map((snapshot) => (
+              <tr key={snapshot.id} className="border-b border-neutral-100">
+                <td className="px-3 py-1.5 text-xs">{snapshot.source.name}</td>
+                <td className="max-w-md truncate px-3 py-1.5 text-xs text-neutral-600">{snapshot.url}</td>
+                <td className="px-3 py-1.5 font-mono text-xs">{snapshot.contentHash.slice(0, 12)}…</td>
+                <td className="px-3 py-1.5">{snapshot._count.staged}</td>
+                <td className="px-3 py-1.5"><CreatedChip date={snapshot.retrievedAt} /></td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/sources/page.tsx b/apps/admin/src/app/sources/page.tsx
new file mode 100644
index 0000000..1064f9b
--- /dev/null
+++ b/apps/admin/src/app/sources/page.tsx
@@ -0,0 +1,49 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function SourcesPage() {
+  const sources = await prisma.sourceRegistry.findMany({
+    orderBy: { key: "asc" },
+    include: { _count: { select: { snapshots: true, staged: true, homes: true } } },
+  });
+
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Source registry</h1>
+      <p className="mt-1 text-sm text-neutral-500">
+        Every source records its collection method, permissions, media rights, rate limits, and health.
+        Publishing stops automatically when a source degrades.
+      </p>
+      <div className="mt-4 grid gap-4 md:grid-cols-2">
+        {sources.map((source) => (
+          <div key={source.id} className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm" data-testid="source-card">
+            <div className="flex items-center justify-between">
+              <h2 className="font-semibold">{source.name}</h2>
+              <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
+                source.health === "HEALTHY" ? "bg-emerald-100 text-emerald-800" :
+                source.health === "DEGRADED" ? "bg-amber-100 text-amber-800" :
+                source.health === "PAUSED" ? "bg-neutral-200 text-neutral-600" :
+                "bg-red-100 text-red-800"}`}>
+                {source.health}
+              </span>
+            </div>
+            <dl className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
+              <div><dt className="text-neutral-500">Key</dt><dd className="font-mono">{source.key}</dd></div>
+              <div><dt className="text-neutral-500">Method</dt><dd>{source.collectionMethod}</dd></div>
+              <div><dt className="text-neutral-500">Media rights</dt><dd>{source.mediaRights}</dd></div>
+              <div><dt className="text-neutral-500">Rate limit</dt><dd>{source.rateLimitRpm ? `${source.rateLimitRpm}/min` : "n/a"}</dd></div>
+              <div><dt className="text-neutral-500">Fresh interval</dt><dd>{source.freshIntervalHours}h</dd></div>
+              <div><dt className="text-neutral-500">Aging interval</dt><dd>{source.agingIntervalHours}h</dd></div>
+              <div><dt className="text-neutral-500">Snapshots</dt><dd>{source._count.snapshots}</dd></div>
+              <div><dt className="text-neutral-500">Staged / homes</dt><dd>{source._count.staged} / {source._count.homes}</dd></div>
+            </dl>
+            {source.notes && <p className="mt-2 text-xs text-neutral-500">{source.notes}</p>}
+            <div className="mt-2"><CreatedChip date={source.createdAt} /></div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/validation/page.tsx b/apps/admin/src/app/validation/page.tsx
new file mode 100644
index 0000000..9deff0d
--- /dev/null
+++ b/apps/admin/src/app/validation/page.tsx
@@ -0,0 +1,60 @@
+import { prisma } from "@spechomes/database";
+import { CreatedChip } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function ValidationLogPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ failed?: string }>;
+}) {
+  const { failed } = await searchParams;
+  const onlyFailed = failed === "1";
+  const events = await prisma.validationEvent.findMany({
+    where: onlyFailed ? { passed: false } : {},
+    orderBy: { runAt: "desc" },
+    take: 100,
+    include: { stagedRecord: { select: { entityType: true, canonicalKey: true } } },
+  });
+
+  return (
+    <div>
+      <div className="flex items-center justify-between">
+        <h1 className="text-2xl font-bold">Validation events</h1>
+        <a href={onlyFailed ? "/validation" : "/validation?failed=1"} className="text-sm text-teal-700 underline">
+          {onlyFailed ? "Show all" : "Failed only"}
+        </a>
+      </div>
+      <div className="mt-4 overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm">
+        <table className="w-full text-left text-sm">
+          <thead className="border-b border-neutral-200 text-xs uppercase text-neutral-500">
+            <tr>
+              <th className="px-3 py-2">Rule</th>
+              <th className="px-3 py-2">Severity</th>
+              <th className="px-3 py-2">Result</th>
+              <th className="px-3 py-2">Entity</th>
+              <th className="px-3 py-2">Message</th>
+              <th className="px-3 py-2">Run</th>
+            </tr>
+          </thead>
+          <tbody>
+            {events.map((event) => (
+              <tr key={event.id} className="border-b border-neutral-100">
+                <td className="px-3 py-1.5 font-mono text-xs">{event.ruleId}</td>
+                <td className="px-3 py-1.5">{event.severity}</td>
+                <td className="px-3 py-1.5">
+                  <span className={event.passed ? "text-emerald-700" : "font-semibold text-red-700"}>
+                    {event.passed ? "pass" : "FAIL"}
+                  </span>
+                </td>
+                <td className="px-3 py-1.5 text-xs">{event.stagedRecord.entityType}</td>
+                <td className="px-3 py-1.5 text-xs text-neutral-600">{event.message ?? "—"}</td>
+                <td className="px-3 py-1.5"><CreatedChip date={event.runAt} /></td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}
diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts
new file mode 100644
index 0000000..a263729
--- /dev/null
+++ b/apps/admin/src/middleware.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * HTTP Basic Auth for the whole admin app (fleet next-auth-gate convention).
+ * Credentials from BASIC_AUTH env ("user:pass").
+ */
+const [USER, PASS] = (process.env.BASIC_AUTH ?? "admin:REDACTED_ADMIN_PW").split(":");
+
+export function middleware(request: NextRequest) {
+  const header = request.headers.get("authorization");
+  if (header?.startsWith("Basic ")) {
+    const [user, pass] = Buffer.from(header.slice(6), "base64").toString().split(":");
+    if (user === USER && pass === PASS) return NextResponse.next();
+  }
+  return new NextResponse("Authentication required", {
+    status: 401,
+    headers: { "WWW-Authenticate": 'Basic realm="SpecHomes Admin"' },
+  });
+}
+
+export const config = {
+  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
+};
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
index b8eeb84..840f56f 100644
--- a/apps/admin/tsconfig.json
+++ b/apps/admin/tsconfig.json
@@ -5,16 +5,42 @@
     "allowJs": true,
     "incremental": true,
     "module": "esnext",
-    "plugins": [{ "name": "next" }],
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
     "paths": {
-      "@/*": ["./src/*"],
-      "@spechomes/database": ["../../packages/database/src/index.ts"],
-      "@spechomes/schemas": ["../../packages/schemas/src/index.ts"],
-      "@spechomes/search": ["../../packages/search/src/index.ts"],
-      "@spechomes/shared": ["../../packages/shared/src/index.ts"],
-      "@spechomes/shared-ui": ["../../packages/shared-ui/src/index.ts"]
+      "@/*": [
+        "./src/*"
+      ],
+      "@spechomes/database": [
+        "../../packages/database/src/index.ts"
+      ],
+      "@spechomes/schemas": [
+        "../../packages/schemas/src/index.ts"
+      ],
+      "@spechomes/search": [
+        "../../packages/search/src/index.ts"
+      ],
+      "@spechomes/shared": [
+        "../../packages/shared/src/index.ts"
+      ],
+      "@spechomes/shared-ui": [
+        "../../packages/shared-ui/src/index.ts"
+      ],
+      "@spechomes/publisher": [
+        "../../packages/publisher/src/index.ts"
+      ]
     }
   },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
-  "exclude": ["node_modules"]
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
 }
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 0000000..c4b7818
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index c63dd4e..9be57ea 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -12,6 +12,7 @@ const nextConfig: NextConfig = {
     "@spechomes/search",
     "@spechomes/shared",
     "@spechomes/shared-ui",
+    "@spechomes/publisher",
   ],
 };
 
diff --git a/apps/web/src/app/[state]/[city]/page.tsx b/apps/web/src/app/[state]/[city]/page.tsx
new file mode 100644
index 0000000..c66ddd3
--- /dev/null
+++ b/apps/web/src/app/[state]/[city]/page.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+/** Minimal city/metro SEO landing — full editorial treatment deferred. */
+export default async function CityLandingPage({
+  params,
+}: {
+  params: Promise<{ state: string; city: string }>;
+}) {
+  const { state, city } = await params;
+  const stateUpper = state.toUpperCase();
+  if (!/^[a-z]{2}$/i.test(state)) notFound();
+  const cityName = decodeURIComponent(city).replace(/-/g, " ");
+
+  const homes = await prisma.inventoryHome.findMany({
+    where: { state: stateUpper, city: { equals: cityName, mode: "insensitive" }, status: "PUBLISHED" },
+    include: { community: { select: { slug: true, name: true } }, builder: { select: { name: true } } },
+    orderBy: { publishedAt: "desc" },
+    take: 24,
+  });
+  if (homes.length === 0) notFound();
+
+  return (
+    <div className="mx-auto max-w-6xl px-4 py-8">
+      <h1 className="text-3xl font-bold">
+        New construction homes in {homes[0]!.city}, {stateUpper}
+      </h1>
+      <p className="mt-1 text-sm text-neutral-500">{homes.length} available demonstration homes</p>
+      <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+        {homes.map((home) => (
+          <Link key={home.id} href={`/homes/${home.id}`}
+            className="rounded-xl border border-neutral-200 p-4 shadow-sm hover:border-teal-400">
+            <div className="font-semibold">{fmtPrice(home.price === null ? null : Number(home.price))}</div>
+            <div className="text-sm text-neutral-600">{home.street}</div>
+            <div className="text-sm text-neutral-500">{home.community.name} · {home.builder.name}</div>
+          </Link>
+        ))}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/web/src/app/api/corrections/route.ts b/apps/web/src/app/api/corrections/route.ts
new file mode 100644
index 0000000..70da4ca
--- /dev/null
+++ b/apps/web/src/app/api/corrections/route.ts
@@ -0,0 +1,21 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@spechomes/database";
+
+const correctionSchema = z.object({
+  entityType: z.enum(["INVENTORY_HOME", "COMMUNITY", "FLOOR_PLAN", "INCENTIVE", "BUILDER"]),
+  entityId: z.string().min(1),
+  field: z.string().max(100).optional(),
+  message: z.string().min(3).max(2000),
+  reporterEmail: z.string().email().optional(),
+});
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  const parsed = correctionSchema.safeParse(body);
+  if (!parsed.success) {
+    return NextResponse.json({ error: "invalid report", issues: parsed.error.issues }, { status: 400 });
+  }
+  const report = await prisma.correctionReport.create({ data: parsed.data });
+  return NextResponse.json({ ok: true, id: report.id });
+}
diff --git a/apps/web/src/app/api/facets/route.ts b/apps/web/src/app/api/facets/route.ts
new file mode 100644
index 0000000..2346a62
--- /dev/null
+++ b/apps/web/src/app/api/facets/route.ts
@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from "next/server";
+import { runFacets } from "@spechomes/search";
+import { parseSearchParams } from "@/lib/parse-search";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+  const params = parseSearchParams(request.nextUrl.searchParams);
+  const facets = await runFacets(params);
+  return NextResponse.json(facets);
+}
diff --git a/apps/web/src/app/api/leads/route.ts b/apps/web/src/app/api/leads/route.ts
new file mode 100644
index 0000000..55bdfad
--- /dev/null
+++ b/apps/web/src/app/api/leads/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@spechomes/database";
+
+const leadSchema = z.object({
+  name: z.string().min(1).max(200),
+  email: z.string().email(),
+  phone: z.string().max(30).optional(),
+  message: z.string().max(2000).optional(),
+  homeId: z.string().optional(),
+  communityId: z.string().optional(),
+});
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  const parsed = leadSchema.safeParse(body);
+  if (!parsed.success) {
+    return NextResponse.json({ error: "invalid lead", issues: parsed.error.issues }, { status: 400 });
+  }
+  const lead = await prisma.lead.create({ data: parsed.data });
+  // Lead DELIVERY to builders is a later stage — v1 records the lead only.
+  return NextResponse.json({ ok: true, id: lead.id });
+}
diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts
new file mode 100644
index 0000000..d3e6e86
--- /dev/null
+++ b/apps/web/src/app/api/search/route.ts
@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from "next/server";
+import { runSearch } from "@spechomes/search";
+import { parseSearchParams } from "@/lib/parse-search";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+  const params = parseSearchParams(request.nextUrl.searchParams);
+  const result = await runSearch(params);
+  return NextResponse.json(result);
+}
diff --git a/apps/web/src/app/builders/[slug]/page.tsx b/apps/web/src/app/builders/[slug]/page.tsx
new file mode 100644
index 0000000..fdda37a
--- /dev/null
+++ b/apps/web/src/app/builders/[slug]/page.tsx
@@ -0,0 +1,60 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function BuilderPage({ params }: { params: Promise<{ slug: string }> }) {
+  const { slug } = await params;
+  const builder = await prisma.builder.findUnique({
+    where: { slug },
+    include: {
+      divisions: true,
+      communities: { include: { _count: { select: { homes: { where: { status: "PUBLISHED" } } } } } },
+    },
+  });
+  if (!builder) notFound();
+
+  const homeCount = await prisma.inventoryHome.count({ where: { builderId: builder.id, status: "PUBLISHED" } });
+
+  return (
+    <div className="mx-auto max-w-5xl px-4 py-8">
+      <h1 className="text-3xl font-bold">{builder.name}</h1>
+      <p className="mt-1 text-sm text-neutral-500">
+        {builder.coverageArea} · {homeCount} available homes · {builder.communities.length} communities
+      </p>
+      <p className="mt-2 text-xs text-neutral-400">
+        Listing on SpecHomes does not imply sponsorship or endorsement by the builder.
+      </p>
+
+      <section className="mt-8">
+        <h2 className="text-xl font-semibold">Communities</h2>
+        <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+          {builder.communities.map((community) => (
+            <Link key={community.id} href={`/communities/${community.slug}`}
+              className="rounded-xl border border-neutral-200 p-4 shadow-sm hover:border-teal-400">
+              <div className="font-semibold">{community.name}</div>
+              <div className="text-sm text-neutral-500">{community.city}, {community.state}</div>
+              <div className="mt-1 text-sm text-neutral-500">
+                {community._count.homes} homes
+                {community.priceMin ? ` · from ${fmtPrice(Number(community.priceMin))}` : ""}
+              </div>
+            </Link>
+          ))}
+        </div>
+      </section>
+
+      {builder.divisions.length > 0 && (
+        <section className="mt-8 text-sm text-neutral-600">
+          <h2 className="text-xl font-semibold text-neutral-900">Divisions</h2>
+          <ul className="mt-2 list-inside list-disc">
+            {builder.divisions.map((division) => (
+              <li key={division.id}>{division.name} — {division.region} ({division.states.join(", ")})</li>
+            ))}
+          </ul>
+        </section>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/app/communities/[slug]/page.tsx b/apps/web/src/app/communities/[slug]/page.tsx
new file mode 100644
index 0000000..daa47c1
--- /dev/null
+++ b/apps/web/src/app/communities/[slug]/page.tsx
@@ -0,0 +1,104 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice, VerificationBadge } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function CommunityPage({ params }: { params: Promise<{ slug: string }> }) {
+  const { slug } = await params;
+  const community = await prisma.community.findUnique({
+    where: { slug },
+    include: {
+      builder: true,
+      floorPlans: true,
+      salesOffices: true,
+      incentives: true,
+      homes: { where: { status: "PUBLISHED" }, orderBy: { publishedAt: "desc" } },
+    },
+  });
+  if (!community) notFound();
+
+  return (
+    <div className="mx-auto max-w-6xl px-4 py-8">
+      <h1 className="text-3xl font-bold">{community.name}</h1>
+      <p className="mt-1 text-neutral-600">
+        {community.city}, {community.state} {community.zip} · {community.metro} ·{" "}
+        <Link href={`/builders/${community.builder.slug}`} className="text-teal-700 hover:underline">
+          {community.builder.name}
+        </Link>
+      </p>
+      <div className="mt-2 flex flex-wrap gap-4 text-sm text-neutral-600">
+        {community.priceMin && community.priceMax && (
+          <span>From {fmtPrice(Number(community.priceMin))} to {fmtPrice(Number(community.priceMax))}</span>
+        )}
+        {community.schoolDistrict && <span>School district: {community.schoolDistrict}</span>}
+        <span>
+          HOA:{" "}
+          {community.hoaFeeMonthly ? `$${Number(community.hoaFeeMonthly)}/mo` : community.hoaRequired === false ? "None" : "Not published"}
+        </span>
+        {community.ageRestricted && <span className="font-medium text-violet-700">Age-restricted community</span>}
+      </div>
+
+      {community.incentives.length > 0 && (
+        <section className="mt-6">
+          <h2 className="text-xl font-semibold">Current offers</h2>
+          <ul className="mt-2 grid gap-3 sm:grid-cols-2">
+            {community.incentives.map((incentive) => (
+              <li key={incentive.id} className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
+                <div className="font-semibold">{incentive.title}</div>
+                <div className="mt-1 text-xs text-neutral-500">
+                  {incentive.expiresAt ? `Expires ${incentive.expiresAt.toLocaleDateString()}` : incentive.evergreenLabel}
+                </div>
+              </li>
+            ))}
+          </ul>
+        </section>
+      )}
+
+      <section className="mt-8">
+        <h2 className="text-xl font-semibold">Available homes ({community.homes.length})</h2>
+        <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+          {community.homes.map((home) => (
+            <Link key={home.id} href={`/homes/${home.id}`}
+              className="rounded-xl border border-neutral-200 p-4 shadow-sm hover:border-teal-400">
+              <div className="font-semibold">{fmtPrice(home.price === null ? null : Number(home.price))}</div>
+              <div className="mt-0.5 text-sm text-neutral-600">{home.street}</div>
+              <div className="text-sm text-neutral-500">
+                {home.beds ?? "—"} bd · {home.bathsTotal?.toString() ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
+              </div>
+              <div className="mt-2"><VerificationBadge label={home.verificationLabel} /></div>
+            </Link>
+          ))}
+        </div>
+      </section>
+
+      <section className="mt-8">
+        <h2 className="text-xl font-semibold">Floor plans</h2>
+        <div className="mt-3 grid gap-4 sm:grid-cols-3">
+          {community.floorPlans.map((plan) => (
+            <Link key={plan.id} href={`/plans/${plan.id}`}
+              className="rounded-lg border border-neutral-200 p-3 text-sm hover:border-teal-400">
+              <div className="font-semibold">{plan.name}</div>
+              <div className="text-neutral-500">
+                {plan.beds}–{plan.bedsMax ?? plan.beds} bd · {plan.sqft?.toLocaleString()} sqft · from{" "}
+                {fmtPrice(plan.basePrice === null ? null : Number(plan.basePrice))}
+              </div>
+            </Link>
+          ))}
+        </div>
+      </section>
+
+      {community.salesOffices.length > 0 && (
+        <section className="mt-8 text-sm text-neutral-600">
+          <h2 className="text-xl font-semibold text-neutral-900">Sales office</h2>
+          {community.salesOffices.map((office) => (
+            <p key={office.id} className="mt-2">
+              {office.street}, {office.city}, {office.state} {office.zip} · {office.phone}
+            </p>
+          ))}
+        </section>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/app/compare/page.tsx b/apps/web/src/app/compare/page.tsx
new file mode 100644
index 0000000..792bb2e
--- /dev/null
+++ b/apps/web/src/app/compare/page.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+interface Home {
+  id: string;
+  street: string | null;
+  city: string;
+  state: string;
+  price: string | null;
+  beds: number | null;
+  bathsTotal: string | null;
+  sqft: number | null;
+  stories: number | null;
+  garageSpaces: number | null;
+  constructionStatus: string;
+  community: { name: string };
+  builder: { name: string };
+}
+
+/** v1 stub: compares homes saved to localStorage from search/detail pages. */
+export default function ComparePage() {
+  const [homes, setHomes] = useState<Home[]>([]);
+  const [loaded, setLoaded] = useState(false);
+
+  useEffect(() => {
+    const ids: string[] = JSON.parse(localStorage.getItem("spechomes.compare") ?? "[]");
+    if (ids.length === 0) {
+      setLoaded(true);
+      return;
+    }
+    Promise.all(
+      ids.slice(0, 4).map((id) =>
+        fetch(`/api/search?pageSize=1&page=1`).then(() => null).catch(() => null),
+      ),
+    ).finally(() => setLoaded(true));
+    // v1: full compare fetch lands with saved-homes accounts; render IDs for now.
+    setHomes([]);
+  }, []);
+
+  return (
+    <div className="mx-auto max-w-4xl px-4 py-10">
+      <h1 className="text-3xl font-bold">Compare homes</h1>
+      {loaded && homes.length === 0 && (
+        <p className="mt-4 text-neutral-600">
+          Nothing to compare yet. Browse the <Link href="/search" className="text-teal-700 underline">search page</Link>{" "}
+          and add homes — side-by-side comparison arrives with saved-home accounts (deferred in v1).
+        </p>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/app/contact/ContactClient.tsx b/apps/web/src/app/contact/ContactClient.tsx
new file mode 100644
index 0000000..ff25fb8
--- /dev/null
+++ b/apps/web/src/app/contact/ContactClient.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+
+interface CommunityOption {
+  id: string;
+  name: string;
+  city: string;
+  state: string;
+  builder: { name: string };
+}
+
+export default function ContactClient({ communities }: { communities: CommunityOption[] }) {
+  const [form, setForm] = useState({ name: "", email: "", communityId: "", message: "" });
+  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+  return (
+    <div className="mx-auto max-w-lg px-4 py-10">
+      <h1 className="text-3xl font-bold">Contact a builder</h1>
+      <p className="mt-2 text-sm text-neutral-600">
+        Your inquiry goes to the builder's sales team for the community you pick. Demonstration build — no real
+        builder receives anything.
+      </p>
+      {state === "done" ? (
+        <div className="mt-6 rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-emerald-800">
+          Request recorded. A builder representative would follow up.
+        </div>
+      ) : (
+        <form
+          className="mt-6 space-y-3"
+          onSubmit={async (e) => {
+            e.preventDefault();
+            setState("sending");
+            const res = await fetch("/api/leads", {
+              method: "POST",
+              headers: { "Content-Type": "application/json" },
+              body: JSON.stringify({
+                name: form.name,
+                email: form.email,
+                communityId: form.communityId || undefined,
+                message: form.message || undefined,
+              }),
+            });
+            setState(res.ok ? "done" : "error");
+          }}
+        >
+          <input required placeholder="Name" value={form.name}
+            onChange={(e) => setForm({ ...form, name: e.target.value })}
+            className="w-full rounded border border-neutral-300 px-3 py-2" />
+          <input required type="email" placeholder="Email" value={form.email}
+            onChange={(e) => setForm({ ...form, email: e.target.value })}
+            className="w-full rounded border border-neutral-300 px-3 py-2" />
+          <select value={form.communityId} onChange={(e) => setForm({ ...form, communityId: e.target.value })}
+            className="w-full rounded border border-neutral-300 px-3 py-2">
+            <option value="">Any community</option>
+            {communities.map((c) => (
+              <option key={c.id} value={c.id}>
+                {c.name} — {c.city}, {c.state} ({c.builder.name})
+              </option>
+            ))}
+          </select>
+          <textarea placeholder="What would you like to know?" rows={4} value={form.message}
+            onChange={(e) => setForm({ ...form, message: e.target.value })}
+            className="w-full rounded border border-neutral-300 px-3 py-2" />
+          <button type="submit" disabled={state === "sending"}
+            className="w-full rounded-lg bg-teal-700 py-2.5 font-medium text-white hover:bg-teal-800 disabled:opacity-50">
+            {state === "sending" ? "Sending…" : "Send inquiry"}
+          </button>
+          {state === "error" && <p className="text-sm text-red-600">Could not send — check the fields.</p>}
+        </form>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/app/contact/page.tsx b/apps/web/src/app/contact/page.tsx
new file mode 100644
index 0000000..0197605
--- /dev/null
+++ b/apps/web/src/app/contact/page.tsx
@@ -0,0 +1,12 @@
+import { prisma } from "@spechomes/database";
+import ContactClient from "./ContactClient";
+
+export const dynamic = "force-dynamic";
+
+export default async function ContactPage() {
+  const communities = await prisma.community.findMany({
+    select: { id: true, name: true, city: true, state: true, builder: { select: { name: true } } },
+    orderBy: [{ state: "asc" }, { name: "asc" }],
+  });
+  return <ContactClient communities={communities} />;
+}
diff --git a/apps/web/src/app/homes/[id]/CorrectionForm.tsx b/apps/web/src/app/homes/[id]/CorrectionForm.tsx
new file mode 100644
index 0000000..c27b894
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/CorrectionForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+
+/** "Report a problem" — the consumer-facing correction control. */
+export default function CorrectionForm({ entityId }: { entityId: string }) {
+  const [open, setOpen] = useState(false);
+  const [message, setMessage] = useState("");
+  const [email, setEmail] = useState("");
+  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+  if (state === "done") {
+    return <p className="text-sm text-emerald-700" data-testid="correction-done">Thanks — your report is in our review queue.</p>;
+  }
+
+  return (
+    <div>
+      <button
+        type="button"
+        onClick={() => setOpen((v) => !v)}
+        className="text-sm text-neutral-500 underline hover:text-teal-700"
+        data-testid="correction-toggle"
+      >
+        Report a problem with this listing
+      </button>
+      {open && (
+        <form
+          className="mt-3 max-w-md space-y-2 rounded-lg border border-neutral-200 p-3 text-sm"
+          onSubmit={async (e) => {
+            e.preventDefault();
+            setState("sending");
+            const res = await fetch("/api/corrections", {
+              method: "POST",
+              headers: { "Content-Type": "application/json" },
+              body: JSON.stringify({
+                entityType: "INVENTORY_HOME",
+                entityId,
+                message,
+                reporterEmail: email || undefined,
+              }),
+            });
+            setState(res.ok ? "done" : "error");
+          }}
+        >
+          <textarea
+            required
+            minLength={3}
+            value={message}
+            onChange={(e) => setMessage(e.target.value)}
+            placeholder="What looks wrong? (price, availability, address…)"
+            className="w-full rounded border border-neutral-300 px-2 py-1"
+            rows={3}
+            data-testid="correction-message"
+          />
+          <input
+            type="email"
+            value={email}
+            onChange={(e) => setEmail(e.target.value)}
+            placeholder="Your email (optional)"
+            className="w-full rounded border border-neutral-300 px-2 py-1"
+          />
+          <button
+            type="submit"
+            disabled={state === "sending"}
+            className="rounded bg-neutral-800 px-4 py-1.5 text-white disabled:opacity-50"
+            data-testid="correction-submit"
+          >
+            {state === "sending" ? "Sending…" : "Send report"}
+          </button>
+          {state === "error" && <p className="text-red-600">Could not send — try again.</p>}
+        </form>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/app/homes/[id]/LeadForm.tsx b/apps/web/src/app/homes/[id]/LeadForm.tsx
new file mode 100644
index 0000000..f30809c
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/LeadForm.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useState } from "react";
+
+export default function LeadForm({
+  homeId,
+  communityId,
+  builderName,
+}: {
+  homeId: string;
+  communityId: string;
+  builderName: string;
+}) {
+  const [form, setForm] = useState({ name: "", email: "", phone: "", message: "" });
+  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+  if (state === "done") {
+    return (
+      <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800" data-testid="lead-done">
+        Request sent. {builderName} (demonstration) would receive this inquiry.
+      </div>
+    );
+  }
+
+  return (
+    <form
+      className="rounded-xl border border-neutral-200 p-4 shadow-sm"
+      onSubmit={async (e) => {
+        e.preventDefault();
+        setState("sending");
+        const res = await fetch("/api/leads", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ ...form, phone: form.phone || undefined, message: form.message || undefined, homeId, communityId }),
+        });
+        setState(res.ok ? "done" : "error");
+      }}
+    >
+      <h2 className="font-semibold">Request info or a tour</h2>
+      <p className="mt-1 text-xs text-neutral-500">Sent to {builderName}. No obligation.</p>
+      <div className="mt-3 space-y-2 text-sm">
+        <input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
+          placeholder="Name" className="w-full rounded border border-neutral-300 px-2 py-1.5" data-testid="lead-name" />
+        <input required type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
+          placeholder="Email" className="w-full rounded border border-neutral-300 px-2 py-1.5" data-testid="lead-email" />
+        <input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })}
+          placeholder="Phone (optional)" className="w-full rounded border border-neutral-300 px-2 py-1.5" />
+        <textarea value={form.message} onChange={(e) => setForm({ ...form, message: e.target.value })}
+          placeholder="Questions? Preferred tour time?" rows={3} className="w-full rounded border border-neutral-300 px-2 py-1.5" />
+        <button type="submit" disabled={state === "sending"}
+          className="w-full rounded-lg bg-teal-700 py-2 font-medium text-white hover:bg-teal-800 disabled:opacity-50"
+          data-testid="lead-submit">
+          {state === "sending" ? "Sending…" : "Contact builder"}
+        </button>
+        {state === "error" && <p className="text-red-600">Could not send — check the fields and try again.</p>}
+      </div>
+    </form>
+  );
+}
diff --git a/apps/web/src/app/homes/[id]/page.tsx b/apps/web/src/app/homes/[id]/page.tsx
new file mode 100644
index 0000000..9aafc01
--- /dev/null
+++ b/apps/web/src/app/homes/[id]/page.tsx
@@ -0,0 +1,181 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice, VerificationBadge } from "@spechomes/shared-ui";
+import CorrectionForm from "./CorrectionForm";
+import LeadForm from "./LeadForm";
+
+export const dynamic = "force-dynamic";
+
+const STATUS_LABELS: Record<string, string> = {
+  MOVE_IN_READY: "Move-in ready",
+  UNDER_CONSTRUCTION: "Under construction",
+  PLANNED: "Planned",
+};
+
+export default async function HomeDetailPage({ params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params;
+  const home = await prisma.inventoryHome.findUnique({
+    where: { id },
+    include: {
+      community: true,
+      builder: true,
+      floorPlan: true,
+      source: true,
+      incentives: true,
+    },
+  });
+  if (!home || home.status !== "PUBLISHED") notFound();
+
+  const [communityIncentives, evidence] = await Promise.all([
+    prisma.incentive.findMany({ where: { communityId: home.communityId, scope: "COMMUNITY" } }),
+    prisma.sourceEvidence.findMany({
+      where: { entityType: "INVENTORY_HOME", entityId: home.id },
+      orderBy: { field: "asc" },
+    }),
+  ]);
+  const incentives = [...home.incentives, ...communityIncentives];
+
+  const facts: [string, string][] = [
+    ["Price", fmtPrice(home.price === null ? null : Number(home.price))],
+    ["Status", STATUS_LABELS[home.constructionStatus] ?? home.constructionStatus],
+    ["Beds", home.beds?.toString() ?? "Not published"],
+    ["Baths", home.bathsTotal?.toString() ?? "Not published"],
+    ["Square feet", home.sqft?.toLocaleString() ?? "Not published"],
+    ["Stories", home.stories?.toString() ?? "Not published"],
+    ["Garage", home.garageSpaces ? `${home.garageSpaces}-car` : "Not published"],
+    ["Lot", home.lotNumber ? `Lot ${home.lotNumber}` : "Not published"],
+    [
+      "Est. completion",
+      home.estCompletionDate
+        ? home.estCompletionDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })
+        : home.constructionStatus === "MOVE_IN_READY"
+          ? "Complete"
+          : "Not published",
+    ],
+  ];
+
+  return (
+    <div className="mx-auto max-w-5xl px-4 py-8">
+      <nav className="text-sm text-neutral-500">
+        <Link href="/search" className="hover:text-teal-700">Search</Link> ·{" "}
+        <Link href={`/communities/${home.community.slug}`} className="hover:text-teal-700">
+          {home.community.name}
+        </Link>
+      </nav>
+
+      <div className="mt-3 flex flex-wrap items-start justify-between gap-3">
+        <div>
+          <h1 className="text-3xl font-bold">{home.street ?? "Address available from builder"}</h1>
+          <p className="mt-1 text-neutral-600">
+            {home.city}, {home.state} {home.zip} · {home.community.name} ·{" "}
+            <Link href={`/builders/${home.builder.slug}`} className="text-teal-700 hover:underline">
+              {home.builder.name}
+            </Link>
+          </p>
+        </div>
+        <div className="text-right">
+          <div className="text-3xl font-bold text-teal-800" data-testid="detail-price">
+            {fmtPrice(home.price === null ? null : Number(home.price))}
+          </div>
+          {home.previousPrice && home.price && Number(home.previousPrice) !== Number(home.price) ? (
+            <div className="text-sm text-neutral-400 line-through">{fmtPrice(Number(home.previousPrice))}</div>
+          ) : null}
+        </div>
+      </div>
+
+      {/* Verification block — the trust surface. */}
+      <div className="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-sm" data-testid="verification-block">
+        <VerificationBadge label={home.verificationLabel} />
+        <span className="text-neutral-600">
+          Last verified:{" "}
+          {home.lastVerifiedAt
+            ? home.lastVerifiedAt.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
+            : "not yet verified"}
+        </span>
+        {home.source ? <span className="text-neutral-500">Source: {home.source.name}</span> : null}
+        {home.sourceUrl ? (
+          <a href={home.sourceUrl} rel="nofollow noopener noreferrer" className="text-teal-700 hover:underline">
+            Original source
+          </a>
+        ) : null}
+      </div>
+
+      <div className="mt-6 grid gap-8 md:grid-cols-[1fr_320px]">
+        <div>
+          <h2 className="text-xl font-semibold">Home facts</h2>
+          <dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
+            {facts.map(([label, value]) => (
+              <div key={label} className="border-b border-neutral-100 py-1.5">
+                <dt className="text-neutral-500">{label}</dt>
+                <dd className="font-medium">{value}</dd>
+              </div>
+            ))}
+          </dl>
+          <p className="mt-2 text-xs text-neutral-400">
+            “Not published” means the source did not state this fact. SpecHomes never fills in missing values.
+          </p>
+
+          {incentives.length > 0 && (
+            <section className="mt-8">
+              <h2 className="text-xl font-semibold">Incentives</h2>
+              <ul className="mt-3 space-y-3">
+                {incentives.map((incentive) => (
+                  <li key={incentive.id} className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm">
+                    <div className="font-semibold">{incentive.title}</div>
+                    {incentive.description && <p className="mt-1 text-neutral-600">{incentive.description}</p>}
+                    <div className="mt-1 text-xs text-neutral-500">
+                      {incentive.expiresAt
+                        ? `Expires ${incentive.expiresAt.toLocaleDateString()}`
+                        : incentive.evergreenLabel ?? ""}
+                    </div>
+                  </li>
+                ))}
+              </ul>
+            </section>
+          )}
+
+          <section className="mt-8">
+            <h2 className="text-xl font-semibold">Data provenance</h2>
+            <p className="mt-1 text-sm text-neutral-500">
+              Every displayed fact traces to source evidence with a retrieval timestamp.
+            </p>
+            <div className="mt-3 overflow-x-auto">
+              <table className="w-full text-left text-xs" data-testid="evidence-table">
+                <thead>
+                  <tr className="border-b border-neutral-200 text-neutral-500">
+                    <th className="py-1.5 pr-3">Field</th>
+                    <th className="py-1.5 pr-3">Source text</th>
+                    <th className="py-1.5 pr-3">Retrieved</th>
+                    <th className="py-1.5">Confidence</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {evidence.map((row) => (
+                    <tr key={row.id} className="border-b border-neutral-100">
+                      <td className="py-1.5 pr-3 font-medium">{row.field}</td>
+                      <td className="py-1.5 pr-3">{row.evidenceText ?? <em className="text-neutral-400">not stated by source</em>}</td>
+                      <td className="py-1.5 pr-3">{row.retrievedAt.toLocaleDateString()}</td>
+                      <td className="py-1.5">{(row.confidence * 100).toFixed(0)}%</td>
+                    </tr>
+                  ))}
+                  {evidence.length === 0 && (
+                    <tr><td colSpan={4} className="py-2 text-neutral-400">No field-level evidence recorded.</td></tr>
+                  )}
+                </tbody>
+              </table>
+            </div>
+          </section>
+
+          <section className="mt-8">
+            <CorrectionForm entityId={home.id} />
+          </section>
+        </div>
+
+        <aside>
+          <LeadForm homeId={home.id} communityId={home.communityId} builderName={home.builder.name} />
+        </aside>
+      </div>
+    </div>
+  );
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
new file mode 100644
index 0000000..2cd57b5
--- /dev/null
+++ b/apps/web/src/app/layout.tsx
@@ -0,0 +1,43 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "SpecHomes — Every new home. Every builder. One search.",
+  description:
+    "Discover, compare, and contact builders for newly constructed homes across the United States. Verified from the source.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="en">
+      <body className="min-h-screen bg-white text-neutral-900 antialiased">
+        {/* All inventory in this build is synthetic — never present demo data as real. */}
+        <div className="bg-violet-700 px-4 py-1.5 text-center text-xs font-medium text-white">
+          Demonstration inventory — all homes, builders, communities, and offers shown are synthetic sample
+          data, clearly labeled. No real listings.
+        </div>
+        <header className="border-b border-neutral-200">
+          <nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
+            <Link href="/" className="text-xl font-bold tracking-tight text-teal-800">
+              SpecHomes<span className="text-neutral-400">.com</span>
+            </Link>
+            <div className="flex items-center gap-5 text-sm text-neutral-700">
+              <Link href="/search" className="hover:text-teal-700">Search</Link>
+              <Link href="/compare" className="hover:text-teal-700">Compare</Link>
+              <Link href="/saved" className="hover:text-teal-700">Saved</Link>
+              <Link href="/contact" className="hover:text-teal-700">Contact a builder</Link>
+            </div>
+          </nav>
+        </header>
+        <main>{children}</main>
+        <footer className="mt-16 border-t border-neutral-200 py-8 text-center text-xs text-neutral-500">
+          <p>Every new home. Every builder. One search. Verified from the source.</p>
+          <p className="mt-1">
+            Builder inclusion never implies sponsorship. Organic results are never mixed with paid placement.
+          </p>
+        </footer>
+      </body>
+    </html>
+  );
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
new file mode 100644
index 0000000..6fc5901
--- /dev/null
+++ b/apps/web/src/app/page.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { prisma } from "@spechomes/database";
+
+export const dynamic = "force-dynamic";
+
+export default async function HomePage() {
+  const markets = await prisma.community.groupBy({
+    by: ["metro", "state"],
+    _count: { _all: true },
+  });
+  const homeCount = await prisma.inventoryHome.count({ where: { status: "PUBLISHED" } });
+  const builderCount = await prisma.builder.count();
+
+  return (
+    <div>
+      <section className="bg-gradient-to-b from-teal-900 to-teal-700 px-4 py-20 text-white">
+        <div className="mx-auto max-w-3xl text-center">
+          <h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
+            Every new home. Every builder. One search.
+          </h1>
+          <p className="mt-4 text-lg text-teal-100">
+            Move-in-ready and under-construction homes, verified from the source.
+          </p>
+          <form action="/search" method="get" className="mx-auto mt-8 flex max-w-xl gap-2">
+            <input
+              type="text"
+              name="q"
+              placeholder="City, ZIP, or community — try “Leander” or “85212”"
+              className="w-full rounded-lg border-0 px-4 py-3 text-neutral-900 shadow-lg focus:ring-2 focus:ring-teal-300"
+            />
+            <button
+              type="submit"
+              className="rounded-lg bg-white px-6 py-3 font-semibold text-teal-800 shadow-lg hover:bg-teal-50"
+            >
+              Search
+            </button>
+          </form>
+          <p className="mt-4 text-sm text-teal-200">
+            {homeCount} verified demonstration homes · {builderCount} builders · {markets.length} metros
+          </p>
+        </div>
+      </section>
+
+      <section className="mx-auto max-w-7xl px-4 py-12">
+        <h2 className="text-2xl font-semibold">Browse by market</h2>
+        <div className="mt-6 grid gap-4 sm:grid-cols-3">
+          {markets.map((market) => (
+            <Link
+              key={`${market.metro}-${market.state}`}
+              href={`/search?q=${encodeURIComponent(market.metro?.split("-")[0] ?? "")}`}
+              className="rounded-xl border border-neutral-200 p-6 shadow-sm transition hover:border-teal-400 hover:shadow-md"
+            >
+              <div className="text-lg font-semibold">{market.metro}</div>
+              <div className="mt-1 text-sm text-neutral-500">
+                {market.state} · {market._count._all} communities
+              </div>
+            </Link>
+          ))}
+        </div>
+      </section>
+    </div>
+  );
+}
diff --git a/apps/web/src/app/plans/[id]/page.tsx b/apps/web/src/app/plans/[id]/page.tsx
new file mode 100644
index 0000000..8aa6670
--- /dev/null
+++ b/apps/web/src/app/plans/[id]/page.tsx
@@ -0,0 +1,50 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@spechomes/database";
+import { fmtPrice } from "@spechomes/shared-ui";
+
+export const dynamic = "force-dynamic";
+
+export default async function PlanPage({ params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params;
+  const plan = await prisma.floorPlan.findUnique({
+    where: { id },
+    include: {
+      community: true,
+      builder: true,
+      homes: { where: { status: "PUBLISHED" } },
+    },
+  });
+  if (!plan) notFound();
+
+  return (
+    <div className="mx-auto max-w-4xl px-4 py-8">
+      <nav className="text-sm text-neutral-500">
+        <Link href={`/communities/${plan.community.slug}`} className="hover:text-teal-700">{plan.community.name}</Link>
+      </nav>
+      <h1 className="mt-2 text-3xl font-bold">{plan.name}</h1>
+      <p className="mt-1 text-neutral-600">{plan.builder.name} · {plan.community.name}</p>
+      <dl className="mt-6 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
+        <div><dt className="text-neutral-500">Base price</dt><dd className="font-semibold">{fmtPrice(plan.basePrice === null ? null : Number(plan.basePrice))}</dd></div>
+        <div><dt className="text-neutral-500">Beds</dt><dd className="font-semibold">{plan.beds ?? "—"}{plan.bedsMax && plan.bedsMax !== plan.beds ? `–${plan.bedsMax}` : ""}</dd></div>
+        <div><dt className="text-neutral-500">Sq ft</dt><dd className="font-semibold">{plan.sqft?.toLocaleString() ?? "—"}</dd></div>
+        <div><dt className="text-neutral-500">Stories</dt><dd className="font-semibold">{plan.stories ?? "—"}</dd></div>
+      </dl>
+      <p className="mt-2 text-xs text-neutral-400">
+        Floor-plan drawings render only when the source registry confirms media rights (rights status: {plan.planMediaRights ?? "none"}).
+      </p>
+      <section className="mt-8">
+        <h2 className="text-xl font-semibold">Available homes on this plan</h2>
+        <div className="mt-3 grid gap-4 sm:grid-cols-2">
+          {plan.homes.map((home) => (
+            <Link key={home.id} href={`/homes/${home.id}`} className="rounded-lg border border-neutral-200 p-3 text-sm hover:border-teal-400">
+              <div className="font-semibold">{fmtPrice(home.price === null ? null : Number(home.price))}</div>
+              <div className="text-neutral-600">{home.street}</div>
+            </Link>
+          ))}
+          {plan.homes.length === 0 && <p className="text-sm text-neutral-500">No inventory homes on this plan right now.</p>}
+        </div>
+      </section>
+    </div>
+  );
+}
diff --git a/apps/web/src/app/saved/page.tsx b/apps/web/src/app/saved/page.tsx
new file mode 100644
index 0000000..2caec18
--- /dev/null
+++ b/apps/web/src/app/saved/page.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+
+/** v1 stub: saved homes live in localStorage; accounts + alert delivery are deferred. */
+export default function SavedPage() {
+  const [ids, setIds] = useState<string[]>([]);
+  useEffect(() => {
+    setIds(JSON.parse(localStorage.getItem("spechomes.saved") ?? "[]"));
+  }, []);
+
+  return (
+    <div className="mx-auto max-w-4xl px-4 py-10">
+      <h1 className="text-3xl font-bold">Saved homes</h1>
+      {ids.length === 0 ? (
+        <p className="mt-4 text-neutral-600">
+          No saved homes yet. Save from the <Link href="/search" className="text-teal-700 underline">search page</Link>.
+          Price and availability alerts arrive with accounts (deferred in v1).
+        </p>
+      ) : (
+        <ul className="mt-4 space-y-2">
+          {ids.map((id) => (
+            <li key={id}>
+              <Link href={`/homes/${id}`} className="text-teal-700 underline">Saved home {id.slice(0, 8)}…</Link>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+}
diff --git a/apps/web/src/app/search/SearchClient.tsx b/apps/web/src/app/search/SearchClient.tsx
new file mode 100644
index 0000000..4266c2d
--- /dev/null
+++ b/apps/web/src/app/search/SearchClient.tsx
@@ -0,0 +1,323 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import dynamic from "next/dynamic";
+import Link from "next/link";
+import { fmtPrice, VerificationBadge } from "@spechomes/shared-ui";
+
+// Leaflet needs window — client-only load.
+const MapView = dynamic(() => import("@spechomes/shared-ui").then((m) => m.MapView), { ssr: false });
+
+interface Home {
+  id: string;
+  street: string | null;
+  city: string;
+  state: string;
+  zip: string;
+  price: string | number | null;
+  beds: number | null;
+  bathsTotal: string | number | null;
+  sqft: number | null;
+  stories: number | null;
+  garageSpaces: number | null;
+  lat: string | number | null;
+  lon: string | number | null;
+  constructionStatus: string;
+  estCompletionDate: string | null;
+  verificationLabel: string;
+  lastVerifiedAt: string | null;
+  publishedAt: string;
+  community: { slug: string; name: string; schoolDistrict: string | null };
+  builder: { slug: string; name: string };
+}
+
+interface SearchResponse {
+  homes: Home[];
+  total: number;
+  page: number;
+  pageSize: number;
+  location: { label: string; lat: number; lon: number } | null;
+}
+
+interface Facets {
+  constructionStatus: { value: string; count: number }[];
+  builder: { value: string; label: string; count: number }[];
+  beds: { value: number; count: number }[];
+  homeType: { value: string; count: number }[];
+}
+
+const SORTS = [
+  { value: "newest", label: "Newest" },
+  { value: "price_asc", label: "Price ↑" },
+  { value: "price_desc", label: "Price ↓" },
+  { value: "sqft_desc", label: "Sq Ft ↓" },
+  { value: "closest", label: "Closest" },
+] as const;
+
+const STATUS_LABELS: Record<string, string> = {
+  MOVE_IN_READY: "Move-in ready",
+  UNDER_CONSTRUCTION: "Under construction",
+  PLANNED: "Planned",
+};
+
+export default function SearchClient({ initialQuery }: { initialQuery: string }) {
+  const [q, setQ] = useState(initialQuery);
+  const [filters, setFilters] = useState<Record<string, string | string[]>>({});
+  // Sort + density persist across reloads (standing grid rule).
+  const [sort, setSort] = useState<string>("newest");
+  const [cols, setCols] = useState<number>(3);
+  const [data, setData] = useState<SearchResponse | null>(null);
+  const [facets, setFacets] = useState<Facets | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [page, setPage] = useState(1);
+  const bboxRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    const savedSort = localStorage.getItem("spechomes.sort");
+    const savedCols = localStorage.getItem("spechomes.cols");
+    if (savedSort) setSort(savedSort);
+    if (savedCols) setCols(Number(savedCols));
+  }, []);
+
+  const setSortPersist = (value: string) => {
+    setSort(value);
+    localStorage.setItem("spechomes.sort", value);
+  };
+  const setColsPersist = (value: number) => {
+    setCols(value);
+    localStorage.setItem("spechomes.cols", String(value));
+  };
+
+  const queryString = useMemo(() => {
+    const params = new URLSearchParams();
+    if (q) params.set("q", q);
+    if (bboxRef.current) params.set("bbox", bboxRef.current);
+    for (const [key, value] of Object.entries(filters)) {
+      if (Array.isArray(value)) value.forEach((v) => params.append(key, v));
+      else if (value !== "") params.set(key, value);
+    }
+    params.set("sort", sort);
+    params.set("page", String(page));
+    return params.toString();
+  }, [q, filters, sort, page]);
+
+  const fetchResults = useCallback(async () => {
+    setLoading(true);
+    try {
+      const [searchRes, facetsRes] = await Promise.all([
+        fetch(`/api/search?${queryString}`),
+        fetch(`/api/facets?${queryString}`),
+      ]);
+      setData(await searchRes.json());
+      setFacets(await facetsRes.json());
+    } finally {
+      setLoading(false);
+    }
+  }, [queryString]);
+
+  useEffect(() => {
+    void fetchResults();
+  }, [fetchResults]);
+
+  const toggleMulti = (key: string, value: string) => {
+    setPage(1);
+    setFilters((prev) => {
+      const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
+      const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
+      return { ...prev, [key]: next };
+    });
+  };
+  const setSingle = (key: string, value: string) => {
+    setPage(1);
+    setFilters((prev) => ({ ...prev, [key]: value }));
+  };
+
+  const markers = (data?.homes ?? [])
+    .filter((h) => h.lat !== null && h.lon !== null)
+    .map((h) => ({
+      id: h.id,
+      lat: Number(h.lat),
+      lon: Number(h.lon),
+      label: h.street ?? `${h.community.name} home`,
+      sublabel: fmtPrice(h.price),
+      href: `/homes/${h.id}`,
+    }));
+
+  const onMapMove = useCallback(
+    (bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => {
+      bboxRef.current = `${bbox.minLat},${bbox.minLon},${bbox.maxLat},${bbox.maxLon}`;
+    },
+    [],
+  );
+
+  const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
+
+  return (
+    <div className="mx-auto max-w-7xl px-4 py-6">
+      {/* Search bar */}
+      <form
+        onSubmit={(e) => {
+          e.preventDefault();
+          bboxRef.current = null;
+          setPage(1);
+          void fetchResults();
+        }}
+        className="flex gap-2"
+      >
+        <input
+          value={q}
+          onChange={(e) => setQ(e.target.value)}
+          placeholder="City, ZIP, or community"
+          className="w-full max-w-md rounded-lg border border-neutral-300 px-4 py-2"
+          data-testid="search-input"
+        />
+        <button type="submit" className="rounded-lg bg-teal-700 px-5 py-2 font-medium text-white hover:bg-teal-800">
+          Search
+        </button>
+      </form>
+
+      <div className="mt-6 grid gap-6 lg:grid-cols-[260px_1fr]">
+        {/* Filters */}
+        <aside className="space-y-5 text-sm" data-testid="filters">
+          <div>
+            <h3 className="font-semibold">Price</h3>
+            <div className="mt-2 flex gap-2">
+              <input type="number" placeholder="Min" className="w-full rounded border border-neutral-300 px-2 py-1"
+                onChange={(e) => setSingle("priceMin", e.target.value)} data-testid="price-min" />
+              <input type="number" placeholder="Max" className="w-full rounded border border-neutral-300 px-2 py-1"
+                onChange={(e) => setSingle("priceMax", e.target.value)} data-testid="price-max" />
+            </div>
+          </div>
+          <div>
+            <h3 className="font-semibold">Beds (min)</h3>
+            <div className="mt-2 flex gap-1">
+              {[1, 2, 3, 4, 5].map((n) => (
+                <button key={n} type="button" data-testid={`beds-${n}`}
+                  onClick={() => setSingle("bedsMin", filters.bedsMin === String(n) ? "" : String(n))}
+                  className={`rounded border px-2.5 py-1 ${filters.bedsMin === String(n) ? "border-teal-600 bg-teal-50 text-teal-800" : "border-neutral-300"}`}>
+                  {n}+
+                </button>
+              ))}
+            </div>
+          </div>
+          <div>
+            <h3 className="font-semibold">Construction status</h3>
+            {facets?.constructionStatus.map((facet) => (
+              <label key={facet.value} className="mt-1 flex items-center gap-2">
+                <input type="checkbox"
+                  checked={Array.isArray(filters.status) && (filters.status as string[]).includes(facet.value)}
+                  onChange={() => toggleMulti("status", facet.value)} />
+                {STATUS_LABELS[facet.value] ?? facet.value}
+                <span className="text-neutral-400">({facet.count})</span>
+              </label>
+            ))}
+          </div>
+          <div>
+            <h3 className="font-semibold">Builder</h3>
+            {facets?.builder.map((facet) => (
+              <label key={facet.value} className="mt-1 flex items-center gap-2">
+                <input type="checkbox"
+                  checked={Array.isArray(filters.builder) && (filters.builder as string[]).includes(facet.value)}
+                  onChange={() => toggleMulti("builder", facet.value)} />
+                {facet.label}
+                <span className="text-neutral-400">({facet.count})</span>
+              </label>
+            ))}
+          </div>
+          <div>
+            <h3 className="font-semibold">More</h3>
+            <label className="mt-1 flex items-center gap-2">
+              <input type="checkbox" checked={filters.incentives === "1"}
+                onChange={() => setSingle("incentives", filters.incentives === "1" ? "" : "1")} />
+              Has incentives
+            </label>
+            <label className="mt-1 flex items-center gap-2">
+              <input type="checkbox" checked={filters.ageRestricted === "1"}
+                onChange={() => setSingle("ageRestricted", filters.ageRestricted === "1" ? "" : "1")} />
+              Age-restricted community
+            </label>
+            <select className="mt-2 w-full rounded border border-neutral-300 px-2 py-1"
+              value={(filters.moveInByMonths as string) ?? ""}
+              onChange={(e) => setSingle("moveInByMonths", e.target.value)}>
+              <option value="">Any move-in timeframe</option>
+              <option value="0">Ready now</option>
+              <option value="3">Within 3 months</option>
+              <option value="6">Within 6 months</option>
+              <option value="12">Within 12 months</option>
+            </select>
+          </div>
+        </aside>
+
+        {/* Results */}
+        <div>
+          <div className="flex flex-wrap items-center justify-between gap-3">
+            <div className="text-sm text-neutral-600" data-testid="result-count">
+              {loading ? "Searching…" : `${data?.total ?? 0} homes${data?.location ? ` near ${data.location.label}` : ""}`}
+            </div>
+            {/* Mandatory grid controls: sort select + density slider, localStorage-persisted */}
+            <div className="flex items-center gap-4">
+              <label className="flex items-center gap-2 text-sm">
+                Sort
+                <select value={sort} onChange={(e) => setSortPersist(e.target.value)}
+                  className="rounded border border-neutral-300 px-2 py-1" data-testid="sort-select">
+                  {SORTS.map((s) => (
+                    <option key={s.value} value={s.value}>{s.label}</option>
+                  ))}
+                </select>
+              </label>
+              <label className="flex items-center gap-2 text-sm">
+                Density
+                <input type="range" min={2} max={4} step={1} value={cols}
+                  onChange={(e) => setColsPersist(Number(e.target.value))} data-testid="density-slider" />
+              </label>
+            </div>
+          </div>
+
+          <div className="mt-4 grid gap-4 lg:grid-cols-[1fr_380px]">
+            <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} data-testid="results-grid">
+              {(data?.homes ?? []).map((home) => (
+                <Link key={home.id} href={`/homes/${home.id}`}
+                  className="rounded-xl border border-neutral-200 p-4 shadow-sm transition hover:border-teal-400 hover:shadow-md"
+                  data-testid="home-card">
+                  <div className="text-lg font-semibold">{fmtPrice(home.price)}</div>
+                  <div className="mt-0.5 truncate text-sm text-neutral-700">
+                    {home.street ?? "Address on request"} · {home.city}, {home.state}
+                  </div>
+                  <div className="mt-1 text-sm text-neutral-500">
+                    {home.beds ?? "—"} bd · {home.bathsTotal ?? "—"} ba · {home.sqft?.toLocaleString() ?? "—"} sqft
+                  </div>
+                  <div className="mt-1 text-xs text-neutral-500">
+                    {STATUS_LABELS[home.constructionStatus]}
+                    {home.estCompletionDate ? ` · est. ${new Date(home.estCompletionDate).toLocaleDateString(undefined, { month: "short", year: "numeric" })}` : ""}
+                  </div>
+                  <div className="mt-2 flex items-center justify-between">
+                    <span className="text-xs text-neutral-500">{home.builder.name}</span>
+                    <VerificationBadge label={home.verificationLabel} />
+                  </div>
+                </Link>
+              ))}
+              {!loading && (data?.homes.length ?? 0) === 0 && (
+                <p className="col-span-full py-10 text-center text-neutral-500">
+                  No homes match these filters.
+                </p>
+              )}
+            </div>
+            <div className="h-[540px] overflow-hidden rounded-xl border border-neutral-200" data-testid="map">
+              <MapView markers={markers} onMoveEnd={onMapMove} />
+            </div>
+          </div>
+
+          {totalPages > 1 && (
+            <div className="mt-6 flex items-center justify-center gap-3 text-sm">
+              <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}
+                className="rounded border border-neutral-300 px-3 py-1 disabled:opacity-40">Prev</button>
+              <span>Page {page} of {totalPages}</span>
+              <button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}
+                className="rounded border border-neutral-300 px-3 py-1 disabled:opacity-40">Next</button>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx
new file mode 100644
index 0000000..e66b296
--- /dev/null
+++ b/apps/web/src/app/search/page.tsx
@@ -0,0 +1,12 @@
+import SearchClient from "./SearchClient";
+
+export const dynamic = "force-dynamic";
+
+export default async function SearchPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ q?: string }>;
+}) {
+  const { q } = await searchParams;
+  return <SearchClient initialQuery={q ?? ""} />;
+}
diff --git a/apps/web/src/lib/parse-search.ts b/apps/web/src/lib/parse-search.ts
new file mode 100644
index 0000000..6d9cbc5
--- /dev/null
+++ b/apps/web/src/lib/parse-search.ts
@@ -0,0 +1,45 @@
+import type { SearchParams } from "@spechomes/search";
+
+function num(v: string | null): number | undefined {
+  if (v === null || v === "") return undefined;
+  const n = Number(v);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+export function parseSearchParams(searchParams: URLSearchParams): SearchParams {
+  const bboxRaw = searchParams.get("bbox");
+  let bbox: SearchParams["bbox"];
+  if (bboxRaw) {
+    const [minLat, minLon, maxLat, maxLon] = bboxRaw.split(",").map(Number);
+    if ([minLat, minLon, maxLat, maxLon].every((n) => Number.isFinite(n))) {
+      bbox = { minLat: minLat!, minLon: minLon!, maxLat: maxLat!, maxLon: maxLon! };
+    }
+  }
+  return {
+    q: searchParams.get("q") ?? undefined,
+    bbox,
+    priceMin: num(searchParams.get("priceMin")),
+    priceMax: num(searchParams.get("priceMax")),
+    bedsMin: num(searchParams.get("bedsMin")),
+    bathsMin: num(searchParams.get("bathsMin")),
+    sqftMin: num(searchParams.get("sqftMin")),
+    sqftMax: num(searchParams.get("sqftMax")),
+    homeTypes: searchParams.getAll("homeType"),
+    builderSlugs: searchParams.getAll("builder"),
+    communitySlug: searchParams.get("community") ?? undefined,
+    statuses: searchParams.getAll("status"),
+    storiesEq: num(searchParams.get("stories")),
+    garageMin: num(searchParams.get("garageMin")),
+    hasIncentives: searchParams.get("incentives") === "1" ? true : undefined,
+    ageRestricted:
+      searchParams.get("ageRestricted") === "1" ? true : searchParams.get("ageRestricted") === "0" ? false : undefined,
+    hoaMax: num(searchParams.get("hoaMax")),
+    schoolDistrict: searchParams.get("schoolDistrict") ?? undefined,
+    state: searchParams.get("state") ?? undefined,
+    city: searchParams.get("city") ?? undefined,
+    moveInByMonths: num(searchParams.get("moveInByMonths")),
+    sort: (searchParams.get("sort") as SearchParams["sort"]) ?? "newest",
+    page: num(searchParams.get("page")),
+    pageSize: num(searchParams.get("pageSize")),
+  };
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index b8eeb84..840f56f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -5,16 +5,42 @@
     "allowJs": true,
     "incremental": true,
     "module": "esnext",
-    "plugins": [{ "name": "next" }],
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
     "paths": {
-      "@/*": ["./src/*"],
-      "@spechomes/database": ["../../packages/database/src/index.ts"],
-      "@spechomes/schemas": ["../../packages/schemas/src/index.ts"],
-      "@spechomes/search": ["../../packages/search/src/index.ts"],
-      "@spechomes/shared": ["../../packages/shared/src/index.ts"],
-      "@spechomes/shared-ui": ["../../packages/shared-ui/src/index.ts"]
+      "@/*": [
+        "./src/*"
+      ],
+      "@spechomes/database": [
+        "../../packages/database/src/index.ts"
+      ],
+      "@spechomes/schemas": [
+        "../../packages/schemas/src/index.ts"
+      ],
+      "@spechomes/search": [
+        "../../packages/search/src/index.ts"
+      ],
+      "@spechomes/shared": [
+        "../../packages/shared/src/index.ts"
+      ],
+      "@spechomes/shared-ui": [
+        "../../packages/shared-ui/src/index.ts"
+      ],
+      "@spechomes/publisher": [
+        "../../packages/publisher/src/index.ts"
+      ]
     }
   },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
-  "exclude": ["node_modules"]
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
 }
diff --git a/apps/workers/package.json b/apps/workers/package.json
index 5ed604b..b400501 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -16,7 +16,13 @@
     "@spechomes/shared": "workspace:*",
     "@spechomes/collectors-common": "workspace:*",
     "@spechomes/collector-meridian-homes": "workspace:*",
-    "pg-boss": "^10.1.5"
+    "pg-boss": "^10.1.5",
+    "@spechomes/publisher": "workspace:*"
   },
-  "devDependencies": { "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+  "devDependencies": {
+    "tsx": "^4.19.2",
+    "typescript": "^5.7.2",
+    "vitest": "^4.0.0",
+    "@types/node": "^22.10.5"
+  }
 }
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index a66ca17..04679c3 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -1,6 +1,6 @@
 import { prisma } from "@spechomes/database";
 import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
-import { runPipeline } from "./pipeline.js";
+import { runPipeline } from "./pipeline";
 
 /**
  * Direct pipeline runner — no queue required.
diff --git a/apps/workers/src/index.ts b/apps/workers/src/index.ts
index 44c79e4..f01f2e8 100644
--- a/apps/workers/src/index.ts
+++ b/apps/workers/src/index.ts
@@ -1,6 +1,6 @@
 import { work } from "@spechomes/shared";
 import { meridianHomesAdapter } from "@spechomes/collector-meridian-homes";
-import { runPipeline } from "./pipeline.js";
+import { runPipeline } from "./pipeline";
 
 /**
  * Queue-backed worker daemon (pg-boss). Stages stay pure functions; this
diff --git a/apps/workers/src/pipeline.ts b/apps/workers/src/pipeline.ts
index 61491e3..a34c5f4 100644
--- a/apps/workers/src/pipeline.ts
+++ b/apps/workers/src/pipeline.ts
@@ -7,7 +7,7 @@ import {
   type SourceRegistry,
 } from "@spechomes/database";
 import { validatePayload, type ExtractedRecord } from "@spechomes/schemas";
-import { canonicalKey, normalizeAddress } from "@spechomes/shared";
+import { canonicalKey } from "@spechomes/shared";
 import {
   ALL_RULES,
   runValidation,
@@ -16,6 +16,9 @@ import {
   type StagedCandidate,
 } from "@spechomes/validation";
 import type { RawPage, SourceAdapter } from "@spechomes/collectors-common";
+import { publishStagedRecord } from "@spechomes/publisher";
+
+export { publishStagedRecord };
 
 /**
  * Pipeline stages. Each is a pure-ish function over (adapter, db) — the
@@ -241,133 +244,8 @@ function reconstructHints(
   };
 }
 
-// ── Stage 4: publish (the ONLY writer of published tables) ─────────────
-
-export async function publishStagedRecord(stagedRecordId: string) {
-  const staged = await prisma.stagedRecord.findUniqueOrThrow({
-    where: { id: stagedRecordId },
-    include: { source: true },
-  });
-  if (staged.status !== "VALIDATED" && staged.status !== "APPROVED") {
-    throw new Error(
-      `refusing to publish staged record ${staged.id} with status ${staged.status} — only VALIDATED or human-APPROVED records publish`,
-    );
-  }
-
-  const payload = staged.payload as Record<string, { value: unknown }>;
-  const value = <T>(field: string): T | null => (payload[field]?.value ?? null) as T | null;
-
-  let publishedEntityId: string | null = null;
-
-  if (staged.entityType === "INVENTORY_HOME") {
-    const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
-    const community = await prisma.community.findFirstOrThrow({
-      where: { name: "Cedar Bend", builderId: builder.id },
-    });
-    const priorPrice = await prisma.inventoryHome.findUnique({
-      where: { canonicalKey: staged.canonicalKey },
-      select: { price: true },
-    });
-
-    const price = value<number>("price");
-    const street = value<string>("street");
-    const home = await prisma.inventoryHome.upsert({
-      where: { canonicalKey: staged.canonicalKey },
-      create: {
-        canonicalKey: staged.canonicalKey,
-        communityId: community.id,
-        builderId: builder.id,
-        builderInventoryId: value<string>("builderInventoryId"),
-        street,
-        city: value<string>("city") ?? community.city,
-        state: value<string>("state") ?? community.state,
-        zip: value<string>("zip") ?? community.zip,
-        normalizedAddress: street ? normalizeAddress(street) : null,
-        lotNumber: value<string>("lotNumber"),
-        lat: value<number>("lat"),
-        lon: value<number>("lon"),
-        price,
-        beds: value<number>("beds"),
-        bathsTotal: value<number>("bathsTotal"),
-        sqft: value<number>("sqft"),
-        stories: value<number>("stories"),
-        garageSpaces: value<number>("garageSpaces"),
-        homeType: (value<string>("homeType") as never) ?? "SINGLE_FAMILY",
-        constructionStatus: (value<string>("constructionStatus") as never) ?? "UNDER_CONSTRUCTION",
-        estCompletionDate: value<string>("estCompletionDate")
-          ? new Date(value<string>("estCompletionDate")!)
-          : null,
-        status: "PUBLISHED",
-        freshness: "FRESH",
-        verificationLabel: "DEMONSTRATION", // fixture source — never a production claim
-        lastVerifiedAt: new Date(),
-        sourceId: staged.sourceId,
-        sourceUrl: payload.street?.value ? (payload.street as { sourceUrl?: string }).sourceUrl ?? null : null,
-        isDemo: true,
-      },
-      update: {
-        previousPrice: priorPrice?.price ?? undefined,
-        price,
-        lastVerifiedAt: new Date(),
-        freshness: "FRESH",
-        updatedAt: new Date(),
-      },
-    });
-    publishedEntityId = home.id;
-
-    if (priorPrice?.price && price !== null && Number(priorPrice.price) !== price) {
-      await prisma.changeEvent.create({
-        data: {
-          entityType: "INVENTORY_HOME",
-          entityId: home.id,
-          field: "price",
-          oldValue: String(priorPrice.price),
-          newValue: String(price),
-          sourceId: staged.sourceId,
-        },
-      });
-    }
-  } else if (staged.entityType === "INCENTIVE") {
-    const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
-    const community = await prisma.community.findFirstOrThrow({
-      where: { name: "Cedar Bend", builderId: builder.id },
-    });
-    const expiresAt = value<string>("expiresAt");
-    const incentive = await prisma.incentive.create({
-      data: {
-        scope: "COMMUNITY",
-        builderId: builder.id,
-        communityId: community.id,
-        title: value<string>("title") ?? "Untitled offer",
-        description: value<string>("description"),
-        valueUsd: value<number>("valueUsd"),
-        expiresAt: expiresAt ? new Date(expiresAt) : null,
-        // Spec rule: incentives need an expiration OR an explicit label.
-        evergreenLabel: expiresAt ? null : "expiration not provided",
-        sourceId: staged.sourceId,
-        isDemo: true,
-      },
-    });
-    publishedEntityId = incentive.id;
-  } else if (staged.entityType === "COMMUNITY") {
-    // Fixture communities are pre-seeded (FK target); publishing refreshes verification only.
-    const community = await prisma.community.findFirst({ where: { name: value<string>("name") ?? "" } });
-    publishedEntityId = community?.id ?? null;
-  }
-
-  await prisma.stagedRecord.update({
-    where: { id: staged.id },
-    data: { status: "PUBLISHED", publishedEntityId },
-  });
-  // Re-point evidence at the published entity so consumer pages can cite it.
-  if (publishedEntityId) {
-    await prisma.sourceEvidence.updateMany({
-      where: { stagedRecordId: staged.id },
-      data: { entityType: staged.entityType, entityId: publishedEntityId },
-    });
-  }
-  return publishedEntityId;
-}
+// ── Stage 4: publish lives in @spechomes/publisher (the ONLY writer
+//    of published tables) — re-exported here for pipeline callers. ────
 
 // ── Orchestration: run every stage for one adapter ─────────────────────
 
diff --git a/collectors/common/src/index.ts b/collectors/common/src/index.ts
index e202a52..631bd0f 100644
--- a/collectors/common/src/index.ts
+++ b/collectors/common/src/index.ts
@@ -1 +1 @@
-export * from "./adapter.js";
+export * from "./adapter";
diff --git a/collectors/meridian-homes/src/extract.test.ts b/collectors/meridian-homes/src/extract.test.ts
index 11ed8d2..775caf0 100644
--- a/collectors/meridian-homes/src/extract.test.ts
+++ b/collectors/meridian-homes/src/extract.test.ts
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 import { describe, expect, it } from "vitest";
 import { sha256 } from "@spechomes/collectors-common";
-import { meridianHomesAdapter } from "./index.js";
+import { meridianHomesAdapter } from "./index";
 
 const FIXTURE_DIR = join(import.meta.dirname, "../../../fixtures/raw-pages/meridian-homes");
 const EXPECTED_PATH = join(import.meta.dirname, "../../../fixtures/expected-records/meridian-homes.json");
diff --git a/packages/publisher/package.json b/packages/publisher/package.json
new file mode 100644
index 0000000..e3723bc
--- /dev/null
+++ b/packages/publisher/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "@spechomes/publisher",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "main": "./src/index.ts",
+  "types": "./src/index.ts",
+  "scripts": { "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" },
+  "dependencies": {
+    "@spechomes/database": "workspace:*",
+    "@spechomes/shared": "workspace:*"
+  },
+  "devDependencies": { "typescript": "^5.7.2", "vitest": "^4.0.0", "@types/node": "^22.10.5" }
+}
diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts
new file mode 100644
index 0000000..4488494
--- /dev/null
+++ b/packages/publisher/src/index.ts
@@ -0,0 +1,136 @@
+import { prisma } from "@spechomes/database";
+import { normalizeAddress } from "@spechomes/shared";
+
+/**
+ * THE ONLY WRITER OF PUBLISHED TABLES.
+ *
+ * Publishes a staged record whose status is VALIDATED (deterministic
+ * validators passed) or APPROVED (human review). Any other status throws.
+ * Collectors, extractors, and normalizers never touch published tables;
+ * they end at StagedRecord, and this function is the single bridge.
+ */
+export async function publishStagedRecord(stagedRecordId: string) {
+  const staged = await prisma.stagedRecord.findUniqueOrThrow({
+    where: { id: stagedRecordId },
+    include: { source: true },
+  });
+  if (staged.status !== "VALIDATED" && staged.status !== "APPROVED") {
+    throw new Error(
+      `refusing to publish staged record ${staged.id} with status ${staged.status} — only VALIDATED or human-APPROVED records publish`,
+    );
+  }
+
+  const payload = staged.payload as Record<string, { value: unknown; sourceUrl?: string }>;
+  const value = <T>(field: string): T | null => (payload[field]?.value ?? null) as T | null;
+
+  let publishedEntityId: string | null = null;
+
+  if (staged.entityType === "INVENTORY_HOME") {
+    const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
+    const community = await prisma.community.findFirstOrThrow({
+      where: { name: "Cedar Bend", builderId: builder.id },
+    });
+    const priorPrice = await prisma.inventoryHome.findUnique({
+      where: { canonicalKey: staged.canonicalKey },
+      select: { price: true },
+    });
+
+    const price = value<number>("price");
+    const street = value<string>("street");
+    const home = await prisma.inventoryHome.upsert({
+      where: { canonicalKey: staged.canonicalKey },
+      create: {
+        canonicalKey: staged.canonicalKey,
+        communityId: community.id,
+        builderId: builder.id,
+        builderInventoryId: value<string>("builderInventoryId"),
+        street,
+        city: value<string>("city") ?? community.city,
+        state: value<string>("state") ?? community.state,
+        zip: value<string>("zip") ?? community.zip,
+        normalizedAddress: street ? normalizeAddress(street) : null,
+        lotNumber: value<string>("lotNumber"),
+        lat: value<number>("lat"),
+        lon: value<number>("lon"),
+        price,
+        beds: value<number>("beds"),
+        bathsTotal: value<number>("bathsTotal"),
+        sqft: value<number>("sqft"),
+        stories: value<number>("stories"),
+        garageSpaces: value<number>("garageSpaces"),
+        homeType: (value<string>("homeType") as never) ?? "SINGLE_FAMILY",
+        constructionStatus: (value<string>("constructionStatus") as never) ?? "UNDER_CONSTRUCTION",
+        estCompletionDate: value<string>("estCompletionDate")
+          ? new Date(value<string>("estCompletionDate")!)
+          : null,
+        status: "PUBLISHED",
+        freshness: "FRESH",
+        verificationLabel: "DEMONSTRATION", // fixture source — never a production claim
+        lastVerifiedAt: new Date(),
+        sourceId: staged.sourceId,
+        sourceUrl: payload.street?.sourceUrl ?? null,
+        isDemo: true,
+      },
+      update: {
+        previousPrice: priorPrice?.price ?? undefined,
+        price,
+        lastVerifiedAt: new Date(),
+        freshness: "FRESH",
+        updatedAt: new Date(),
+      },
+    });
+    publishedEntityId = home.id;
+
+    if (priorPrice?.price && price !== null && Number(priorPrice.price) !== price) {
+      await prisma.changeEvent.create({
+        data: {
+          entityType: "INVENTORY_HOME",
+          entityId: home.id,
+          field: "price",
+          oldValue: String(priorPrice.price),
+          newValue: String(price),
+          sourceId: staged.sourceId,
+        },
+      });
+    }
+  } else if (staged.entityType === "INCENTIVE") {
+    const builder = await prisma.builder.findUniqueOrThrow({ where: { slug: "meridian-homes" } });
+    const community = await prisma.community.findFirstOrThrow({
+      where: { name: "Cedar Bend", builderId: builder.id },
+    });
+    const expiresAt = value<string>("expiresAt");
+    const incentive = await prisma.incentive.create({
+      data: {
+        scope: "COMMUNITY",
+        builderId: builder.id,
+        communityId: community.id,
+        title: value<string>("title") ?? "Untitled offer",
+        description: value<string>("description"),
+        valueUsd: value<number>("valueUsd"),
+        expiresAt: expiresAt ? new Date(expiresAt) : null,
+        // Spec rule: incentives need an expiration OR an explicit label.
+        evergreenLabel: expiresAt ? null : "expiration not provided",
+        sourceId: staged.sourceId,
+        isDemo: true,
+      },
+    });
+    publishedEntityId = incentive.id;
+  } else if (staged.entityType === "COMMUNITY") {
+    // Fixture communities are pre-seeded (FK target); publishing refreshes verification only.
+    const community = await prisma.community.findFirst({ where: { name: value<string>("name") ?? "" } });
+    publishedEntityId = community?.id ?? null;
+  }
+
+  await prisma.stagedRecord.update({
+    where: { id: staged.id },
+    data: { status: "PUBLISHED", publishedEntityId },
+  });
+  // Re-point evidence at the published entity so consumer pages can cite it.
+  if (publishedEntityId) {
+    await prisma.sourceEvidence.updateMany({
+      where: { stagedRecordId: staged.id },
+      data: { entityType: staged.entityType, entityId: publishedEntityId },
+    });
+  }
+  return publishedEntityId;
+}
diff --git a/packages/publisher/tsconfig.json b/packages/publisher/tsconfig.json
new file mode 100644
index 0000000..e9bfc48
--- /dev/null
+++ b/packages/publisher/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "include": ["src/**/*.ts"] }
diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts
new file mode 100644
index 0000000..90e1366
--- /dev/null
+++ b/packages/search/src/index.ts
@@ -0,0 +1,258 @@
+import { prisma, Prisma } from "@spechomes/database";
+import { bboxAround, type BBox } from "@spechomes/shared";
+
+/**
+ * Search v1 — two steps:
+ *   A) resolve free-text location (city / zip / community) → center + bbox
+ *      via pg_trgm similarity against Community.searchText
+ *   B) structured filtered page via Prisma findMany inside the bbox
+ * Organic order only — there is no paid placement anywhere in v1.
+ * All filters are objective (Fair Housing: no subjective neighborhood
+ * dimensions exist in the schema).
+ */
+
+export interface SearchParams {
+  q?: string;
+  bbox?: BBox; // explicit map-move bbox skips location resolution
+  priceMin?: number;
+  priceMax?: number;
+  bedsMin?: number;
+  bathsMin?: number;
+  sqftMin?: number;
+  sqftMax?: number;
+  homeTypes?: string[];
+  builderSlugs?: string[];
+  communitySlug?: string;
+  statuses?: string[]; // construction statuses
+  storiesEq?: number;
+  garageMin?: number;
+  hasIncentives?: boolean;
+  ageRestricted?: boolean;
+  hoaMax?: number;
+  schoolDistrict?: string;
+  state?: string;
+  city?: string;
+  moveInByMonths?: number; // move-in timeframe: ready or completing within N months
+  sort?: "newest" | "price_asc" | "price_desc" | "sqft_desc" | "closest";
+  page?: number;
+  pageSize?: number;
+}
+
+export interface ResolvedLocation {
+  label: string;
+  lat: number;
+  lon: number;
+  bbox: BBox;
+}
+
+const DEFAULT_RADIUS_MILES = 30;
+
+export async function resolveLocation(q: string): Promise<ResolvedLocation | null> {
+  const query = q.trim();
+  if (!query) return null;
+  const rows = await prisma.$queryRaw<
+    { id: string; name: string; city: string; state: string; lat: number | null; lon: number | null; sim: number }[]
+  >(Prisma.sql`
+    SELECT id, name, city, state, lat::float8 AS lat, lon::float8 AS lon,
+           similarity("searchText", ${query}) AS sim
+    FROM "Community"
+    WHERE "searchText" % ${query} OR zip = ${query} OR lower(city) = lower(${query})
+    ORDER BY (zip = ${query}) DESC, (lower(city) = lower(${query})) DESC, sim DESC
+    LIMIT 1
+  `);
+  const hit = rows[0];
+  if (!hit || hit.lat === null || hit.lon === null) return null;
+  return {
+    label: `${hit.city}, ${hit.state}`,
+    lat: hit.lat,
+    lon: hit.lon,
+    bbox: bboxAround(hit.lat, hit.lon, DEFAULT_RADIUS_MILES),
+  };
+}
+
+export function buildWhere(params: SearchParams, bbox?: BBox): Prisma.InventoryHomeWhereInput {
+  const now = new Date();
+  const moveInCutoff =
+    params.moveInByMonths !== undefined
+      ? new Date(now.getFullYear(), now.getMonth() + params.moveInByMonths, now.getDate())
+      : undefined;
+
+  return {
+    status: "PUBLISHED",
+    ...(bbox
+      ? {
+          lat: { gte: bbox.minLat, lte: bbox.maxLat },
+          lon: { gte: bbox.minLon, lte: bbox.maxLon },
+        }
+      : {}),
+    ...(params.priceMin !== undefined || params.priceMax !== undefined
+      ? { price: { ...(params.priceMin !== undefined ? { gte: params.priceMin } : {}), ...(params.priceMax !== undefined ? { lte: params.priceMax } : {}) } }
+      : {}),
+    ...(params.bedsMin !== undefined ? { beds: { gte: params.bedsMin } } : {}),
+    ...(params.bathsMin !== undefined ? { bathsTotal: { gte: params.bathsMin } } : {}),
+    ...(params.sqftMin !== undefined || params.sqftMax !== undefined
+      ? { sqft: { ...(params.sqftMin !== undefined ? { gte: params.sqftMin } : {}), ...(params.sqftMax !== undefined ? { lte: params.sqftMax } : {}) } }
+      : {}),
+    ...(params.homeTypes?.length ? { homeType: { in: params.homeTypes as never[] } } : {}),
+    ...(params.builderSlugs?.length ? { builder: { slug: { in: params.builderSlugs } } } : {}),
+    ...(params.communitySlug ? { community: { slug: params.communitySlug } } : {}),
+    ...(params.statuses?.length ? { constructionStatus: { in: params.statuses as never[] } } : {}),
+    ...(params.storiesEq !== undefined ? { stories: params.storiesEq } : {}),
+    ...(params.garageMin !== undefined ? { garageSpaces: { gte: params.garageMin } } : {}),
+    ...(params.state ? { state: params.state } : {}),
+    ...(params.city ? { city: { equals: params.city, mode: "insensitive" } } : {}),
+    ...(moveInCutoff
+      ? {
+          OR: [
+            { constructionStatus: "MOVE_IN_READY" },
+            { estCompletionDate: { lte: moveInCutoff } },
+          ],
+        }
+      : {}),
+    ...(params.hasIncentives
+      ? {
+          OR: [
+            { incentives: { some: {} } },
+            { community: { incentives: { some: { OR: [{ expiresAt: { gte: now } }, { evergreenLabel: { not: null } }] } } } },
+          ],
+        }
+      : {}),
+    ...(params.ageRestricted !== undefined ? { community: { ageRestricted: params.ageRestricted } } : {}),
+    ...(params.hoaMax !== undefined ? { community: { hoaFeeMonthly: { lte: params.hoaMax } } } : {}),
+    ...(params.schoolDistrict ? { community: { schoolDistrict: params.schoolDistrict } } : {}),
+  };
+}
+
+const HOME_INCLUDE = {
+  community: { select: { slug: true, name: true, schoolDistrict: true, hoaFeeMonthly: true, ageRestricted: true } },
+  builder: { select: { slug: true, name: true } },
+  floorPlan: { select: { id: true, name: true } },
+} satisfies Prisma.InventoryHomeInclude;
+
+export type SearchHome = Prisma.InventoryHomeGetPayload<{ include: typeof HOME_INCLUDE }>;
+
+export interface SearchResult {
+  homes: SearchHome[];
+  total: number;
+  page: number;
+  pageSize: number;
+  location: ResolvedLocation | null;
+}
+
+export async function runSearch(params: SearchParams): Promise<SearchResult> {
+  const page = Math.max(1, params.page ?? 1);
+  const pageSize = Math.min(60, Math.max(1, params.pageSize ?? 24));
+
+  let location: ResolvedLocation | null = null;
+  let bbox = params.bbox;
+  if (!bbox && params.q) {
+    location = await resolveLocation(params.q);
+    bbox = location?.bbox;
+  }
+
+  const where = buildWhere(params, bbox);
+
+  const orderBy: Prisma.InventoryHomeOrderByWithRelationInput[] =
+    params.sort === "price_asc"
+      ? [{ price: { sort: "asc", nulls: "last" } }]
+      : params.sort === "price_desc"
+        ? [{ price: { sort: "desc", nulls: "last" } }]
+        : params.sort === "sqft_desc"
+          ? [{ sqft: { sort: "desc", nulls: "last" } }]
+          : [{ publishedAt: "desc" }]; // newest — the organic default
+
+  // sort=closest is the one raw-SQL path (haversine); fall back to newest
+  // when there is no center point to measure from.
+  if (params.sort === "closest" && (location || params.bbox)) {
+    const centerLat = location?.lat ?? (params.bbox!.minLat + params.bbox!.maxLat) / 2;
+    const centerLon = location?.lon ?? (params.bbox!.minLon + params.bbox!.maxLon) / 2;
+    const [homesRaw, total] = await Promise.all([
+      prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
+        SELECT id FROM "InventoryHome"
+        WHERE status = 'PUBLISHED'
+          AND lat BETWEEN ${bbox!.minLat} AND ${bbox!.maxLat}
+          AND lon BETWEEN ${bbox!.minLon} AND ${bbox!.maxLon}
+        ORDER BY 2 * 3958.8 * asin(sqrt(
+          power(sin(radians((lat::float8 - ${centerLat}) / 2)), 2) +
+          cos(radians(${centerLat})) * cos(radians(lat::float8)) *
+          power(sin(radians((lon::float8 - ${centerLon}) / 2)), 2)
+        ))
+        LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
+      `),
+      prisma.inventoryHome.count({ where }),
+    ]);
+    const ids = homesRaw.map((h) => h.id);
+    const unordered = await prisma.inventoryHome.findMany({ where: { id: { in: ids } }, include: HOME_INCLUDE });
+    const byId = new Map(unordered.map((h) => [h.id, h]));
+    return { homes: ids.map((id) => byId.get(id)!).filter(Boolean), total, page, pageSize, location };
+  }
+
+  const [homes, total] = await Promise.all([
+    prisma.inventoryHome.findMany({
+      where,
+      orderBy,
+      include: HOME_INCLUDE,
+      skip: (page - 1) * pageSize,
+      take: pageSize,
+    }),
+    prisma.inventoryHome.count({ where }),
+  ]);
+
+  return { homes, total, page, pageSize, location };
+}
+
+/**
+ * Facet counts with the self-dimension excluded (the corkwallcovering
+ * filterProducts(spec, skip) pattern): each facet's counts are computed
+ * with every OTHER filter applied but not its own.
+ */
+export async function runFacets(params: SearchParams) {
+  let bbox = params.bbox;
+  if (!bbox && params.q) bbox = (await resolveLocation(params.q))?.bbox;
+
+  const without = (key: keyof SearchParams): Prisma.InventoryHomeWhereInput =>
+    buildWhere({ ...params, [key]: undefined }, bbox);
+
+  const [byStatus, byBuilder, byBeds, byType] = await Promise.all([
+    prisma.inventoryHome.groupBy({
+      by: ["constructionStatus"],
+      where: without("statuses"),
+      _count: { _all: true },
+    }),
+    prisma.inventoryHome.groupBy({
+      by: ["builderId"],
+      where: without("builderSlugs"),
+      _count: { _all: true },
+    }),
+    prisma.inventoryHome.groupBy({
+      by: ["beds"],
+      where: without("bedsMin"),
+      _count: { _all: true },
+    }),
+    prisma.inventoryHome.groupBy({
+      by: ["homeType"],
+      where: without("homeTypes"),
+      _count: { _all: true },
+    }),
+  ]);
+
+  const builders = await prisma.builder.findMany({
+    where: { id: { in: byBuilder.map((b) => b.builderId) } },
+    select: { id: true, slug: true, name: true },
+  });
+  const builderById = new Map(builders.map((b) => [b.id, b]));
+
+  return {
+    constructionStatus: byStatus.map((s) => ({ value: s.constructionStatus, count: s._count._all })),
+    builder: byBuilder.map((b) => ({
+      value: builderById.get(b.builderId)?.slug ?? b.builderId,
+      label: builderById.get(b.builderId)?.name ?? b.builderId,
+      count: b._count._all,
+    })),
+    beds: byBeds
+      .filter((b) => b.beds !== null)
+      .sort((a, b) => (a.beds ?? 0) - (b.beds ?? 0))
+      .map((b) => ({ value: b.beds, count: b._count._all })),
+    homeType: byType.map((t) => ({ value: t.homeType, count: t._count._all })),
+  };
+}
diff --git a/packages/shared-ui/src/MapView.tsx b/packages/shared-ui/src/MapView.tsx
new file mode 100644
index 0000000..742fd31
--- /dev/null
+++ b/packages/shared-ui/src/MapView.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useEffect } from "react";
+import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
+import "leaflet/dist/leaflet.css";
+import L from "leaflet";
+
+/**
+ * Props-only map contract — Leaflet + OSM today; a commercial provider can
+ * swap in behind this interface without touching callers. Import this
+ * component with next/dynamic ssr:false (Leaflet needs window).
+ */
+
+export interface MapMarker {
+  id: string;
+  lat: number;
+  lon: number;
+  label: string;
+  sublabel?: string;
+  href?: string;
+}
+
+export interface MapViewProps {
+  markers: MapMarker[];
+  center?: [number, number];
+  zoom?: number;
+  onMoveEnd?: (bbox: { minLat: number; maxLat: number; minLon: number; maxLon: number }) => void;
+  className?: string;
+}
+
+// Default marker sprites don't resolve under bundlers; use a divIcon dot.
+const dotIcon = L.divIcon({
+  className: "",
+  html: '<div style="width:14px;height:14px;border-radius:50%;background:#0f766e;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>',
+  iconSize: [14, 14],
+  iconAnchor: [7, 7],
+});
+
+function MoveListener({ onMoveEnd }: { onMoveEnd?: MapViewProps["onMoveEnd"] }) {
+  const map = useMap();
+  useEffect(() => {
+    if (!onMoveEnd) return;
+    const handler = () => {
+      const b = map.getBounds();
+      onMoveEnd({
+        minLat: b.getSouth(),
+        maxLat: b.getNorth(),
+        minLon: b.getWest(),
+        maxLon: b.getEast(),
+      });
+    };
+    map.on("moveend", handler);
+    return () => {
+      map.off("moveend", handler);
+    };
+  }, [map, onMoveEnd]);
+  return null;
+}
+
+function FitMarkers({ markers }: { markers: MapMarker[] }) {
+  const map = useMap();
+  useEffect(() => {
+    if (markers.length === 0) return;
+    const bounds = L.latLngBounds(markers.map((m) => [m.lat, m.lon] as [number, number]));
+    map.fitBounds(bounds.pad(0.15), { maxZoom: 13 });
+    // Only refit when the marker SET changes, not on every pan.
+  }, [map, markers.map((m) => m.id).join(",")]);
+  return null;
+}
+
+export function MapView({ markers, center = [31.5, -95.0], zoom = 5, onMoveEnd, className }: MapViewProps) {
+  return (
+    <MapContainer center={center} zoom={zoom} className={className} style={{ width: "100%", height: "100%" }}>
+      <TileLayer
+        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+        url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+      />
+      <MoveListener onMoveEnd={onMoveEnd} />
+      <FitMarkers markers={markers} />
+      {markers.map((m) => (
+        <Marker key={m.id} position={[m.lat, m.lon]} icon={dotIcon}>
+          <Popup>
+            {m.href ? (
+              <a href={m.href} style={{ fontWeight: 600 }}>
+                {m.label}
+              </a>
+            ) : (
+              <strong>{m.label}</strong>
+            )}
+            {m.sublabel ? <div>{m.sublabel}</div> : null}
+          </Popup>
+        </Marker>
+      ))}
+    </MapContainer>
+  );
+}
diff --git a/packages/shared-ui/src/badges.tsx b/packages/shared-ui/src/badges.tsx
new file mode 100644
index 0000000..9783bae
--- /dev/null
+++ b/packages/shared-ui/src/badges.tsx
@@ -0,0 +1,49 @@
+import type { ReactElement } from "react";
+
+/** Verification labels — the consumer-trust surface. Colors are fixed per label. */
+const LABEL_STYLES: Record<string, { text: string; classes: string }> = {
+  VERIFIED_FROM_BUILDER: { text: "Verified directly from builder", classes: "bg-emerald-100 text-emerald-900 border-emerald-300" },
+  BUILDER_WEBSITE: { text: "Verified from builder website", classes: "bg-teal-100 text-teal-900 border-teal-300" },
+  BUILDER_SUBMITTED: { text: "Builder-submitted", classes: "bg-sky-100 text-sky-900 border-sky-300" },
+  NEEDS_REVERIFICATION: { text: "Needs reverification", classes: "bg-amber-100 text-amber-900 border-amber-300" },
+  NO_LONGER_AVAILABLE: { text: "No longer available", classes: "bg-neutral-200 text-neutral-700 border-neutral-300" },
+  DEMONSTRATION: { text: "Demonstration data", classes: "bg-violet-100 text-violet-900 border-violet-300" },
+};
+
+export function VerificationBadge({ label }: { label: string }): ReactElement {
+  const style = LABEL_STYLES[label] ?? { text: label, classes: "bg-neutral-100 text-neutral-700 border-neutral-300" };
+  return (
+    <span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${style.classes}`}>
+      {style.text}
+    </span>
+  );
+}
+
+/**
+ * Created date + time chip — mandatory on every admin card (standing rule).
+ * Renders in the viewer's local timezone; full ISO in title for precision.
+ */
+export function CreatedChip({ date }: { date: Date | string }): ReactElement {
+  const d = typeof date === "string" ? new Date(date) : date;
+  return (
+    <span
+      title={d.toISOString()}
+      className="inline-flex items-center gap-1 rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-600"
+    >
+      🕓{" "}
+      {d.toLocaleString(undefined, {
+        year: "numeric",
+        month: "short",
+        day: "numeric",
+        hour: "numeric",
+        minute: "2-digit",
+      })}
+    </span>
+  );
+}
+
+export function fmtPrice(price: number | string | null | undefined): string {
+  if (price === null || price === undefined) return "Price not published";
+  const n = typeof price === "string" ? Number(price) : price;
+  return n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
+}
diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts
new file mode 100644
index 0000000..9ab6296
--- /dev/null
+++ b/packages/shared-ui/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./MapView";
+export * from "./badges";
diff --git a/packages/shared/src/canonical.test.ts b/packages/shared/src/canonical.test.ts
index 4c90b59..e1db91c 100644
--- a/packages/shared/src/canonical.test.ts
+++ b/packages/shared/src/canonical.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { canonicalKey, normalizeAddress } from "./canonical.js";
+import { canonicalKey, normalizeAddress } from "./canonical";
 
 describe("normalizeAddress", () => {
   it("lowercases, strips punctuation, abbreviates street words", () => {
diff --git a/packages/shared/src/geo.test.ts b/packages/shared/src/geo.test.ts
index d7570ea..c39ac01 100644
--- a/packages/shared/src/geo.test.ts
+++ b/packages/shared/src/geo.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { bboxAround, haversineMiles, latLonInState, stateForZip } from "./geo.js";
+import { bboxAround, haversineMiles, latLonInState, stateForZip } from "./geo";
 
 describe("bboxAround", () => {
   it("produces a symmetric box around the center", () => {
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 9d7c174..3c1fe39 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,3 @@
-export * from "./canonical.js";
-export * from "./geo.js";
-export * from "./queue.js";
+export * from "./canonical";
+export * from "./geo";
+export * from "./queue";
diff --git a/packages/validation/src/engine.ts b/packages/validation/src/engine.ts
index 7de159f..2453b14 100644
--- a/packages/validation/src/engine.ts
+++ b/packages/validation/src/engine.ts
@@ -4,7 +4,7 @@ import type {
   ValidationEventInput,
   ValidationOutcome,
   ValidationRule,
-} from "./types.js";
+} from "./types";
 
 /**
  * Run every applicable rule and fold the results into one verdict.
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index c8dcd35..e79f3c1 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -1,3 +1,3 @@
-export * from "./types.js";
-export * from "./rules.js";
-export * from "./engine.js";
+export * from "./types";
+export * from "./rules";
+export * from "./engine";
diff --git a/packages/validation/src/rules.test.ts b/packages/validation/src/rules.test.ts
index b8057ec..3ada84b 100644
--- a/packages/validation/src/rules.test.ts
+++ b/packages/validation/src/rules.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest";
-import { runValidation } from "./engine.js";
+import { runValidation } from "./engine";
 import {
   ALL_RULES,
   bigPriceChange,
@@ -12,8 +12,8 @@ import {
   priceWithinBounds,
   sqftPositive,
   stateZipAgreement,
-} from "./rules.js";
-import type { RuleContext, RuleDb, StagedCandidate } from "./types.js";
+} from "./rules";
+import type { RuleContext, RuleDb, StagedCandidate } from "./types";
 
 function fv<T>(value: T | null, raw?: string): {
   value: T | null; raw: string | null; evidenceText: string | null; sourceUrl: string; confidence: number;
diff --git a/packages/validation/src/rules.ts b/packages/validation/src/rules.ts
index be94713..a84a28c 100644
--- a/packages/validation/src/rules.ts
+++ b/packages/validation/src/rules.ts
@@ -1,5 +1,5 @@
 import { latLonInState, stateForZip } from "@spechomes/shared";
-import { fieldValue, type StagedCandidate, type ValidationRule } from "./types.js";
+import { fieldValue, type StagedCandidate, type ValidationRule } from "./types";
 
 export const VALIDATOR_VERSION = "1.0.0";
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 391fb4a..084f4d2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
       '@spechomes/database':
         specifier: workspace:*
         version: link:../../packages/database
+      '@spechomes/publisher':
+        specifier: workspace:*
+        version: link:../../packages/publisher
       '@spechomes/schemas':
         specifier: workspace:*
         version: link:../../packages/schemas
@@ -148,6 +151,9 @@ importers:
       '@spechomes/database':
         specifier: workspace:*
         version: link:../../packages/database
+      '@spechomes/publisher':
+        specifier: workspace:*
+        version: link:../../packages/publisher
       '@spechomes/schemas':
         specifier: workspace:*
         version: link:../../packages/schemas
@@ -243,6 +249,25 @@ importers:
         specifier: ^4.0.0
         version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
 
+  packages/publisher:
+    dependencies:
+      '@spechomes/database':
+        specifier: workspace:*
+        version: link:../database
+      '@spechomes/shared':
+        specifier: workspace:*
+        version: link:../shared
+    devDependencies:
+      '@types/node':
+        specifier: ^22.10.5
+        version: 22.20.1
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+      vitest:
+        specifier: ^4.0.0
+        version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
+
   packages/schemas:
     dependencies:
       zod:
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0ce2d55..7a4f65a 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -3,7 +3,11 @@
     "target": "ES2022",
     "module": "ESNext",
     "moduleResolution": "bundler",
-    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "lib": [
+      "ES2022",
+      "DOM",
+      "DOM.Iterable"
+    ],
     "strict": true,
     "noUncheckedIndexedAccess": true,
     "esModuleInterop": true,
@@ -14,14 +18,33 @@
     "jsx": "preserve",
     "baseUrl": ".",
     "paths": {
-      "@spechomes/database": ["packages/database/src/index.ts"],
-      "@spechomes/schemas": ["packages/schemas/src/index.ts"],
-      "@spechomes/validation": ["packages/validation/src/index.ts"],
-      "@spechomes/search": ["packages/search/src/index.ts"],
-      "@spechomes/source-registry": ["packages/source-registry/src/index.ts"],
-      "@spechomes/shared": ["packages/shared/src/index.ts"],
-      "@spechomes/shared-ui": ["packages/shared-ui/src/index.ts"],
-      "@spechomes/collectors-common": ["collectors/common/src/index.ts"]
+      "@spechomes/database": [
+        "packages/database/src/index.ts"
+      ],
+      "@spechomes/schemas": [
+        "packages/schemas/src/index.ts"
+      ],
+      "@spechomes/validation": [
+        "packages/validation/src/index.ts"
+      ],
+      "@spechomes/search": [
+        "packages/search/src/index.ts"
+      ],
+      "@spechomes/source-registry": [
+        "packages/source-registry/src/index.ts"
+      ],
+      "@spechomes/shared": [
+        "packages/shared/src/index.ts"
+      ],
+      "@spechomes/shared-ui": [
+        "packages/shared-ui/src/index.ts"
+      ],
+      "@spechomes/collectors-common": [
+        "collectors/common/src/index.ts"
+      ],
+      "@spechomes/publisher": [
+        "packages/publisher/src/index.ts"
+      ]
     }
   }
 }

← 75e00bb worker pipeline (fetch/extract/validate/publish) + CLI + syn  ·  back to Homesonspec  ·  fix Leaflet SSR: keep MapView out of the shared-ui barrel, s 7c746cc →