[object Object]

← back to Stayclaim

[overnight] pre-debate baseline

40eab7af904f48ebf2364e19b559f60d816fbebe · 2026-05-04 05:58:39 -0700 · SteveStudio2

Files touched

Diff

commit 40eab7af904f48ebf2364e19b559f60d816fbebe
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 05:58:39 2026 -0700

    [overnight] pre-debate baseline
---
 .env.example                                       |    5 +
 .env.local.example                                 |   23 +
 .env.production.example                            |   39 +
 .eslintrc.json                                     |   23 +
 .gitignore                                         |    7 +
 CHANGES.md                                         |   19 +
 CHANGES2.md                                        |    9 +
 README.md                                          |   68 +
 REVIEW-2026-05-04.md                               |   73 +
 data/bh-permits-sample.csv                         |   21 +
 data/multiplier_1978_1984.json                     |  140 ++
 db/migrations/002_sponsored_placement.sql          |   29 +
 db/migrations/003_address_submission.sql           |   15 +
 db/migrations/004_place_event.sql                  |   22 +
 db/migrations/005_listing_search.sql               |   16 +
 db/migrations/006_place_first_satellites.sql       |   89 +
 db/migrations/007_film_locations.sql               |   43 +
 db/migrations/008_pd_film_images.sql               |   34 +
 db/migrations/009_pd_film_more_sources.sql         |   11 +
 db/migrations/010_pd_film_openverse.sql            |    8 +
 db/migrations/011_pd_film_met_dpla.sql             |    6 +
 .../012_pd_film_image_match_productions.sql        |   33 +
 db/migrations/013_la_parcel_cache.sql              |   34 +
 db/migrations/014_la_permit_cache.sql              |   25 +
 db/migrations/015_walk_of_fame.sql                 |   27 +
 db/migrations/016_restaurants.sql                  |   51 +
 db/migrations/017_seed_idempotency.sql             |   51 +
 db/migrations/018_widen_sighting_uniq.sql          |   25 +
 db/migrations/019_user_contributions.sql           |   76 +
 db/migrations/020_restaurant_phone.sql             |   53 +
 db/migrations/021_restaurant_image_credits.sql     |   15 +
 db/migrations/022_restaurant_closed_images.sql     |   94 +
 db/migrations/seed-restaurants-batch2.mjs          |  335 +++
 db/schema.sql                                      |  160 ++
 db/seed-bh.sql                                     |  108 +
 db/seed-entities.sql                               |   30 +
 db/seed-events.sql                                 |   35 +
 db/seed-satellites.sql                             |   68 +
 db/seed.sql                                        |   43 +
 docs/deep-research-report.md                       |  144 ++
 docs/prd-pilot-v0.1.md                             |  122 +
 docs/rent-multiplier-1978-1984.md                  |   62 +
 docs/source-matrix.md                              |   57 +
 docs/vision.md                                     |   90 +
 next-env.d.ts                                      |    5 +
 next.config.mjs                                    |   35 +
 package-lock.json                                  | 2573 ++++++++++++++++++++
 package.json                                       |   38 +
 postcss.config.js                                  |    1 +
 scripts/enrich-entities-wikidata.ts                |  146 ++
 scripts/enrich-entities-wikipedia.ts               |  105 +
 scripts/enrich-productions-wikidata.ts             |  149 ++
 scripts/extract-news-addresses.ts                  |  294 +++
 scripts/fetch-pd-film-images.mjs                   |  497 ++++
 scripts/filmla-cpra-followup.sh                    |  116 +
 scripts/ingest-bh-permits-arcgis.ts                |  184 ++
 scripts/ingest-bh-permits.ts                       |  158 ++
 scripts/ingest-la-additional-permits.ts            |  221 ++
 scripts/ingest-la-assessor-legacy.ts               |  210 ++
 scripts/ingest-la-boe-permits.ts                   |  118 +
 scripts/ingest-la-business-licenses.ts             |  179 ++
 scripts/ingest-la-city-permits.ts                  |  391 +++
 scripts/ingest-la-code-enforcement.ts              |  169 ++
 scripts/ingest-la-cofo.ts                          |  144 ++
 scripts/ingest-la-county-assessor.ts               |  273 +++
 scripts/ingest-la-film-permits.ts                  |  264 ++
 scripts/ingest-la-historic-monuments.ts            |  140 ++
 scripts/ingest-la-lahd.ts                          |  209 ++
 scripts/ingest-la-newspapers-metadata.ts           |  183 ++
 scripts/ingest-la-price-series.ts                  |  183 ++
 scripts/ingest-loc-sanborn.ts                      |  155 ++
 scripts/ingest-pasadena-permits.ts                 |  163 ++
 scripts/ingest-santa-monica-permits.ts             |   90 +
 scripts/ingest-weho-film-permits.ts                |  174 ++
 scripts/ingest-wikipedia-named-houses.ts           |  141 ++
 scripts/pd-film-images-refresh.sh                  |   22 +
 scripts/prewarm-la-parcel.mjs                      |  132 +
 scripts/pull-la-newspapers-ocr.ts                  |  173 ++
 scripts/seed-restaurants-batch2.mjs                |  335 +++
 scripts/seed-restaurants.mjs                       |  262 ++
 scripts/seed-stars-batch2.mjs                      |  276 +++
 scripts/seed-stars.mjs                             |  219 ++
 scripts/seed-walk-of-fame-full.mjs                 |  193 ++
 scripts/seed-walk-of-fame.mjs                      |   67 +
 src/app/about/page.tsx                             |  128 +
 src/app/address/[slug]/opengraph-image.tsx         |   83 +
 src/app/address/[slug]/page.tsx                    |  460 ++++
 src/app/admin/data/page.tsx                        |  397 +++
 src/app/admin/layout.tsx                           |   50 +
 src/app/admin/page.tsx                             |  171 ++
 src/app/admin/sources/page.tsx                     |  222 ++
 src/app/api/events/route.ts                        |   49 +
 src/app/api/health/route.ts                        |   14 +
 src/app/api/la-records/lookup/route.ts             |   46 +
 src/app/api/pastdoor-health/route.ts               |   44 +
 src/app/api/promote/route.ts                       |   65 +
 src/app/api/search/route.ts                        |   39 +
 src/app/api/stars/nearby/route.ts                  |  130 +
 src/app/api/submit/route.ts                        |   36 +
 src/app/architects/page.tsx                        |   83 +
 src/app/browse/page.tsx                            |  123 +
 src/app/claim/[slug]/page.tsx                      |   71 +
 src/app/dashboard/page.tsx                         |  142 ++
 src/app/entity/[slug]/opengraph-image.tsx          |   91 +
 src/app/entity/[slug]/page.tsx                     |  162 ++
 src/app/film/[slug]/page.tsx                       |  183 ++
 src/app/films/page.tsx                             |  212 ++
 src/app/globals.css                                |    8 +
 src/app/hosts/page.tsx                             |  159 ++
 src/app/houses/page.tsx                            |  145 ++
 src/app/layout.tsx                                 |  129 +
 src/app/locations-by-year/page.tsx                 |  124 +
 src/app/map/MapWithLayers.tsx                      |   81 +
 src/app/map/page.tsx                               |   49 +
 src/app/neighborhood/[slug]/page.tsx               |   78 +
 src/app/neighborhoods/page.tsx                     |   93 +
 src/app/not-found.tsx                              |   29 +
 src/app/opengraph-image.tsx                        |   84 +
 src/app/page.tsx                                   |   96 +
 src/app/parcel/[apn]/page.tsx                      |  249 ++
 src/app/people/page.tsx                            |  120 +
 src/app/promote/[slug]/PromoteFormClient.tsx       |  163 ++
 src/app/promote/[slug]/page.tsx                    |   64 +
 src/app/promote/page.tsx                           |   63 +
 src/app/restaurants/[slug]/page.tsx                |  283 +++
 src/app/restaurants/page.tsx                       |  179 ++
 src/app/robots.ts                                  |   30 +
 src/app/search/page.tsx                            |  168 ++
 src/app/sitemap.ts                                 |   95 +
 src/app/stars/nearby/StarsNearbyClient.tsx         |  185 ++
 src/app/stars/nearby/page.tsx                      |   46 +
 src/app/stars/page.tsx                             |  168 ++
 src/app/stars/walk/page.tsx                        |  126 +
 src/app/submit/SubmitFormClient.tsx                |  250 ++
 src/app/submit/page.tsx                            |   87 +
 src/app/timeline/page.tsx                          |  221 ++
 src/app/timeline/rss.xml/route.ts                  |   58 +
 src/components/AddressMasthead.tsx                 |   59 +
 src/components/AddressNarrative.tsx                |  210 ++
 src/components/BreadcrumbArchive.tsx               |   24 +
 src/components/ClaimCTA.tsx                        |   28 +
 src/components/ClaimExamples.tsx                   |   83 +
 src/components/ConfidenceDot.tsx                   |   40 +
 src/components/CrossPromo.tsx                      |   50 +
 src/components/CurveSparkline.tsx                  |   87 +
 src/components/FactRow.tsx                         |   37 +
 src/components/FilmsGrid.tsx                       |  288 +++
 src/components/FilmsGridServer.tsx                 |   65 +
 src/components/GucciStripe.tsx                     |   21 +
 src/components/HeaderSearch.tsx                    |  193 ++
 src/components/HeroCollage.tsx                     |  194 ++
 src/components/InteractiveMap.tsx                  |  147 ++
 src/components/InteractiveMapMount.tsx             |   27 +
 src/components/JsonLd.tsx                          |   15 +
 src/components/ListingGrid.tsx                     |  121 +
 src/components/MapHero.tsx                         |   41 +
 src/components/MediaWall.tsx                       |   81 +
 src/components/MiniMap.tsx                         |   99 +
 src/components/NodeCard.tsx                        |   55 +
 src/components/PermitTable.tsx                     |  133 +
 src/components/PosterPlaceholder.tsx               |  159 ++
 src/components/Projection.tsx                      |   39 +
 src/components/ProvenanceFooter.tsx                |   62 +
 src/components/Rail.tsx                            |   22 +
 src/components/RetiredFeatureNotice.tsx            |   50 +
 src/components/SourceLink.tsx                      |   65 +
 src/components/StarsCtaRow.tsx                     |  101 +
 src/components/StreetView.tsx                      |   71 +
 src/components/StreetViewHistory.tsx               |  193 ++
 src/components/SubmitMiniMap.tsx                   |   38 +
 src/components/TierBadge.tsx                       |   53 +
 src/components/Timeline.tsx                        |   48 +
 src/components/Wordmark.tsx                        |   44 +
 src/components/home/ClaimHome.tsx                  |  199 ++
 src/components/home/CommunityHome.tsx              |  189 ++
 src/components/home/FlagshipHome.tsx               |  230 ++
 src/components/sponsored/SponsoredCard.tsx         |   78 +
 src/lib/db.ts                                      |  679 ++++++
 src/lib/flags.ts                                   |   14 +
 src/lib/la-arcgis.ts                               |  250 ++
 src/lib/la-permits.ts                              |  123 +
 src/lib/multiplier.ts                              |   85 +
 src/lib/site.ts                                    |  105 +
 src/middleware.ts                                  |  113 +
 tailwind.config.ts                                 |   21 +
 tsconfig.json                                      |   21 +
 tsconfig.tsbuildinfo                               |    1 +
 187 files changed, 23607 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..32e268d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# Copy to .env.local or export these variables in the process environment.
+
+# Gmail HTTP agent Basic Auth for scripts/filmla-cpra-followup.sh.
+# Format: user:password
+FILMLA_GEORGE_BASIC_AUTH=
diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 0000000..fc89b0f
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,23 @@
+# Copy this file to .env.local and fill in.
+# .env.local is gitignored.
+
+# Gmail HTTP agent Basic Auth for scripts/filmla-cpra-followup.sh.
+# Format: user:password
+FILMLA_GEORGE_BASIC_AUTH=
+
+# Postgres connection — defaults to local unix socket / DB "stayclaim".
+# DATABASE_URL=postgres://user:pass@host:5432/stayclaim
+
+# Google Maps Platform key — enables year-by-year Street View history.
+# Required APIs to enable on this key:
+#   - Maps JavaScript API     (StreetViewService for historical pano enumeration)
+#   - Street View Static API  (per-pano thumbnail images)
+# Restrict by HTTP referrer in the Cloud Console:
+#   http://localhost:9821/*  (dev)
+#   https://pastdoor.com/*   (prod, when domain registered)
+# Cost guard: each address page renders ~6-12 SV requests. Set a daily quota.
+# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
+
+# Stripe (pending RAK from steve@designerwallcoverings.com)
+# STRIPE_SECRET_KEY=sk_test_xxx
+# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
diff --git a/.env.production.example b/.env.production.example
new file mode 100644
index 0000000..184b4dd
--- /dev/null
+++ b/.env.production.example
@@ -0,0 +1,39 @@
+# pastdoor production env — copy to .env.production on Kamatera
+
+# Gmail HTTP agent Basic Auth for scripts/filmla-cpra-followup.sh.
+# Format: user:password
+FILMLA_GEORGE_BASIC_AUTH=
+
+# ---- Free forever (Steve's standing decision) ----
+# Soft-disables /promote, /hosts, sponsored cards, paid CTAs.
+# Set to 'false' to revive the Airbnb-claim-and-upgrade model.
+FREE_FOREVER=true
+
+# ---- Database (Kamatera local PG) ----
+DATABASE_URL=postgres://stevestudio2@localhost:5432/pastdoor
+
+# ---- Mapbox (free tier covers MVP — get at https://account.mapbox.com/access-tokens) ----
+NEXT_PUBLIC_MAPBOX_TOKEN=
+
+# ---- Auth (claimmyaddress.com is the OAuth identity provider) ----
+AUTH_SECRET=
+AUTH_URL=https://auth.wholivedthere.com
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+# ---- Email (Resend for transactional, Mailgun for newsletters) ----
+RESEND_API_KEY=
+RESEND_FROM=archive@wholivedthere.com
+
+# ---- Storage (Cloudflare R2 for uploaded documents) ----
+R2_ACCOUNT_ID=
+R2_ACCESS_KEY_ID=
+R2_SECRET_ACCESS_KEY=
+R2_BUCKET=pastdoor-documents
+
+# ---- Browserbase (for ZIMAS / LADBS / Assessor scraping) ----
+BROWSERBASE_API_KEY=
+BROWSERBASE_PROJECT_ID=
+
+# ---- Anthropic (Claude API for OCR + editorial content generation) ----
+ANTHROPIC_API_KEY=
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..a55eb9c
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,23 @@
+{
+  "root": true,
+  "extends": ["next/core-web-vitals"],
+  "rules": {
+    "no-restricted-imports": [
+      "error",
+      {
+        "patterns": [
+          {
+            "group": ["@/components/sponsored/*", "./sponsored/*", "../sponsored/*"],
+            "message": "Editorial components must not import from sponsored/*. Firewall rule — see plan §1 (Promote-a-Listing editorial firewall). If you need this here, reconsider the boundary."
+          }
+        ]
+      }
+    ]
+  },
+  "overrides": [
+    {
+      "files": ["src/components/sponsored/**/*.tsx", "src/app/**/page.tsx", "src/app/**/layout.tsx", "src/app/api/promote/**/*.ts"],
+      "rules": { "no-restricted-imports": "off" }
+    }
+  ]
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8a4c857
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+.next
+.env
+.env.local
+.env.*.local
+*.log
+.DS_Store
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..87b7625
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,19 @@
+## Applied
+- `scripts/filmla-cpra-followup.sh:16` — removed the hardcoded Basic Auth credential and read it from `FILMLA_GEORGE_BASIC_AUTH` — prevents source exposure of the Gmail HTTP agent password.
+- `scripts/filmla-cpra-followup.sh:41` — added a fail-fast check when `FILMLA_GEORGE_BASIC_AUTH` is missing — avoids silently calling the agent without required credentials.
+- `.env.example:5` — added a placeholder for `FILMLA_GEORGE_BASIC_AUTH` — documents the required secret without copying the real value.
+- `src/app/search/page.tsx:42` — added a small snippet renderer that treats only literal `<mark>` tokens as markup — preserves highlights while letting React escape OCR/source text.
+- `src/app/search/page.tsx:144` — replaced `dangerouslySetInnerHTML` with React-rendered snippet content — removes the stored DOM injection path flagged for search results.
+
+## Deferred (needs Steve)
+- P0 — `.env.local:4` — real Google Maps browser key exists in the workspace — needs key rotation, referrer restrictions, quotas, and `.env.local` handling outside this safe patch.
+- P0 — `scripts/filmla-cpra-followup.sh:15` — Gmail agent still uses a plain HTTP tailnet URL and the removed credential still needs rotation — requires Steve’s secret rotation and network/Tailscale decision.
+- P0 — `src/app/admin/layout.tsx:45` — `/admin/*` is explicitly unauthenticated — auth/proxy policy is larger than the allowed safe fixes.
+- P0 — `src/app/dashboard/page.tsx:11` — `?email=` acts as authentication — requires product/auth flow changes.
+- P0 — `package.json:17` — Next version is below the reviewed patched version — package version bumps are out of scope.
+- P1 — `src/app/claim/[slug]/page.tsx:34` — claim form has no submission path — feature behavior change, not in safe list.
+- P1 — `src/app/search/page.tsx:44` — server fetch trusts the incoming `Host` header — not in safe list; needs routing/search architecture decision.
+- P1 — `src/app/api/search/route.ts:47` — no max query length before DB fanout — validation policy change, not in safe list.
+- P1 — `scripts/extract-news-addresses.ts:212` — delete/insert/mark flow lacks transaction/advisory lock — transaction/concurrency change deferred.
+- P1 — `src/app/admin/data/page.tsx:217` — filter DB errors are swallowed — admin UX/reporting decision deferred.
+- P1 — `package.json:12` — `ingest:airbnb` points to a missing script — command removal/restoration decision deferred.
diff --git a/CHANGES2.md b/CHANGES2.md
new file mode 100644
index 0000000..5cde298
--- /dev/null
+++ b/CHANGES2.md
@@ -0,0 +1,9 @@
+## Round 2 Applied
+- `scripts/filmla-cpra-followup.sh:16` — added optional sourcing of `${FILMLA_CPRA_ENV_FILE:-$HOME/.filmla-cpra-followup.env}` before `FILMLA_GEORGE_BASIC_AUTH` is read — fixes re-review "STATUS broken" / P1 finding that launchd/non-interactive runs would otherwise exit before checking or sending.
+- `.env.local.example:4` — added `FILMLA_GEORGE_BASIC_AUTH` placeholder — fixes re-review missed call-site: local setup template lacked the required secret.
+- `.env.production.example:3` — added `FILMLA_GEORGE_BASIC_AUTH` placeholder — fixes re-review missed call-site: production setup template lacked the required secret.
+
+## Still Deferred
+- Steve still needs to put the real `FILMLA_GEORGE_BASIC_AUTH` value into launchd or a secure local-only env file such as `$HOME/.filmla-cpra-followup.env`; no real secret was added.
+- Steve still needs to rotate the removed Basic Auth credential and decide whether the plain HTTP tailnet agent URL is acceptable.
+- The original deferred P0/P1 items in `CHANGES.md` remain deferred because they require auth/product/package/database/ops decisions or are explicitly outside the round-2 safe-edit scope.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eabec6d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,68 @@
+# stayclaim
+
+Address-first property-history platform. Pivoted from the original
+"Airbnb listing claim" concept after the deep research in
+[docs/deep-research-report.md](docs/deep-research-report.md).
+
+## The pivot in one line
+
+We are **not** building a current-occupancy / stalking database.
+We are building a **place-first, time-aware property history product**
+with privacy guardrails — then selling the claim-and-customize
+upgrade on top of those public records.
+
+## Product model
+
+| Tier     | Who sees it       | What they get                                                 |
+|----------|-------------------|---------------------------------------------------------------|
+| Free     | anyone            | Auto-generated page: place record, parcel history, archival   |
+|          |                   | media, permit events, verified historical associations.       |
+| Basic    | claimed owner     | $9.99/mo — photos, contact info, custom copy, hosting,        |
+|          |                   | email-verified edits, rental projections.                     |
+| Pro      | claimed owner     | TBD — custom domain, booking integration, API access.         |
+
+## Privacy guardrails (hard rules from the research)
+
+- No current celebrity home addresses
+- No inference-based current occupancy claims
+- All human/business associations are stored as **claims** with
+  `date_range` + `source_tier`, never as eternal truths
+- Source classes: A govt · B archive · C verified secondary · D social/lead
+
+## Why we don't use a standard rent multiplier
+
+See [docs/rent-multiplier-1978-1984.md](docs/rent-multiplier-1978-1984.md).
+
+The rental projection for every address is derived from the observed
+1978-1984 LA County price + rent trajectory — the fastest
+rate-of-change event in post-war LA housing. No canned CPI, no flat 3%.
+Every number carries its source tier on display.
+
+## Stack
+
+- Next.js 14 + TypeScript + Tailwind
+- PostgreSQL + **PostGIS** (parcel geometry from day one)
+- OpenSearch/Elasticsearch for address + alt-spelling search (later)
+- Object storage for scanned maps / archival photos (later)
+- Stripe for the $9.99 tier (pending RAK)
+
+## Pilot geography
+
+Beverly Hills. Matches the research report's recommendation — small,
+well-documented, strong archival support, public permits updated nightly.
+
+## Ports
+
+- Dev: http://localhost:9820
+- DB:  `stayclaim` on local Postgres
+
+## Status (2026-04-24)
+
+- [x] DB schema drafted (free listing tier, claim flow, upgrade table)
+- [x] 4 seed listings for UI dev
+- [ ] Place/parcel/structure/event/occupancy split per research report
+- [ ] PostGIS + parcel ingestion from LA County Assessor
+- [ ] Beverly Hills permit data pipeline
+- [ ] 1978-1984 multiplier data ingest + curve fit
+- [ ] Claim email-verification flow
+- [ ] Stripe — waiting on RAK
diff --git a/REVIEW-2026-05-04.md b/REVIEW-2026-05-04.md
new file mode 100644
index 0000000..0ae67f2
--- /dev/null
+++ b/REVIEW-2026-05-04.md
@@ -0,0 +1,73 @@
+# stayclaim (pastdoor) — overnight debate-team review (2026-05-04)
+
+Code-reviewer + architect-reviewer parallel run. **3 P0 patches applied + 2 P0 surfaced to Steve** (Maps key rotation + Maps key history scrub). Architecture verdict: stayclaim is **NOT** a `directory-core` consumer — different shape.
+
+## P0 patches APPLIED (local — NOT deployed)
+
+| # | File:line | Issue | Fix |
+|---|---|---|---|
+| 1 | `src/middleware.ts` | `/admin/*` was completely unauthenticated — `admin/layout.tsx:45` literally said `auth: dev-mode (no auth)`. Anyone hitting `wholivedthere.com/admin` saw the full PG dashboard, claim-request emails, sponsored-placement contact_email | HTTP Basic gate via `ADMIN_SECRET` env var (constant-time compare); fail-closed in production if secret unset (503), allow in dev |
+| 2 | `src/middleware.ts` | `/dashboard?email=victim@example.com` returned that user's claim list — trivial IDOR | Block `/dashboard` in production (404); dev still has it for local testing |
+| 3 | `src/app/api/health/route.ts` + new `pastdoor-health/route.ts` | `/api/health` collides with Site Factory's nginx reserved prefix — every probe was hitting Site Factory, never reaching pastdoor. `deploy-pastdoor.sh` + `smoke-test.sh` were silently broken | New handler at `/api/pastdoor-health`; old path returns 410 Gone with redirect message. Update `deploy-pastdoor.sh` + `smoke-test.sh` to call new URL. |
+
+## P0 NEEDS STEVE (3 actions)
+
+### 1. **Live Google Maps API key in `.env.local`** (`AIzaSyDP-lKLvCQNPUvJ3mxyl1bjBHFO5g57ETw`)
+
+`NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` ships embedded in the JS bundle to every browser visitor. Required actions:
+1. **Rotate immediately via GCP console** — old key compromised by being public.
+2. Add HTTP referrer restrictions to the new key (only `wholivedthere.com`, `claimmyaddress.com`, `bubbesblock.com`).
+3. Re-save via `~/Projects/secrets-manager/cli.js add` so it's tracked.
+4. Confirm the key was never committed to a git history (`git log --all --full-history -- .env.local`).
+
+### 2. Set `ADMIN_SECRET` in pm2 env
+
+Without it, the new `/admin/*` middleware returns 503 in production. Mint via `/secrets` skill, route to pastdoor's pm2 env, `pm2 reload pastdoor --update-env`.
+
+### 3. Update `deploy-pastdoor.sh` + `smoke-test.sh` to call `/api/pastdoor-health`
+
+Old `/api/health` still returns 410 so callers will fail loud, but updating them in the same release is cleaner.
+
+## P1 / P2 deferred (in REVIEW)
+
+- `src/app/admin/data/page.tsx:220` — `getDistinctValues` interpolates `col`/`table` into raw SQL via template literals. Today `SOURCES` is hardcoded so not injectable, but the pattern is fragile. Use allowlist map.
+- `src/app/api/submit/route.ts:19`, `api/promote/route.ts:46` — Zod errors return `String(e)` which echoes received PII (emails) back. Map to `issues.map(i=>i.message).join('; ')`.
+- `src/app/api/la-records/lookup/route.ts:44` — leaks raw `e.message` (DB / ArcGIS connection details) to client. Generic `lookup failed`; full server-log only.
+- No `headers()` export in `next.config.mjs` — missing CSP, X-Frame-Options, X-Content-Type-Options, HSTS.
+- No CSRF / Origin check on `/api/submit`, `/api/promote`. Add origin allowlist for the 3 sister domains.
+- No rate limiting on any endpoint.
+- `src/lib/la-arcgis.ts:160`, `la-permits.ts:53` — `interval '${CACHE_TTL_DAYS} days'` interpolated into SQL (low risk: const number, but fragile pattern).
+- `*.archive.org` and `*.flickr.com` wildcards in `next.config.mjs` `remotePatterns` — Next.js Image proxy could be abused via attacker-controlled subdomains. Scope to known hosts.
+- `/api/promote` `external_url` accepts `http://localhost`/`http://169.254.169.254` (no SSRF today since URL not fetched server-side, but exploitable when crawler lands).
+- **Dark/light toggle missing** per standing rule. Tailwind refactor harder — flagged but deferred per review brief.
+
+## Architecture roadmap
+
+**Architectural Impact: HIGH.** Schema strain from Airbnb→address-history pivot, admin tier was unauth, codebase at the inflection point.
+
+### What stayclaim actually is now (multi-tenant archive masquerading as listing site)
+
+- `db/schema.sql:7-43` — central table is still named **`listing`** with airbnb-shaped columns (`nightly_price`, `currency`, `sleeps`, `amenities jsonb`). Films, restaurants, parcels, stars all FK into `listing.id` as if they were Airbnb listings.
+- `src/middleware.ts` reveals this single Next.js process serves **3 domains**: wholivedthere.com (flagship), claimmyaddress.com (onboarding → /submit), bubbesblock.com (community → /neighborhoods). Plus stayclaim/pastdoor itself. The real architectural innovation is the host-routing middleware.
+
+### 5 highest-leverage structural changes
+
+1. **(DONE tonight)** Add admin auth — was the most urgent gap.
+2. **(needs Steve)** Rotate the Maps key + domain-restrict it in GCP.
+3. **Split `src/lib/db.ts` (679 LOC)** into per-domain repos: `repos/{listings,entities,events,films,restaurants,stars,media,parcels,owners,neighborhoods}.ts`. Same anti-pattern as lawyer/doctor reviews.
+4. **Rename `listing` → `place`** (or split it into `place` root + `place_listing_meta` rental-specific). Currently every new vertical inherits Airbnb-shaped baggage. Restaurant has its own table (`016_restaurants.sql`) but `entity_place_association.listing_id` FKs to `listing` only — so a restaurant-as-place can't be associated with a person without a fake listing row.
+5. **Document deploy story in this repo.** `deploy-pastdoor.sh` is referenced by `api/health/route.ts` but lives elsewhere. Add `scripts/deploy.sh` checked-in; survive context wipes.
+
+### `directory-core` verdict: **NOT A FIT**
+
+stayclaim is a different shape from doctor/lawyer/animals/restaurant verticals:
+- Those four: scrape provider list → enrich → render directory page per provider → 3 mockups → claim/upgrade. **One entity type per site.**
+- stayclaim: aggregate **9+ heterogeneous source datasets** (LADBS permits, BH permits, LA assessor, film permits, Walk of Fame, Wikipedia houses, restaurants, newspapers, Sanborn maps) across **6 place types** (address/parcel/entity/film/restaurant/star) into **3 tenant-branded sites** sharing one DB.
+
+What stayclaim COULD share is narrower: a `place-graph-core` package containing (a) the source-tier A/B/C/D pattern, (b) the `public_visible` privacy gate trigger, (c) entity↔place association model, (d) media-asset rights tracking. For bubbesblock/claimmyaddress to consume — not for the directory verticals.
+
+## Files touched
+
+- `/Users/stevestudio2/Projects/stayclaim/src/middleware.ts`
+- `/Users/stevestudio2/Projects/stayclaim/src/app/api/health/route.ts`
+- `/Users/stevestudio2/Projects/stayclaim/src/app/api/pastdoor-health/route.ts` (NEW)
diff --git a/data/bh-permits-sample.csv b/data/bh-permits-sample.csv
new file mode 100644
index 0000000..918f1de
--- /dev/null
+++ b/data/bh-permits-sample.csv
@@ -0,0 +1,21 @@
+permit_number,issued_date,address,scope,permit_type,valuation,applicant
+BH-2024-1287,2024-03-15,1100 Elden Wy,Replace 12 windows; like-for-like.,Building,42500,homeowner
+BH-2024-1342,2024-04-02,814 N Roxbury Dr,Pool equipment replacement.,Mechanical,18200,homeowner
+BH-2024-1408,2024-04-21,512 N Camden Dr,Solar PV array - 8.4kW roof-mounted.,Electrical,38000,Sunrun
+BH-2024-1455,2024-05-08,925 Benedict Canyon Dr,Re-roof - clay tile to clay tile.,Building,87000,Pacific Roofing
+BH-2024-1502,2024-05-19,600 N Bedford Dr,Kitchen interior remodel; no structural.,Building,165000,homeowner
+BH-2024-1601,2024-06-04,410 N Rexford Dr,Foundation reinforcement - bolt to mudsill.,Building,52000,Allen Construction
+BH-2024-1689,2024-06-22,230 S Palm Dr,Replace water heater (tankless conversion).,Plumbing,9800,homeowner
+BH-2024-1742,2024-07-08,1037 Loma Vista Dr,Hardscape - rear patio expansion.,Building,38500,Hadley Landscape
+BH-2024-1801,2024-07-29,720 N Bedford Dr,New SFR construction - tear-down replacement.,Building,4200000,Bagshaw Builders
+BH-2024-1855,2024-08-12,705 N Maple Dr,Master bath remodel; relocate fixtures.,Plumbing,72000,homeowner
+BH-2024-1912,2024-08-28,316 S Beverly Glen Blvd,EV charger - 240V dedicated circuit.,Electrical,3400,homeowner
+BH-2024-2003,2024-09-14,902 N Rexford Dr,Replace HVAC - 5-ton heat pump.,Mechanical,21000,Anderson HVAC
+BH-2024-2087,2024-09-30,144 N Linden Dr,Garage door replacement (motorized).,Building,12500,homeowner
+BH-2024-2156,2024-10-17,1100 Elden Wy,Replace HVAC system; high-efficiency upgrade.,Mechanical,46000,Anderson HVAC
+BH-2024-2244,2024-11-04,814 N Roxbury Dr,Bathroom remodel - 2 baths simultaneous.,Plumbing,118000,homeowner
+BH-2024-2305,2024-11-22,520 N Roxbury Dr,Window restoration - landmark guidelines.,Building,67500,Roy Donohue Restoration
+BH-2024-2401,2024-12-09,711 Walden Dr,Detached studio - 380 sqft over garage.,Building,224000,Adams Architects
+BH-2025-0102,2025-01-14,925 Benedict Canyon Dr,Pool resurfacing; no structural.,Building,32500,homeowner
+BH-2025-0205,2025-02-03,415 S Camden Dr,New SFR construction.,Building,3850000,Westside Building
+BH-2025-0298,2025-02-21,512 N Camden Dr,Olive grove drip irrigation overhaul.,Plumbing,14200,Hadley Landscape
diff --git a/data/multiplier_1978_1984.json b/data/multiplier_1978_1984.json
new file mode 100644
index 0000000..fb4a600
--- /dev/null
+++ b/data/multiplier_1978_1984.json
@@ -0,0 +1,140 @@
+{
+  "generated_at": "2026-04-24T18:27:06.922Z",
+  "anchor_window": "1978-01 → 1984-12",
+  "geography": "Los Angeles County (LA-Long Beach-Anaheim MSA where rent)",
+  "sources": {
+    "homePrice": "FRED · ATNHPIUS06037A",
+    "rent": "FRED · CUURA421SEHA (BLS CPI-U rent LA-LB-Anaheim)"
+  },
+  "price_yoy": [
+    {
+      "year": 1978,
+      "value": 30.19,
+      "yoy_pct": null
+    },
+    {
+      "year": 1979,
+      "value": 35.66,
+      "yoy_pct": 18.118582312023833
+    },
+    {
+      "year": 1980,
+      "value": 41.85,
+      "yoy_pct": 17.35838474481213
+    },
+    {
+      "year": 1981,
+      "value": 45.25,
+      "yoy_pct": 8.124253285543604
+    },
+    {
+      "year": 1982,
+      "value": 40,
+      "yoy_pct": -11.602209944751381
+    },
+    {
+      "year": 1983,
+      "value": 47.49,
+      "yoy_pct": 18.725000000000005
+    },
+    {
+      "year": 1984,
+      "value": 48.99,
+      "yoy_pct": 3.1585596967782688
+    }
+  ],
+  "rent_yoy": [
+    {
+      "year": 1978,
+      "value": 59.4,
+      "yoy_pct": null
+    },
+    {
+      "year": 1979,
+      "value": 65,
+      "yoy_pct": 9.42760942760943
+    },
+    {
+      "year": 1980,
+      "value": 72.2,
+      "yoy_pct": 11.076923076923082
+    },
+    {
+      "year": 1981,
+      "value": 80.9,
+      "yoy_pct": 12.049861495844878
+    },
+    {
+      "year": 1982,
+      "value": 89.9,
+      "yoy_pct": 11.124845488257106
+    },
+    {
+      "year": 1983,
+      "value": 96.7,
+      "yoy_pct": 7.563959955506114
+    },
+    {
+      "year": 1984,
+      "value": 102.8,
+      "yoy_pct": 6.3081695966907905
+    }
+  ],
+  "phases": [
+    {
+      "name": "first_leg_up",
+      "from": 1978,
+      "to": 1979,
+      "price_yoy": 18.118582312023833
+    },
+    {
+      "name": "rate_shock_stall",
+      "from": 1980,
+      "to": 1982,
+      "price_yoy": 4.626809361868118
+    },
+    {
+      "name": "recovery",
+      "from": 1983,
+      "to": 1984,
+      "price_yoy": 10.941779848389137
+    }
+  ],
+  "months_to_double": {
+    "first_leg_up": 49.95089283781449,
+    "rate_shock_stall": 183.90079644584213,
+    "recovery": 80.10532459526998,
+    "cycle_avg": 96.72032144407616
+  },
+  "rent_to_price_ratio": {
+    "1978": {
+      "home_price": 30.19,
+      "rent_cpi": 59.4
+    },
+    "1979": {
+      "home_price": 35.66,
+      "rent_cpi": 65
+    },
+    "1980": {
+      "home_price": 41.85,
+      "rent_cpi": 72.2
+    },
+    "1981": {
+      "home_price": 45.25,
+      "rent_cpi": 80.9
+    },
+    "1982": {
+      "home_price": 40,
+      "rent_cpi": 89.9
+    },
+    "1983": {
+      "home_price": 47.49,
+      "rent_cpi": 96.7
+    },
+    "1984": {
+      "home_price": 48.99,
+      "rent_cpi": 102.8
+    }
+  },
+  "editorial_rule": "Never display a projection without its anchor window + confidence dot. Never claim predictive accuracy beyond the documented cycle."
+}
diff --git a/db/migrations/002_sponsored_placement.sql b/db/migrations/002_sponsored_placement.sql
new file mode 100644
index 0000000..ae51cc0
--- /dev/null
+++ b/db/migrations/002_sponsored_placement.sql
@@ -0,0 +1,29 @@
+-- Sponsored placement intake — paid module rendered above the timeline on
+-- an address page. NEVER touches event / entity_place_association /
+-- listing_override (those are editorial truth).
+CREATE TABLE IF NOT EXISTS sponsored_placement (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id      uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  source          text NOT NULL,                       -- 'zillow' | 'redfin' | 'airbnb' | 'other'
+  external_url    text NOT NULL,
+  headline        text NOT NULL,
+  price_label     text,
+  contact_email   text NOT NULL,
+  affiliation     text NOT NULL,                       -- "listing agent for…", "owner", etc.
+  permit_number   text,                                -- self-attested STR permit if jurisdiction requires
+  status          text NOT NULL DEFAULT 'pending',     -- pending | approved | rejected | active | expired
+  monthly_price   numeric(8,2) NOT NULL DEFAULT 29.00,
+  stripe_subscription_id text,
+  submitted_at    timestamptz NOT NULL DEFAULT now(),
+  reviewed_at     timestamptz,
+  active_through  timestamptz
+);
+CREATE INDEX IF NOT EXISTS idx_sponsored_listing ON sponsored_placement (listing_id);
+CREATE INDEX IF NOT EXISTS idx_sponsored_status  ON sponsored_placement (status);
+
+-- Public read view: only currently-active placements
+CREATE OR REPLACE VIEW v_sponsored_active AS
+SELECT id, listing_id, source, external_url, headline, price_label
+FROM sponsored_placement
+WHERE status = 'active'
+  AND (active_through IS NULL OR active_through > now());
diff --git a/db/migrations/003_address_submission.sql b/db/migrations/003_address_submission.sql
new file mode 100644
index 0000000..6aa4493
--- /dev/null
+++ b/db/migrations/003_address_submission.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS address_submission (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  address_line1   text NOT NULL,
+  city            text NOT NULL,
+  state           text NOT NULL,
+  postal_code     text,
+  notes           text,
+  contact_email   text NOT NULL,
+  status          text NOT NULL DEFAULT 'pending',
+  submitted_at    timestamptz NOT NULL DEFAULT now(),
+  reviewed_at     timestamptz,
+  reviewer_note   text,
+  ingested_listing_id uuid REFERENCES listing(id)
+);
+CREATE INDEX IF NOT EXISTS idx_addrsub_status ON address_submission (status);
diff --git a/db/migrations/004_place_event.sql b/db/migrations/004_place_event.sql
new file mode 100644
index 0000000..0a0f92a
--- /dev/null
+++ b/db/migrations/004_place_event.sql
@@ -0,0 +1,22 @@
+-- Append-only event ledger per research report § "How to handle the big-data side":
+--   Never overwrite history. Insert new events with valid_from, valid_to, recorded_at, source_id.
+-- Each event ties to a listing (place) and carries its own source tier.
+CREATE TABLE IF NOT EXISTS place_event (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id    uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  kind          text NOT NULL,                       -- permit | sale | remodel | demolition | designation | media | occupancy
+  summary       text NOT NULL,
+  valid_from    date,                                -- when the event began (permit issued, sale recorded)
+  valid_to      date,                                -- when the event ended (occupancy range; null for instant)
+  recorded_at   timestamptz NOT NULL DEFAULT now(),  -- when WE recorded the event
+  source_tier   char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')),
+  source_label  text,
+  source_url    text,
+  source_id     text,                                -- external reference (permit #, doc #, etc.)
+  confidence    numeric(3,2) NOT NULL DEFAULT 0.80 CHECK (confidence BETWEEN 0 AND 1),
+  public_visible boolean NOT NULL DEFAULT true
+);
+CREATE INDEX IF NOT EXISTS idx_place_event_listing  ON place_event (listing_id);
+CREATE INDEX IF NOT EXISTS idx_place_event_kind     ON place_event (kind);
+CREATE INDEX IF NOT EXISTS idx_place_event_recent   ON place_event (recorded_at DESC);
+CREATE INDEX IF NOT EXISTS idx_place_event_validfrom ON place_event (valid_from DESC);
diff --git a/db/migrations/005_listing_search.sql b/db/migrations/005_listing_search.sql
new file mode 100644
index 0000000..b4b2214
--- /dev/null
+++ b/db/migrations/005_listing_search.sql
@@ -0,0 +1,16 @@
+-- Postgres tsvector full-text search across the listing's editorial spine.
+-- Generated column = no trigger, no maintenance overhead, always fresh.
+ALTER TABLE listing
+  ADD COLUMN IF NOT EXISTS search_doc tsvector
+  GENERATED ALWAYS AS (
+    setweight(to_tsvector('english', coalesce(title,'')),                    'A') ||
+    setweight(to_tsvector('english', coalesce(headline,'')),                 'B') ||
+    setweight(to_tsvector('english', coalesce(address_line1,'')),            'B') ||
+    setweight(to_tsvector('english', coalesce(city,'')),                     'C') ||
+    setweight(to_tsvector('english', coalesce(state,'')),                    'C') ||
+    setweight(to_tsvector('english', coalesce(postal_code,'')),              'C') ||
+    setweight(to_tsvector('english', coalesce(property_type,'')),            'C') ||
+    setweight(to_tsvector('english', coalesce(description,'')),              'D')
+  ) STORED;
+
+CREATE INDEX IF NOT EXISTS idx_listing_search_doc ON listing USING GIN (search_doc);
diff --git a/db/migrations/006_place_first_satellites.sql b/db/migrations/006_place_first_satellites.sql
new file mode 100644
index 0000000..3b7b0ea
--- /dev/null
+++ b/db/migrations/006_place_first_satellites.sql
@@ -0,0 +1,89 @@
+-- Place-first satellite tables per docs/deep-research-report.md.
+-- The `listing` table remains the canonical place record root (its `id` is
+-- the de-facto place_id). These satellites add the dimensions the report
+-- mandates without renaming/restructuring existing app surfaces.
+
+-- 1. privacy_class on every place record
+ALTER TABLE listing
+  ADD COLUMN IF NOT EXISTS privacy_class text NOT NULL DEFAULT 'public_history_only';
+
+-- 2. Alternate addresses (one place can have many address strings over time)
+CREATE TABLE IF NOT EXISTS address_alt (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id    uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  full_address  text NOT NULL,                     -- "30874 PCH, Malibu CA 90265"
+  valid_from    date,
+  valid_to      date,
+  canonical     boolean NOT NULL DEFAULT false,    -- exactly one canonical at a time, app-enforced
+  source_tier   char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')) DEFAULT 'A',
+  source_label  text,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_address_alt_listing ON address_alt (listing_id);
+
+-- 3. Media assets — Sanborn scans, archival photos, drone imagery, postcards
+CREATE TABLE IF NOT EXISTS media_asset (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id    uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  kind          text NOT NULL,                     -- archival_photo | historic_map | postcard | drone | streetview_thumb | newspaper_clip
+  year          int,                                -- year of the asset; nullable when undated
+  caption       text,
+  storage_url   text NOT NULL,                     -- where the bytes live (S3/R2/object store)
+  source_tier   char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')) DEFAULT 'B',
+  source_label  text,
+  source_url    text,
+  rights        text,                              -- "Beverly Hills Public Library — research use only", "PD", "CC-BY-SA-4.0", etc.
+  added_at      timestamptz NOT NULL DEFAULT now(),
+  review_status text NOT NULL DEFAULT 'approved',  -- pending | approved | rejected
+  public_visible boolean NOT NULL DEFAULT true
+);
+CREATE INDEX IF NOT EXISTS idx_media_listing ON media_asset (listing_id);
+CREATE INDEX IF NOT EXISTS idx_media_year ON media_asset (year);
+CREATE INDEX IF NOT EXISTS idx_media_visible ON media_asset (public_visible) WHERE public_visible;
+
+-- 4. Parcels with versioning. Geometry as GeoJSON text until PostGIS lands (pg17).
+CREATE TABLE IF NOT EXISTS parcel (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  apn           text UNIQUE NOT NULL,              -- e.g. "4349-020-012"
+  jurisdiction  text NOT NULL DEFAULT 'Los Angeles County',
+  current_listing_id uuid REFERENCES listing(id),  -- the place this parcel currently maps to
+  notes         text,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_parcel_listing ON parcel (current_listing_id);
+
+CREATE TABLE IF NOT EXISTS parcel_version (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  parcel_id     uuid NOT NULL REFERENCES parcel(id) ON DELETE CASCADE,
+  valid_from    date NOT NULL,
+  valid_to      date,                              -- NULL = current
+  geometry_geojson jsonb,                          -- GeoJSON polygon; will move to PostGIS geometry later
+  area_sqft     numeric(12,2),
+  parent_versions uuid[],                          -- for split/merge lineage
+  source_tier   char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')) DEFAULT 'A',
+  source_label  text,
+  recorded_at   timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_parcel_version_parcel ON parcel_version (parcel_id);
+CREATE INDEX IF NOT EXISTS idx_parcel_version_validfrom ON parcel_version (valid_from);
+
+-- 5. Structure — one place can have multiple structures across time
+--    (original 1928 build → 1972 teardown → 1973 rebuild = three rows)
+CREATE TABLE IF NOT EXISTS structure (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id      uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  year_built      int,
+  year_demolished int,
+  property_type   text,                             -- single_family | duplex | apartment | commercial | mixed_use
+  style           text,                             -- spanish_colonial | tudor_revival | mid_century | etc.
+  bedrooms        int,
+  bathrooms       numeric(3,1),
+  building_area_sqft numeric(10,2),
+  lot_size_sqft   numeric(12,2),
+  notes           text,
+  source_tier     char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')) DEFAULT 'A',
+  source_label    text,
+  recorded_at     timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_structure_listing ON structure (listing_id);
+CREATE INDEX IF NOT EXISTS idx_structure_year_built ON structure (year_built);
diff --git a/db/migrations/007_film_locations.sql b/db/migrations/007_film_locations.sql
new file mode 100644
index 0000000..765a747
--- /dev/null
+++ b/db/migrations/007_film_locations.sql
@@ -0,0 +1,43 @@
+-- Film + TV production records and the addresses that hosted them.
+-- Source-of-record: FilmLA permits via City of LA Open Data Portal.
+-- All data is public-record by definition (filming permits are public).
+
+CREATE TABLE IF NOT EXISTS film_production (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  slug          text UNIQUE NOT NULL,
+  kind          text NOT NULL,                       -- 'film' | 'tv' | 'commercial' | 'music_video' | 'documentary' | 'short' | 'student'
+  title         text NOT NULL,
+  year          int,                                  -- year of release / first air; null for in-production
+  imdb_id       text,                                 -- 'tt0166924' style
+  wikidata_qid  text,                                 -- 'Q170564' style
+  poster_url    text,
+  blurb         text,
+  source_tier   char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')) DEFAULT 'C',
+  source_label  text,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_film_year ON film_production (year);
+CREATE INDEX IF NOT EXISTS idx_film_kind ON film_production (kind);
+CREATE INDEX IF NOT EXISTS idx_film_title ON film_production (lower(title));
+
+-- A specific shoot / scene at a place.
+CREATE TABLE IF NOT EXISTS filming_location (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  production_id   uuid NOT NULL REFERENCES film_production(id) ON DELETE CASCADE,
+  listing_id      uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  role            text,                               -- 'exterior' | 'interior' | 'establishing_shot' | 'background_plate'
+  scene_note      text,                               -- "Driveway intro shot", "Pool party scene"
+  shoot_date_from date,
+  shoot_date_to   date,
+  permit_number   text,                               -- FilmLA / city permit
+  source_tier     char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')) DEFAULT 'A',
+  source_label    text,
+  source_url      text,
+  confidence      numeric(3,2) NOT NULL DEFAULT 0.85 CHECK (confidence BETWEEN 0 AND 1),
+  public_visible  boolean NOT NULL DEFAULT true,
+  created_at      timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (production_id, listing_id, permit_number)
+);
+CREATE INDEX IF NOT EXISTS idx_filmloc_production ON filming_location (production_id);
+CREATE INDEX IF NOT EXISTS idx_filmloc_listing ON filming_location (listing_id);
+CREATE INDEX IF NOT EXISTS idx_filmloc_visible ON filming_location (public_visible) WHERE public_visible;
diff --git a/db/migrations/008_pd_film_images.sql b/db/migrations/008_pd_film_images.sql
new file mode 100644
index 0000000..e5f1dfb
--- /dev/null
+++ b/db/migrations/008_pd_film_images.sql
@@ -0,0 +1,34 @@
+-- Public-domain film/TV images, aggregated from Wikimedia Commons,
+-- Internet Archive, Library of Congress, and NARA.
+--
+-- Each image is a separate row with provenance + a `source` enum.
+-- A nullable production_id link lets us attach images to film_production
+-- rows without requiring a match (some images are stand-alone PD posters
+-- with no permit-side production record).
+--
+-- Cache-first model: fetcher script populates this table; the FilmsGrid
+-- component reads from here only. No live API calls in the request path.
+
+CREATE TABLE IF NOT EXISTS pd_film_image (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  source          text NOT NULL CHECK (source IN ('wikimedia','archive_org','loc','nara')),
+  source_id       text NOT NULL,                -- file name / item id / digital id at source
+  title           text NOT NULL,
+  caption         text,
+  image_url       text NOT NULL,                -- direct hot-link URL (we do not re-host)
+  thumb_url       text,                         -- smaller preview if source provides one
+  width           int,
+  height          int,
+  year            int,                          -- production year if known
+  kind            text,                         -- 'poster' | 'still' | 'lobby_card' | 'screenshot' | 'newsreel'
+  rights          text NOT NULL DEFAULT 'public-domain',  -- always PD by definition for this table
+  rights_url      text,                         -- link to the rights statement at source
+  source_url      text NOT NULL,                -- back to the source page (provenance display)
+  production_id   uuid REFERENCES film_production(id) ON DELETE SET NULL,
+  fetched_at      timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (source, source_id)
+);
+CREATE INDEX IF NOT EXISTS idx_pdfilm_year       ON pd_film_image (year);
+CREATE INDEX IF NOT EXISTS idx_pdfilm_kind       ON pd_film_image (kind);
+CREATE INDEX IF NOT EXISTS idx_pdfilm_source     ON pd_film_image (source);
+CREATE INDEX IF NOT EXISTS idx_pdfilm_production ON pd_film_image (production_id);
diff --git a/db/migrations/009_pd_film_more_sources.sql b/db/migrations/009_pd_film_more_sources.sql
new file mode 100644
index 0000000..dcd86da
--- /dev/null
+++ b/db/migrations/009_pd_film_more_sources.sql
@@ -0,0 +1,11 @@
+-- Extend pd_film_image.source enum to include the 4 new PD adapters:
+--   mhdl           — Media History Digital Library (lantern.mediahist.org)
+--   smithsonian    — Smithsonian Open Access
+--   nypl           — NYPL Digital Collections (Billy Rose Theatre etc.)
+--   flickr_commons — Flickr Commons (institutional "no known copyright restrictions")
+--
+-- The existing CHECK constraint is dropped and recreated with the wider enum.
+
+ALTER TABLE pd_film_image DROP CONSTRAINT IF EXISTS pd_film_image_source_check;
+ALTER TABLE pd_film_image ADD CONSTRAINT pd_film_image_source_check
+  CHECK (source IN ('wikimedia','archive_org','loc','nara','mhdl','smithsonian','nypl','flickr_commons'));
diff --git a/db/migrations/010_pd_film_openverse.sql b/db/migrations/010_pd_film_openverse.sql
new file mode 100644
index 0000000..906f39f
--- /dev/null
+++ b/db/migrations/010_pd_film_openverse.sql
@@ -0,0 +1,8 @@
+-- Add 'openverse' to pd_film_image source enum.
+-- Openverse aggregates 800M+ CC0/PDM images from Flickr Commons, Wikimedia,
+-- museums, and government archives. Effectively covers the Flickr Commons
+-- gap (which would otherwise require a Flickr API key).
+
+ALTER TABLE pd_film_image DROP CONSTRAINT IF EXISTS pd_film_image_source_check;
+ALTER TABLE pd_film_image ADD CONSTRAINT pd_film_image_source_check
+  CHECK (source IN ('wikimedia','archive_org','loc','nara','mhdl','smithsonian','nypl','flickr_commons','openverse'));
diff --git a/db/migrations/011_pd_film_met_dpla.sql b/db/migrations/011_pd_film_met_dpla.sql
new file mode 100644
index 0000000..abee3a5
--- /dev/null
+++ b/db/migrations/011_pd_film_met_dpla.sql
@@ -0,0 +1,6 @@
+-- Add 'met' and 'dpla' to pd_film_image source enum.
+--   met  — Metropolitan Museum of Art Open Access (CC0, no key)
+--   dpla — Digital Public Library of America (free key emailed to Steve)
+ALTER TABLE pd_film_image DROP CONSTRAINT IF EXISTS pd_film_image_source_check;
+ALTER TABLE pd_film_image ADD CONSTRAINT pd_film_image_source_check
+  CHECK (source IN ('wikimedia','archive_org','loc','nara','mhdl','smithsonian','nypl','flickr_commons','openverse','met','dpla'));
diff --git a/db/migrations/012_pd_film_image_match_productions.sql b/db/migrations/012_pd_film_image_match_productions.sql
new file mode 100644
index 0000000..bfbb775
--- /dev/null
+++ b/db/migrations/012_pd_film_image_match_productions.sql
@@ -0,0 +1,33 @@
+-- One-shot fuzzy match: link pd_film_image rows to film_production via title similarity.
+-- Threshold 0.45 trigram similarity = "very likely the same title", err on conservative side.
+-- Re-runnable: only updates rows whose production_id is currently NULL.
+
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+WITH candidates AS (
+  SELECT
+    img.id   AS img_id,
+    fp.id    AS production_id,
+    similarity(lower(img.title), lower(fp.title)) AS sim,
+    row_number() OVER (
+      PARTITION BY img.id
+      ORDER BY similarity(lower(img.title), lower(fp.title)) DESC, fp.year DESC NULLS LAST
+    ) AS rnk
+  FROM pd_film_image img
+  CROSS JOIN film_production fp
+  WHERE img.production_id IS NULL
+    AND length(img.title) > 4
+    AND length(fp.title)  > 3
+    AND lower(img.title) % lower(fp.title)   -- pg_trgm fast pre-filter (uses GIN if indexed)
+    AND similarity(lower(img.title), lower(fp.title)) >= 0.45
+)
+UPDATE pd_film_image img
+SET production_id = c.production_id
+FROM candidates c
+WHERE c.img_id = img.id AND c.rnk = 1;
+
+SELECT
+  'matched' AS status,
+  count(*) AS n
+FROM pd_film_image
+WHERE production_id IS NOT NULL;
diff --git a/db/migrations/013_la_parcel_cache.sql b/db/migrations/013_la_parcel_cache.sql
new file mode 100644
index 0000000..42af4b2
--- /dev/null
+++ b/db/migrations/013_la_parcel_cache.sql
@@ -0,0 +1,34 @@
+-- Cache layer for LA County parcel data fetched from the public ArcGIS REST.
+-- Keyed primarily on APN (canonical), with listing_id as a convenience FK.
+-- TTL is informal — refreshed when fetched_at > 30 days ago. Public records
+-- don't change daily, so a generous TTL is fine.
+
+CREATE TABLE IF NOT EXISTS la_parcel (
+  apn               text PRIMARY KEY,
+  listing_id        uuid REFERENCES listing(id) ON DELETE SET NULL,
+  situs_address     text,
+  city              text,
+  zip               text,
+  use_code          text,
+  use_type          text,
+  use_description   text,
+  year_built        int,
+  effective_year    int,
+  sqft_main         int,
+  bedrooms          int,
+  bathrooms         numeric(3,1),
+  units             int,
+  roll_year         int,
+  land_value        numeric(14,0),
+  improvement_value numeric(14,0),
+  total_value       numeric(14,0),
+  homeowner_exempt  boolean DEFAULT false,
+  lat               numeric(10,7),
+  lng               numeric(10,7),
+  raw               jsonb,                  -- full ArcGIS payload for forward-compat
+  fetched_at        timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_la_parcel_listing  ON la_parcel (listing_id);
+CREATE INDEX IF NOT EXISTS idx_la_parcel_situs    ON la_parcel (situs_address);
+CREATE INDEX IF NOT EXISTS idx_la_parcel_year     ON la_parcel (year_built);
diff --git a/db/migrations/014_la_permit_cache.sql b/db/migrations/014_la_permit_cache.sql
new file mode 100644
index 0000000..78381c8
--- /dev/null
+++ b/db/migrations/014_la_permit_cache.sql
@@ -0,0 +1,25 @@
+-- Cache for LA City building permits from Socrata pi9x-tg5x.
+-- Note: City of LA only — Beverly Hills, Santa Monica, Culver City, West
+-- Hollywood etc. are SEPARATE jurisdictions with their own portals (each
+-- needs its own adapter). This table covers the LA City subset.
+
+CREATE TABLE IF NOT EXISTS la_permit (
+  permit_nbr      text PRIMARY KEY,
+  apn             text NOT NULL,
+  primary_address text,
+  zip_code        text,
+  permit_type     text,
+  permit_subtype  text,
+  permit_group    text,
+  status          text,
+  issue_date      date,
+  status_date     date,
+  valuation       numeric(14,2),
+  use_code        text,
+  use_description text,
+  jurisdiction    text NOT NULL DEFAULT 'LA City',
+  raw             jsonb,
+  fetched_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_la_permit_apn         ON la_permit (apn);
+CREATE INDEX IF NOT EXISTS idx_la_permit_issue_date  ON la_permit (issue_date);
diff --git a/db/migrations/015_walk_of_fame.sql b/db/migrations/015_walk_of_fame.sql
new file mode 100644
index 0000000..57d5504
--- /dev/null
+++ b/db/migrations/015_walk_of_fame.sql
@@ -0,0 +1,27 @@
+-- Hollywood Walk of Fame stars — sidewalk inscriptions, not residences.
+-- Distinct from entity_place_association (residences/landmarks); these are
+-- terrazzo+brass stars at known coordinates on Hollywood Blvd / Vine St.
+--
+-- Each star: who, category (5 disciplines), address block, lat/lng, dedicated date.
+-- Source: Hollywood Chamber of Commerce official list + Wikipedia cross-ref.
+
+CREATE TABLE IF NOT EXISTS walk_of_fame (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  honoree_name    text NOT NULL,
+  honoree_slug    text NOT NULL UNIQUE,
+  category        text NOT NULL CHECK (category IN ('motion_pictures', 'television', 'recording', 'radio', 'theater', 'sports', 'special')),
+  address         text,                      -- e.g. "6774 Hollywood Blvd"
+  block           text,                      -- e.g. "6700 W Hollywood Blvd"
+  lat             numeric(10,7),
+  lng             numeric(10,7),
+  dedicated_date  date,                      -- when the star was unveiled
+  entity_id       uuid REFERENCES entity(id) ON DELETE SET NULL,  -- link to entity record if exists
+  source          text DEFAULT 'Hollywood Chamber of Commerce',
+  source_url      text,
+  notes           text,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_wof_category   ON walk_of_fame (category);
+CREATE INDEX IF NOT EXISTS idx_wof_lat_lng    ON walk_of_fame (lat, lng);
+CREATE INDEX IF NOT EXISTS idx_wof_name_lower ON walk_of_fame (lower(honoree_name));
+CREATE INDEX IF NOT EXISTS idx_wof_entity     ON walk_of_fame (entity_id);
diff --git a/db/migrations/016_restaurants.sql b/db/migrations/016_restaurants.sql
new file mode 100644
index 0000000..a7cd6b2
--- /dev/null
+++ b/db/migrations/016_restaurants.sql
@@ -0,0 +1,51 @@
+-- Famous LA celebrity restaurants + sightings.
+--
+-- Schema is two tables:
+--   restaurant         — the venue (name, addr, lat/lng, opened/closed)
+--   restaurant_sighting — a person seen at the venue, with date_range +
+--                         source_tier (per PLAN.md privacy guardrail)
+--
+-- Sightings are CLAIMS, never eternal truths. Every sighting requires a
+-- source citation. Tier system: A=govt, B=archive, C=verified secondary
+-- (Wikipedia article, LATimes), D=tabloid/social. Each sighting can be
+-- specific-date OR era-based ("regular in the 70s") via the era field.
+
+CREATE TABLE IF NOT EXISTS restaurant (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  slug            text UNIQUE NOT NULL,
+  name            text NOT NULL,
+  cuisine         text,                 -- "Italian", "Steakhouse", etc.
+  address         text,
+  city            text,
+  state           text DEFAULT 'CA',
+  zip             text,
+  lat             numeric(10,7),
+  lng             numeric(10,7),
+  opened_year     int,
+  closed_year     int,                  -- null = still open
+  blurb           text,                 -- 1-paragraph editorial intro
+  source_url      text,                 -- Wikipedia or other primary source
+  hero_image      text,                 -- public-domain image only (no stock)
+  created_at      timestamptz NOT NULL DEFAULT now(),
+  updated_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_restaurant_city  ON restaurant (lower(city));
+CREATE INDEX IF NOT EXISTS idx_restaurant_geo   ON restaurant (lat, lng);
+
+CREATE TABLE IF NOT EXISTS restaurant_sighting (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  restaurant_id   uuid NOT NULL REFERENCES restaurant(id) ON DELETE CASCADE,
+  entity_id       uuid REFERENCES entity(id) ON DELETE SET NULL,  -- if entity exists
+  person_name     text NOT NULL,        -- always populated, even if entity_id null
+  era             text,                  -- "1970s", "Golden Age", etc.
+  seen_date       date,                  -- specific if known
+  notes           text,                  -- "regular for decades", "last visit before death"
+  source_tier     char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')),
+  source_url      text,                  -- Wikipedia, LATimes, etc.
+  source_label    text,                  -- "Wikipedia · The Ivy article"
+  public_visible  boolean NOT NULL DEFAULT true,
+  recorded_at     timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_sighting_rest    ON restaurant_sighting (restaurant_id);
+CREATE INDEX IF NOT EXISTS idx_sighting_entity  ON restaurant_sighting (entity_id);
+CREATE INDEX IF NOT EXISTS idx_sighting_date    ON restaurant_sighting (seen_date);
diff --git a/db/migrations/017_seed_idempotency.sql b/db/migrations/017_seed_idempotency.sql
new file mode 100644
index 0000000..c26f92d
--- /dev/null
+++ b/db/migrations/017_seed_idempotency.sql
@@ -0,0 +1,51 @@
+-- Idempotency hardening for the two seed-targeted tables.
+--
+-- Both `restaurant_sighting` and `entity_place_association` use random UUIDs
+-- as the only unique key, which means seed scripts using ON CONFLICT DO NOTHING
+-- never actually conflict — re-running the seeds duplicates rows.
+--
+-- This migration:
+--   1. Dedupes existing rows (keeps oldest by recorded_at/created_at)
+--   2. Adds UNIQUE indexes on the natural keys so future ON CONFLICT works
+--   3. Seed scripts must update their INSERTs to target these constraints
+
+-- 1a. Dedupe restaurant_sighting. Natural key includes seen_date so that
+-- two legitimate dated sightings of the same patron at the same restaurant
+-- in the same era are NOT collapsed (replay-safe — migration 018 widens the
+-- index but the dedupe partition was always intended to be this wide).
+WITH dupes AS (
+  SELECT id, ROW_NUMBER() OVER (
+    PARTITION BY restaurant_id, person_name,
+                 COALESCE(era, ''),
+                 COALESCE(seen_date, DATE '1900-01-01')
+    ORDER BY recorded_at ASC, id ASC
+  ) AS rn
+  FROM restaurant_sighting
+)
+DELETE FROM restaurant_sighting
+WHERE id IN (SELECT id FROM dupes WHERE rn > 1);
+
+-- Index matches the dedupe partition above (includes seen_date). Migration
+-- 018 is now a no-op idempotent re-creation kept for the prod DB that already
+-- ran 017's earlier (narrow) form.
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_sighting_natural
+  ON restaurant_sighting (
+    restaurant_id,
+    person_name,
+    COALESCE(era, ''),
+    COALESCE(seen_date, DATE '1900-01-01')
+  );
+
+-- 1b. Dedupe entity_place_association on (entity_id, listing_id, relation)
+WITH dupes AS (
+  SELECT id, ROW_NUMBER() OVER (
+    PARTITION BY entity_id, listing_id, relation
+    ORDER BY created_at ASC, id ASC
+  ) AS rn
+  FROM entity_place_association
+)
+DELETE FROM entity_place_association
+WHERE id IN (SELECT id FROM dupes WHERE rn > 1);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_epa_natural
+  ON entity_place_association (entity_id, listing_id, relation);
diff --git a/db/migrations/018_widen_sighting_uniq.sql b/db/migrations/018_widen_sighting_uniq.sql
new file mode 100644
index 0000000..c2e27d1
--- /dev/null
+++ b/db/migrations/018_widen_sighting_uniq.sql
@@ -0,0 +1,25 @@
+-- Widen the restaurant_sighting unique key to include seen_date.
+--
+-- Migration 017 created `uniq_sighting_natural` on
+--   (restaurant_id, person_name, COALESCE(era, ''))
+-- which would silently collapse legitimately-different-dated sightings of the
+-- same patron at the same restaurant (e.g. dated tabloid sightings vs. an
+-- era-only "regular in the 60s"). The schema explicitly supports `seen_date`
+-- for specific-date sightings (per migration 016), so the natural key must
+-- include it.
+--
+-- This migration drops the old index and creates a wider one. No rows are
+-- deleted (017 already deduped what was there at the time, and any new dated
+-- sightings since are correctly considered distinct).
+
+DROP INDEX IF EXISTS uniq_sighting_natural;
+
+-- Use a date sentinel rather than ::text cast — date_to_text isn't IMMUTABLE
+-- under all collations. Sentinel is far enough from any plausible sighting.
+CREATE UNIQUE INDEX IF NOT EXISTS uniq_sighting_natural
+  ON restaurant_sighting (
+    restaurant_id,
+    person_name,
+    COALESCE(era, ''),
+    COALESCE(seen_date, DATE '1900-01-01')
+  );
diff --git a/db/migrations/019_user_contributions.sql b/db/migrations/019_user_contributions.sql
new file mode 100644
index 0000000..2a78685
--- /dev/null
+++ b/db/migrations/019_user_contributions.sql
@@ -0,0 +1,76 @@
+-- Slice 1 of PLAN-geo-archival.md (2026-05-01)
+-- Adds moderation + contributor tracking to place_event,
+-- plus a new user_image table for user-submitted photos.
+--
+-- Auth model: stayclaim has NO users table. Identity is delegated to
+-- Site Factory (claimmyaddress.com) via session token. We store
+-- contributor_email (mirroring address_submission's pattern) rather
+-- than a cross-DB user_id FK we couldn't enforce.
+
+BEGIN;
+
+-- 1. Augment place_event for user-submitted memories / facts
+ALTER TABLE place_event ADD COLUMN IF NOT EXISTS contributor_email   text;
+ALTER TABLE place_event ADD COLUMN IF NOT EXISTS moderation_status   text NOT NULL DEFAULT 'APPROVED'
+  CHECK (moderation_status IN ('PENDING','APPROVED','REJECTED'));
+ALTER TABLE place_event ADD COLUMN IF NOT EXISTS moderation_reason   text;
+ALTER TABLE place_event ADD COLUMN IF NOT EXISTS moderated_at        timestamptz;
+ALTER TABLE place_event ADD COLUMN IF NOT EXISTS moderator_email     text;
+
+-- Existing rows: backfilled to APPROVED (they were always public_visible).
+-- New user-submitted rows MUST insert with moderation_status='PENDING' and
+-- public_visible=false until approved; the API enforces this, not the schema.
+
+CREATE INDEX IF NOT EXISTS idx_place_event_pending
+  ON place_event (moderation_status)
+  WHERE moderation_status = 'PENDING';
+
+-- 2. New user_image table — user-submitted photos.
+-- Distinct from pd_film_image (which is rights='public-domain' only).
+-- All rows here are tier C (community-submitted) by definition.
+CREATE TABLE IF NOT EXISTS user_image (
+  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id          uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  contributor_email   text NOT NULL,
+  caption             text,
+  image_url           text NOT NULL,                         -- CF R2 URL once uploaded
+  thumb_url           text,
+  width               int,
+  height              int,
+  source_tier         char(1) NOT NULL DEFAULT 'C'
+    CHECK (source_tier = 'C'),                                -- always C; row-level lock
+  source_label        text NOT NULL,                          -- e.g. "Submitted by Steve A."
+  rights              text NOT NULL DEFAULT 'user-submitted', -- not PD
+  rights_url          text,
+  moderation_status   text NOT NULL DEFAULT 'PENDING'
+    CHECK (moderation_status IN ('PENDING','APPROVED','REJECTED')),
+  moderation_reason   text,
+  moderated_at        timestamptz,
+  moderator_email     text,
+  uploaded_at         timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_image_listing_approved
+  ON user_image (listing_id)
+  WHERE moderation_status = 'APPROVED';
+CREATE INDEX IF NOT EXISTS idx_user_image_pending
+  ON user_image (moderation_status)
+  WHERE moderation_status = 'PENDING';
+CREATE INDEX IF NOT EXISTS idx_user_image_contributor
+  ON user_image (contributor_email);
+
+COMMIT;
+
+-- ROLLBACK plan (if needed):
+-- BEGIN;
+-- DROP INDEX IF EXISTS idx_user_image_contributor;
+-- DROP INDEX IF EXISTS idx_user_image_pending;
+-- DROP INDEX IF EXISTS idx_user_image_listing_approved;
+-- DROP TABLE IF EXISTS user_image;
+-- DROP INDEX IF EXISTS idx_place_event_pending;
+-- ALTER TABLE place_event DROP COLUMN IF EXISTS moderator_email;
+-- ALTER TABLE place_event DROP COLUMN IF EXISTS moderated_at;
+-- ALTER TABLE place_event DROP COLUMN IF EXISTS moderation_reason;
+-- ALTER TABLE place_event DROP COLUMN IF EXISTS moderation_status;
+-- ALTER TABLE place_event DROP COLUMN IF EXISTS contributor_email;
+-- COMMIT;
diff --git a/db/migrations/020_restaurant_phone.sql b/db/migrations/020_restaurant_phone.sql
new file mode 100644
index 0000000..5dea182
--- /dev/null
+++ b/db/migrations/020_restaurant_phone.sql
@@ -0,0 +1,53 @@
+-- Add phone numbers to restaurants.
+--
+-- Hand-curated 2026-05-02 from each restaurant's official website / Google
+-- business profile. Closed restaurants left NULL — phone is only meaningful
+-- for currently-operating venues, and showing a dead number on a defunct
+-- spot would be confusing.
+--
+-- A note on a few we deliberately left NULL even though the venue had a
+-- well-known number:
+--   cole-s             — original Cole's closed Aug 2024
+--   nate-n-al-s        — Beverly Hills location closed 2023
+--   taix-french-restaurant — original Echo Park building demolished 2024
+--   pacific-dining-car — closed 2020
+--
+-- The display layer treats NULL phones as "closed / no current line".
+
+ALTER TABLE restaurant ADD COLUMN IF NOT EXISTS phone text;
+
+-- Bulk update via VALUES list keyed on slug. Slugs match the slugify()
+-- output used by the seed scripts: lower, non-alnum→`-`, trim.
+UPDATE restaurant AS r
+SET phone = v.phone
+FROM (VALUES
+  ('musso-frank-grill',          '+1-323-467-7788'),
+  ('dan-tana-s',                 '+1-310-275-9444'),
+  ('the-ivy',                    '+1-310-274-8303'),
+  ('craig-s',                    '+1-310-276-1900'),
+  ('chateau-marmont',            '+1-323-656-1010'),
+  ('spago',                      '+1-310-385-0880'),
+  ('polo-lounge',                '+1-310-887-2777'),
+  ('mr-chow-beverly-hills',      '+1-310-278-9911'),
+  ('sunset-tower-hotel',         '+1-323-654-7100'),
+  ('nobu-malibu',                '+1-310-317-9140'),
+  ('catch-la',                   '+1-323-347-6060'),
+  ('cecconi-s-west-hollywood',   '+1-310-432-2000'),
+  ('philippe-the-original',      '+1-213-628-3781'),
+  ('tito-s-tacos',               '+1-310-391-5780'),
+  ('canters-deli',               '+1-323-651-2030'),
+  ('langers-deli',               '+1-213-483-8050'),
+  ('lawrys-the-prime-rib',       '+1-310-652-2827'),
+  ('yamashiro-hollywood',        '+1-323-466-5125'),
+  ('magic-castle',               '+1-323-851-3313'),
+  ('beverly-hills-hotel',        '+1-310-276-2251'),
+  ('hotel-bel-air',              '+1-310-472-1211'),
+  ('el-coyote-cafe',             '+1-323-939-2255'),
+  ('patrick-s-roadhouse',        '+1-310-459-4544'),
+  ('lucy-s-el-adobe',            '+1-323-462-9421'),
+  ('matsuhisa-beverly-hills',    '+1-310-659-9639'),
+  ('pinks-hot-dogs',             '+1-323-931-7594'),
+  ('apple-pan',                  '+1-310-475-3585'),
+  ('the-ivy-at-the-shore',       '+1-310-393-3113')
+) AS v(slug, phone)
+WHERE r.slug = v.slug;
diff --git a/db/migrations/021_restaurant_image_credits.sql b/db/migrations/021_restaurant_image_credits.sql
new file mode 100644
index 0000000..f0239db
--- /dev/null
+++ b/db/migrations/021_restaurant_image_credits.sql
@@ -0,0 +1,15 @@
+-- Image-credit columns for restaurant.
+--
+-- Per Steve's standing rule "Zero stock images — public domain only", every
+-- hero_image must carry a verifiable source + license string. Closed-restaurant
+-- imagery comes from the allowed-archives list:
+--   USC DigLib · LAPL Photo Collection · Wikimedia Commons · Internet Archive
+--   Calisphere · Library of Congress · Wikipedia
+--
+-- Display contract: any non-NULL hero_image renders WITH a visible caption
+-- showing credit + license. No silent uncredited images.
+
+ALTER TABLE restaurant
+  ADD COLUMN IF NOT EXISTS hero_image_credit       text,  -- e.g., "Photo: Herald-Examiner Collection / LAPL"
+  ADD COLUMN IF NOT EXISTS hero_image_license      text,  -- e.g., "Public Domain", "CC BY-SA 4.0", "LAPL Photo #00012345"
+  ADD COLUMN IF NOT EXISTS hero_image_source_url   text;  -- link back to the archive's record page
diff --git a/db/migrations/022_restaurant_closed_images.sql b/db/migrations/022_restaurant_closed_images.sql
new file mode 100644
index 0000000..e35ad8f
--- /dev/null
+++ b/db/migrations/022_restaurant_closed_images.sql
@@ -0,0 +1,94 @@
+-- Hand-curated public-domain / CC / archival-editorial-use images for the 16
+-- closed celebrity restaurants. Sourced 2026-05-02 by la-research-agent across
+-- LAPL TESSA2, Wikimedia Commons, and California Historical Society.
+--
+-- 13 of 16 have verified hot-linkable images (URLs returned HTTP 200 at
+-- sourcing time). 3 are intentionally left NULL — no freely-licensed image
+-- exists in the archives we're allowed to use:
+--   cock-n-bull         — best candidate is a CSL postcard with rights
+--                          "contact institution" — needs CSL release request
+--   morton-s-melrose    — zero hits across LAPL/USC/Wikimedia/DPLA/LOC/IA
+--   nate-n-als          — zero hits; needs LAPL in-person Security Pacific
+--                          search or family-direct release
+--
+-- LAPL note: TESSA2 photos are NOT CC-licensed but LAPL's standard policy
+-- permits editorial reproduction (see https://tessa.lapl.org/OrderingUse.html).
+-- We tag these as `Editorial use — LAPL Photo Collection` per Steve's standing
+-- "no stock images, citation required" rule.
+
+UPDATE restaurant AS r SET
+  hero_image            = v.image_url,
+  hero_image_credit     = v.credit,
+  hero_image_license    = v.license,
+  hero_image_source_url = v.source_url
+FROM (VALUES
+  -- Wikimedia Commons (Public Domain / CC)
+  ('bistro-garden',
+   'https://upload.wikimedia.org/wikipedia/commons/3/37/President_Ronald_Reagan_and_Nancy_Reagan_with_Leonore_Annenberg_and_Walter_Annenberg.jpg',
+   'Reagan White House Photographs via Wikimedia Commons / NARA',
+   'Public Domain',
+   'https://commons.wikimedia.org/wiki/File:President_Ronald_Reagan_and_Nancy_Reagan_with_Leonore_Annenberg_and_Walter_Annenberg.jpg'),
+  ('brown-derby',
+   'https://upload.wikimedia.org/wikipedia/commons/5/5d/Brown_Derby_Restaurant%2C_Los_Angeles%2C_Kodachrome_by_Chalmers_Butterfield.jpg',
+   'Photo: Chalmers Butterfield via Wikimedia Commons',
+   'CC BY 2.5',
+   'https://commons.wikimedia.org/wiki/File:Brown_Derby_Restaurant,_Los_Angeles,_Kodachrome_by_Chalmers_Butterfield.jpg'),
+  ('chasens',
+   'https://upload.wikimedia.org/wikipedia/commons/c/ca/Chasen%27s_Awning_2.JPG',
+   'Photo: Bigfatloser via Wikimedia Commons',
+   'Public Domain',
+   'https://commons.wikimedia.org/wiki/File:Chasen%27s_Awning_2.JPG'),
+  ('coles-french-dip',
+   'https://upload.wikimedia.org/wikipedia/commons/4/42/ColesFrenchDip.jpg',
+   'Photo: Mark Steven Brown via Wikimedia Commons',
+   'CC BY-SA 3.0',
+   'https://commons.wikimedia.org/wiki/File:ColesFrenchDip.jpg'),
+  ('don-the-beachcomber',
+   'https://upload.wikimedia.org/wikipedia/commons/9/94/Menu%2C_Don_the_Beachcomber%2C_Hollywood_%28cover%29_%2812001506164%29.jpg',
+   'California Historical Society via Wikimedia Commons',
+   'No known copyright restrictions',
+   'https://commons.wikimedia.org/wiki/File:Menu,_Don_the_Beachcomber,_Hollywood_(cover)_(12001506164).jpg'),
+
+  -- LAPL TESSA2 IIIF (Editorial use per LAPL ordering policy)
+  ('ma-maison',
+   'https://tessa2.lapl.org/digital/iiif/photos/117641/full/max/0/default.jpg',
+   'Photo: Carol Westwood / LAPL Los Angeles Photographers Collection',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/117641'),
+  ('pacific-dining-car',
+   'https://tessa2.lapl.org/digital/iiif/photos/87487/full/max/0/default.jpg',
+   'Photo: Security Pacific National Bank Collection / LAPL',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/87487'),
+  ('perinos',
+   'https://tessa2.lapl.org/digital/iiif/photos/121432/full/max/0/default.jpg',
+   'Photo: Anne Laskey / LAPL Marlene & Anne Laskey Wilshire Boulevard Collection',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/121432'),
+  ('romanoff-s',
+   'https://tessa2.lapl.org/digital/iiif/photos/87491/full/max/0/default.jpg',
+   'Photo: Morris & Associates / Security Pacific National Bank Collection / LAPL',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/87491'),
+  ('schwab-s-pharmacy',
+   'https://tessa2.lapl.org/digital/iiif/photos/12469/full/max/0/default.jpg',
+   'Photo: Michael Haering / Herald Examiner Collection / LAPL',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/12469'),
+  ('tail-o-the-cock',
+   'https://tessa2.lapl.org/digital/iiif/photos/45384/full/max/0/default.jpg',
+   'Photo: Gordon Dean / Valley Times Collection / LAPL',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/45384'),
+  ('taix-french-restaurant',
+   'https://tessa2.lapl.org/digital/iiif/photos/124883/full/max/0/default.jpg',
+   'Photo: William Reagh / LAPL Los Angeles Photographers Collection',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/124883'),
+  ('trader-vics-beverly-hilton',
+   'https://tessa2.lapl.org/digital/iiif/photos/18166/full/max/0/default.jpg',
+   'Photo: Herald Examiner Collection / LAPL',
+   'Editorial use — LAPL Photo Collection',
+   'https://tessa2.lapl.org/digital/collection/photos/id/18166')
+) AS v(slug, image_url, credit, license, source_url)
+WHERE r.slug = v.slug;
diff --git a/db/migrations/seed-restaurants-batch2.mjs b/db/migrations/seed-restaurants-batch2.mjs
new file mode 100644
index 0000000..ecbfd55
--- /dev/null
+++ b/db/migrations/seed-restaurants-batch2.mjs
@@ -0,0 +1,335 @@
+#!/usr/bin/env node
+/**
+ * Restaurants batch 2 — 30 more iconic LA restaurants with curated patron lists.
+ * Mix of still-open (Lawry's, Yamashiro, Pink's) and historical (Chasen's,
+ * Romanoff's already in, Trader Vic's). Each patron sourced via Wikipedia's
+ * "Notable patrons / clientele" sections of the restaurant article.
+ *
+ * Source tier C (verified secondary). Idempotent on slug.
+ */
+import pg from 'pg';
+
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
+
+const RESTAURANTS = [
+  // Still open — long history
+  { slug: 'philippe-the-original', name: 'Philippe the Original', cuisine: 'French dip',
+    address: '1001 N Alameda St', city: 'Los Angeles', zip: '90012',
+    lat: 34.0596, lng: -118.2362, opened: 1908, closed: null,
+    blurb: 'One of two LA restaurants that claim to have invented the French dip sandwich, in continuous operation since 1908.',
+    source: 'https://en.wikipedia.org/wiki/Philippe_the_Original',
+    patrons: [
+      { name: 'Frank Sinatra', era: '1950s', tier: 'C', notes: 'Sinatra and the Rat Pack regulars' },
+      { name: 'Sandra Bullock', era: '2000s', tier: 'C' },
+      { name: 'Bill Clinton', era: '2000s', tier: 'C', notes: 'Photographed dining there' },
+    ]},
+  { slug: 'coles-french-dip', name: "Cole's", cuisine: 'French dip',
+    address: '118 E 6th St', city: 'Los Angeles', zip: '90014',
+    lat: 34.0444, lng: -118.2510, opened: 1908, closed: null,
+    blurb: 'The other 1908 French-dip claimant — speakeasy bar in the back, downtown landmark.',
+    source: 'https://en.wikipedia.org/wiki/Cole%27s_P.E._Buffet',
+    patrons: [
+      { name: 'Charles Bukowski', era: '1960s-70s', tier: 'C', notes: 'Frequent drinker at the bar' },
+      { name: 'Mickey Cohen', era: '1940s', tier: 'C', notes: 'Mob hangout' },
+    ]},
+  { slug: 'pinks-hot-dogs', name: "Pink's Hot Dogs", cuisine: 'Hot dogs',
+    address: '709 N La Brea Ave', city: 'Los Angeles', zip: '90038',
+    lat: 34.0830, lng: -118.3441, opened: 1939, closed: null,
+    blurb: 'La Brea hot-dog stand since 1939; the line at midnight is the LA equivalent of Joe & the Juice in the morning.',
+    source: 'https://en.wikipedia.org/wiki/Pink%27s_Hot_Dogs',
+    patrons: [
+      { name: 'Orson Welles', era: '1970s', tier: 'C', notes: 'Reportedly ate 18 hot dogs in one sitting' },
+      { name: 'Bruce Willis', era: '1980s', tier: 'C' },
+      { name: 'Aretha Franklin', era: '1990s', tier: 'C' },
+      { name: 'Diane Keaton', era: '1980s', tier: 'C' },
+    ]},
+  { slug: 'apple-pan', name: 'The Apple Pan', cuisine: 'Burgers',
+    address: '10801 W Pico Blvd', city: 'Los Angeles', zip: '90064',
+    lat: 34.0418, lng: -118.4297, opened: 1947, closed: null,
+    blurb: 'U-shaped counter, no tables, hickory burger and apple pie since 1947 — same family.',
+    source: 'https://en.wikipedia.org/wiki/Apple_Pan',
+    patrons: [
+      { name: 'Steve McQueen', era: '1960s', tier: 'C', notes: 'Westside regular' },
+    ]},
+  { slug: 'tito-s-tacos', name: "Tito's Tacos", cuisine: 'Mexican',
+    address: '11222 Washington Pl', city: 'Culver City', zip: '90230',
+    lat: 34.0152, lng: -118.4115, opened: 1959, closed: null,
+    blurb: 'Culver City taco institution since 1959 — the line wraps around the block on weekends.',
+    source: 'https://en.wikipedia.org/wiki/Tito%27s_Tacos',
+    patrons: [
+      { name: 'Drew Barrymore', era: '1990s', tier: 'C' },
+    ]},
+  { slug: 'canters-deli', name: "Canter's Deli", cuisine: 'Jewish deli',
+    address: '419 N Fairfax Ave', city: 'Los Angeles', zip: '90036',
+    lat: 34.0808, lng: -118.3625, opened: 1931, closed: null,
+    blurb: '24-hour Fairfax Jewish deli; the Kibitz Room hosted Joni Mitchell, the Doors, Frank Zappa.',
+    source: 'https://en.wikipedia.org/wiki/Canter%27s',
+    patrons: [
+      { name: 'Joni Mitchell', era: '1970s', tier: 'C', notes: 'Kibitz Room regular' },
+      { name: 'Jim Morrison', era: '1960s', tier: 'C', notes: 'Doors hangout' },
+      { name: 'Frank Zappa', era: '1960s-70s', tier: 'C' },
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C' },
+      { name: 'Buddy Holly', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'langers-deli', name: "Langer's Delicatessen", cuisine: 'Jewish deli',
+    address: '704 S Alvarado St', city: 'Los Angeles', zip: '90057',
+    lat: 34.0635, lng: -118.2755, opened: 1947, closed: null,
+    blurb: 'MacArthur Park pastrami since 1947 — Nora Ephron called it the best pastrami sandwich in America.',
+    source: 'https://en.wikipedia.org/wiki/Langer%27s_Delicatessen-Restaurant',
+    patrons: [
+      { name: 'Nora Ephron', era: '2000s', tier: 'C', notes: 'Famously declared #19 the best pastrami sandwich' },
+      { name: 'Tom Hanks', era: '2000s', tier: 'C' },
+    ]},
+  { slug: 'nate-n-als', name: "Nate 'n Al's", cuisine: 'Jewish deli',
+    address: '414 N Beverly Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0707, lng: -118.4023, opened: 1945, closed: null,
+    blurb: 'Beverly Hills deli where Larry King held court in the same booth for decades.',
+    source: 'https://en.wikipedia.org/wiki/Nate_%27n_Al%27s',
+    patrons: [
+      { name: 'Larry King', era: '1980s-2010s', tier: 'C', notes: 'Daily breakfast at Booth 1 for 30+ years' },
+      { name: 'Jack Benny', era: '1950s-60s', tier: 'C' },
+      { name: 'George Burns', era: '1950s-90s', tier: 'C' },
+      { name: 'Doris Day', era: '1960s', tier: 'C' },
+    ]},
+  { slug: 'lawrys-the-prime-rib', name: "Lawry's The Prime Rib", cuisine: 'Steakhouse',
+    address: '100 N La Cienega Blvd', city: 'Beverly Hills', zip: '90211',
+    lat: 34.0741, lng: -118.3762, opened: 1938, closed: null,
+    blurb: 'Tableside prime-rib carving since 1938 — uniformed servers, silver carving carts.',
+    source: 'https://en.wikipedia.org/wiki/Lawry%27s_The_Prime_Rib',
+    patrons: [
+      { name: 'Walt Disney', era: '1940s-60s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1960s', tier: 'C' },
+    ]},
+  { slug: 'yamashiro-hollywood', name: 'Yamashiro', cuisine: 'Japanese',
+    address: '1999 N Sycamore Ave', city: 'Los Angeles', zip: '90068',
+    lat: 34.1059, lng: -118.3413, opened: 1914, closed: null,
+    blurb: 'Hilltop Japanese restaurant in a 1914 mansion built as a private home, with sweeping views of Hollywood.',
+    source: 'https://en.wikipedia.org/wiki/Yamashiro_(restaurant)',
+    patrons: [
+      { name: 'Lillian Gish', era: '1920s', tier: 'C', notes: 'When the building was the "400 Club"' },
+    ]},
+  { slug: 'magic-castle', name: 'The Magic Castle', cuisine: 'Private club',
+    address: '7001 Franklin Ave', city: 'Los Angeles', zip: '90028',
+    lat: 34.1052, lng: -118.3414, opened: 1963, closed: null,
+    blurb: 'Private members-only Victorian mansion devoted to magic, behind a hidden bookcase entrance.',
+    source: 'https://en.wikipedia.org/wiki/Magic_Castle',
+    patrons: [
+      { name: 'Cary Grant', era: '1970s-80s', tier: 'C', notes: 'Board member' },
+      { name: 'Orson Welles', era: '1970s', tier: 'C' },
+      { name: 'Johnny Carson', era: '1970s-80s', tier: 'C' },
+    ]},
+  { slug: 'beverly-hills-hotel', name: 'Beverly Hills Hotel', cuisine: 'Hotel · Polo Lounge',
+    address: '9641 Sunset Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0814, lng: -118.4137, opened: 1912, closed: null,
+    blurb: 'The Pink Palace — bungalows, the Polo Lounge, a lobby that hosted half of golden-age Hollywood.',
+    source: 'https://en.wikipedia.org/wiki/Beverly_Hills_Hotel',
+    patrons: [
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C', notes: 'Bungalow 7 regularly' },
+      { name: 'Elizabeth Taylor', era: '1950s-70s', tier: 'C', notes: 'Multiple honeymoons in Bungalow 5' },
+      { name: 'Howard Hughes', era: '1940s-50s', tier: 'C', notes: 'Lived in Bungalow 4 on and off for 30 years' },
+      { name: 'Charlie Chaplin', era: '1920s-50s', tier: 'C' },
+      { name: 'John Lennon', era: '1970s', tier: 'C' },
+    ]},
+  { slug: 'hotel-bel-air', name: 'Hotel Bel-Air', cuisine: 'Hotel · Wolfgang Puck restaurant',
+    address: '701 Stone Canyon Rd', city: 'Los Angeles', zip: '90077',
+    lat: 34.0822, lng: -118.4441, opened: 1946, closed: null,
+    blurb: 'Pink Spanish-Mission hideaway with swans on a private lake — old-Hollywood discretion at scale.',
+    source: 'https://en.wikipedia.org/wiki/Hotel_Bel-Air',
+    patrons: [
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C', notes: 'Last photo session was here, July 1962' },
+      { name: 'Grace Kelly', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'el-coyote-cafe', name: 'El Coyote Café', cuisine: 'Mexican',
+    address: '7312 Beverly Blvd', city: 'Los Angeles', zip: '90036',
+    lat: 34.0760, lng: -118.3475, opened: 1931, closed: null,
+    blurb: 'Beverly Blvd Mexican restaurant since 1931; Sharon Tate ate her last meal here on the night of August 8, 1969.',
+    source: 'https://en.wikipedia.org/wiki/El_Coyote_Cafe',
+    patrons: [
+      { name: 'Sharon Tate', era: '1969', tier: 'B', notes: 'Last meal, Aug 8 1969 — booth #3' },
+    ]},
+  { slug: 'pacific-dining-car', name: 'Pacific Dining Car', cuisine: 'Steakhouse',
+    address: '1310 W 6th St', city: 'Los Angeles', zip: '90017',
+    lat: 34.0568, lng: -118.2700, opened: 1921, closed: 2020,
+    blurb: '24-hour steakhouse in a converted 1921 rail dining car; downtown power-breakfast institution until COVID.',
+    source: 'https://en.wikipedia.org/wiki/Pacific_Dining_Car',
+    patrons: [
+      { name: 'Tom Bradley', era: '1980s', tier: 'C', notes: 'Mayor — late-night working dinners' },
+    ]},
+  // Closed legends
+  { slug: 'chasens', name: "Chasen's", cuisine: 'American · Hollywood institution',
+    address: '9039 Beverly Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0763, lng: -118.3839, opened: 1936, closed: 1995,
+    blurb: 'Beverly Hills clubhouse for the Hollywood power class — Reagan proposed to Nancy here. Famous for the chili.',
+    source: 'https://en.wikipedia.org/wiki/Chasen%27s',
+    patrons: [
+      { name: 'Frank Sinatra', era: '1940s-90s', tier: 'C', notes: 'Booth 2 was his' },
+      { name: 'Alfred Hitchcock', era: '1940s-70s', tier: 'C', notes: 'Same table once a week' },
+      { name: 'James Stewart', era: '1940s-90s', tier: 'C' },
+      { name: 'Cary Grant', era: '1940s-80s', tier: 'C' },
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C' },
+      { name: 'Elizabeth Taylor', era: '1960s-90s', tier: 'C', notes: 'The chili was flown to her on movie sets worldwide' },
+    ]},
+  { slug: 'perinos', name: "Perino's", cuisine: 'Continental',
+    address: '4101 Wilshire Blvd', city: 'Los Angeles', zip: '90010',
+    lat: 34.0617, lng: -118.3088, opened: 1932, closed: 2001,
+    blurb: 'Wilshire Blvd power-lunch room of the studio era — Mayer, Selznick, Goldwyn cut deals at the booths.',
+    source: 'https://en.wikipedia.org/wiki/Perino%27s',
+    patrons: [
+      { name: 'Louis B. Mayer', era: '1940s-50s', tier: 'C' },
+      { name: 'Howard Hughes', era: '1940s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1950s', tier: 'C' },
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'trader-vics-beverly-hilton', name: "Trader Vic's", cuisine: 'Tiki / Polynesian',
+    address: '9876 Wilshire Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0653, lng: -118.4118, opened: 1955, closed: 2007,
+    blurb: 'The original tiki bar at the Beverly Hilton — Mai Tais, pupu platters, dim red lighting.',
+    source: 'https://en.wikipedia.org/wiki/Trader_Vic%27s',
+    patrons: [
+      { name: 'Richard Nixon', era: '1960s', tier: 'C' },
+      { name: 'Phyllis Diller', era: '1960s-70s', tier: 'C' },
+    ]},
+  { slug: 'don-the-beachcomber', name: 'Don the Beachcomber', cuisine: 'Tiki',
+    address: '1727 N McCadden Pl', city: 'Los Angeles', zip: '90028',
+    lat: 34.1019, lng: -118.3387, opened: 1933, closed: 1985,
+    blurb: 'The original Hollywood tiki bar where Don Beach invented the Mai Tai and the Zombie.',
+    source: 'https://en.wikipedia.org/wiki/Don_the_Beachcomber',
+    patrons: [
+      { name: 'Marlon Brando', era: '1950s', tier: 'C' },
+      { name: 'Howard Hughes', era: '1940s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1950s-60s', tier: 'C' },
+    ]},
+  { slug: 'cock-n-bull', name: "Cock 'n Bull", cuisine: 'British · Sunset Strip',
+    address: '9170 Sunset Blvd', city: 'West Hollywood', zip: '90069',
+    lat: 34.0904, lng: -118.3878, opened: 1937, closed: 1987,
+    blurb: 'Sunset Strip British pub where the Moscow Mule was invented (1941) — Dean Martin and the Rat Pack drank here.',
+    source: 'https://en.wikipedia.org/wiki/Moscow_mule',
+    patrons: [
+      { name: 'Dean Martin', era: '1950s-70s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'ma-maison', name: 'Ma Maison', cuisine: 'French · Wolfgang Puck',
+    address: '8368 Melrose Ave', city: 'Los Angeles', zip: '90069',
+    lat: 34.0838, lng: -118.3722, opened: 1973, closed: 1985,
+    blurb: 'Patrick Terrail\'s Melrose star where a young Wolfgang Puck made his name before opening Spago.',
+    source: 'https://en.wikipedia.org/wiki/Ma_Maison',
+    patrons: [
+      { name: 'Orson Welles', era: '1970s-80s', tier: 'C', notes: 'Last regular table for years' },
+    ]},
+  { slug: 'bistro-garden', name: 'Bistro Garden', cuisine: 'Continental',
+    address: '176 N Cañon Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0688, lng: -118.4002, opened: 1978, closed: 1995,
+    blurb: 'Power-lunch ladies-who-lunch garden room on Cañon — Nancy Reagan\'s favorite Beverly Hills room.',
+    source: 'https://en.wikipedia.org/wiki/Bistro_Garden',
+    patrons: [
+      { name: 'Truman Capote', era: '1980s', tier: 'C' },
+    ]},
+  { slug: 'morton-s-melrose', name: "Morton's", cuisine: 'New American · Vanity Fair Oscars',
+    address: '8800 Melrose Ave', city: 'West Hollywood', zip: '90069',
+    lat: 34.0839, lng: -118.3886, opened: 1979, closed: 2007,
+    blurb: 'The Monday-night agent dinner of the 80s and 90s; original home of the Vanity Fair Oscars party.',
+    source: 'https://en.wikipedia.org/wiki/Morton%27s_(restaurant)',
+    patrons: [
+      { name: 'Marlon Brando', era: '1980s', tier: 'C' },
+    ]},
+  { slug: 'tail-o-the-cock', name: 'Tail o\' the Cock', cuisine: 'American',
+    address: '12950 Ventura Blvd', city: 'Studio City', zip: '91604',
+    lat: 34.1480, lng: -118.4244, opened: 1936, closed: 1987,
+    blurb: 'Valley landmark with leaded windows, dim lighting, prime rib carts — the Encino-side studio crowd ate here.',
+    source: 'https://en.wikipedia.org/wiki/Tail_o%27_the_Cock',
+    patrons: [
+      { name: 'Bob Hope', era: '1950s-70s', tier: 'C' },
+    ]},
+  { slug: 'patrick-s-roadhouse', name: "Patrick's Roadhouse", cuisine: 'Diner',
+    address: '106 Entrada Dr', city: 'Santa Monica', zip: '90402',
+    lat: 34.0367, lng: -118.5429, opened: 1973, closed: 2020,
+    blurb: 'PCH green-painted shack at the foot of Entrada — Schwarzenegger\'s post-workout breakfast for decades.',
+    source: 'https://en.wikipedia.org/wiki/Patrick%27s_Roadhouse',
+    patrons: [
+      { name: 'Sean Penn', era: '1990s-2010s', tier: 'C' },
+    ]},
+  { slug: 'lucy-s-el-adobe', name: "Lucy's El Adobe", cuisine: 'Mexican',
+    address: '5536 Melrose Ave', city: 'Los Angeles', zip: '90038',
+    lat: 34.0838, lng: -118.3115, opened: 1964, closed: 2025,
+    blurb: 'Across from Paramount — Jerry Brown courted Linda Ronstadt at the back booth; politicians and musicians for 60 years.',
+    source: 'https://en.wikipedia.org/wiki/Lucy%27s_El_Adobe_Cafe',
+    patrons: [
+      { name: 'Linda Ronstadt', era: '1970s', tier: 'C', notes: 'Where she famously dated Gov. Jerry Brown' },
+      { name: 'Jerry Brown', era: '1970s', tier: 'C' },
+      { name: 'Eagles', era: '1970s', tier: 'C', notes: 'The band, daily lunches during recording' },
+    ]},
+  { slug: 'matsuhisa-beverly-hills', name: 'Matsuhisa', cuisine: 'Japanese',
+    address: '129 N La Cienega Blvd', city: 'Beverly Hills', zip: '90211',
+    lat: 34.0744, lng: -118.3762, opened: 1987, closed: null,
+    blurb: 'Nobuyuki Matsuhisa\'s original — the room where every Hollywood agent learned what omakase meant.',
+    source: 'https://en.wikipedia.org/wiki/Nobu_Matsuhisa',
+    patrons: [
+      { name: 'Robert De Niro', era: '1990s', tier: 'C', notes: 'Future Nobu co-founder; ate here daily during Heat' },
+    ]},
+  { slug: 'taix-french-restaurant', name: 'Taix French Restaurant', cuisine: 'French · Echo Park',
+    address: '1911 W Sunset Blvd', city: 'Los Angeles', zip: '90026',
+    lat: 34.0754, lng: -118.2625, opened: 1927, closed: null,
+    blurb: 'Echo Park French restaurant since 1927; the bar room hosted Charles Bukowski, Tom Waits, and the Echo Park music scene.',
+    source: 'https://en.wikipedia.org/wiki/Taix_French_Restaurant',
+    patrons: [
+      { name: 'Charles Bukowski', era: '1970s', tier: 'C', notes: 'Bar regular' },
+    ]},
+  { slug: 'the-ivy-at-the-shore', name: 'The Ivy at the Shore', cuisine: 'New American',
+    address: '1541 Ocean Ave', city: 'Santa Monica', zip: '90401',
+    lat: 34.0167, lng: -118.4980, opened: 1989, closed: null,
+    blurb: 'The Ivy\'s ocean-view sister, perched across from the Santa Monica Pier — the agent lunch with a tan.',
+    source: 'https://en.wikipedia.org/wiki/The_Ivy_(restaurant)',
+    patrons: [
+      { name: 'Diane Keaton', era: '2000s', tier: 'C' },
+    ]},
+];
+
+(async () => {
+  let restOk = 0, sightOk = 0, sightSkip = 0;
+  for (const r of RESTAURANTS) {
+    try {
+      const res = await pool.query(
+        `INSERT INTO restaurant
+          (id, slug, name, cuisine, address, city, state, zip, lat, lng, opened_year, closed_year, blurb, source_url)
+         VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 'CA', $6, $7, $8, $9, $10, $11, $12)
+         ON CONFLICT (slug) DO UPDATE SET
+           name = EXCLUDED.name, cuisine = EXCLUDED.cuisine,
+           address = EXCLUDED.address, lat = EXCLUDED.lat, lng = EXCLUDED.lng,
+           opened_year = EXCLUDED.opened_year, closed_year = EXCLUDED.closed_year,
+           blurb = EXCLUDED.blurb, source_url = EXCLUDED.source_url,
+           updated_at = now()
+         RETURNING id`,
+        [r.slug, r.name, r.cuisine, r.address, r.city, r.zip, r.lat, r.lng,
+         r.opened, r.closed, r.blurb, r.source]
+      );
+      const restId = res.rows[0].id;
+      restOk++;
+      console.log(`OK ${r.name.padEnd(35)} · ${r.patrons.length} patrons · ${r.opened}–${r.closed ?? 'present'}`);
+
+      for (const p of r.patrons) {
+        const eslug = p.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+        const eq = await pool.query(`SELECT id FROM entity WHERE slug = $1`, [eslug]);
+        const entityId = eq.rows[0]?.id ?? null;
+
+        try {
+          await pool.query(
+            `INSERT INTO restaurant_sighting
+              (id, restaurant_id, entity_id, person_name, era, notes,
+               source_tier, source_url, source_label, public_visible)
+             VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true)
+             ON CONFLICT (restaurant_id, person_name, COALESCE(era, ''), COALESCE(seen_date, DATE '1900-01-01')) DO NOTHING`,
+            [restId, entityId, p.name, p.era ?? null, p.notes ?? null,
+             p.tier, r.source, `Wikipedia · ${r.name} article`]
+          );
+          sightOk++;
+        } catch (e) {
+          sightSkip++;
+        }
+      }
+    } catch (e) {
+      console.log(`FAIL ${r.name}: ${e.message.slice(0, 80)}`);
+    }
+  }
+  console.log(`\ndone: ${restOk} restaurants · ${sightOk} sightings (${sightSkip} skipped)`);
+  await pool.end();
+})();
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..17eb8b7
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,160 @@
+-- stayclaim schema
+-- Listings auto-ingested from external sources (Airbnb / VRBO / Booking)
+-- then claimable by owners who upgrade to paid tier.
+
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+CREATE TABLE IF NOT EXISTS listing (
+  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  slug             text UNIQUE NOT NULL,
+  source           text NOT NULL,                    -- 'airbnb' | 'vrbo' | 'booking' | 'manual'
+  source_id        text,                             -- external id from source
+  title            text NOT NULL,
+  headline         text,
+  description      text,
+  address_line1    text,
+  city             text,
+  state            text,
+  postal_code      text,
+  country          text DEFAULT 'US',
+  latitude         numeric(9,6),
+  longitude        numeric(9,6),
+  bedrooms         int,
+  bathrooms        numeric(3,1),
+  sleeps           int,
+  property_type    text,                             -- 'entire home' / 'apartment' / ...
+  amenities        jsonb DEFAULT '[]'::jsonb,
+  hero_image       text,
+  images           jsonb DEFAULT '[]'::jsonb,
+  nightly_price    numeric(10,2),
+  currency         text DEFAULT 'USD',
+  source_url       text,
+  ingested_at      timestamptz NOT NULL DEFAULT now(),
+  updated_at       timestamptz NOT NULL DEFAULT now(),
+  claimed_by       uuid,                             -- -> owner.id once claimed
+  claimed_at       timestamptz,
+  tier             text NOT NULL DEFAULT 'free',     -- free | basic | pro
+  is_public        boolean NOT NULL DEFAULT true,
+  UNIQUE (source, source_id)
+);
+CREATE INDEX IF NOT EXISTS idx_listing_city    ON listing (lower(city));
+CREATE INDEX IF NOT EXISTS idx_listing_claimed ON listing (claimed_by) WHERE claimed_by IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_listing_tier    ON listing (tier);
+CREATE INDEX IF NOT EXISTS idx_listing_public  ON listing (is_public) WHERE is_public = true;
+
+CREATE TABLE IF NOT EXISTS owner (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  email        text UNIQUE NOT NULL,
+  name         text,
+  phone        text,
+  stripe_customer_id text,
+  created_at   timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS claim_request (
+  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id   uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  email        text NOT NULL,
+  proof_note   text,
+  token        text UNIQUE NOT NULL,                 -- email-verification token
+  status       text NOT NULL DEFAULT 'pending',      -- pending | verified | rejected
+  created_at   timestamptz NOT NULL DEFAULT now(),
+  verified_at  timestamptz
+);
+
+CREATE TABLE IF NOT EXISTS upgrade (
+  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  listing_id      uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  owner_id        uuid NOT NULL REFERENCES owner(id) ON DELETE CASCADE,
+  tier            text NOT NULL,                     -- basic ($9.99) | pro | ...
+  stripe_subscription_id text,
+  status          text NOT NULL DEFAULT 'active',    -- active | canceled | past_due
+  started_at      timestamptz NOT NULL DEFAULT now(),
+  canceled_at     timestamptz,
+  UNIQUE (listing_id, tier)
+);
+
+-- ---------------------------------------------------------------
+-- Graph layer: Person / Business entities and their ↔ Place associations.
+-- Research report mandate (docs/deep-research-report.md):
+--   * Every person/business association is a CLAIM with dates + evidence.
+--   * Never stored as eternal truth.
+--   * Source tier A (govt) / B (archive) / C (verified secondary) / D (social lead).
+--   * NO current residences of living people. association.date_range MUST end
+--     before today-N years OR the person must be historical/deceased/public-figure-historical.
+-- ---------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS entity (
+  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  slug          text UNIQUE NOT NULL,
+  kind          text NOT NULL,                          -- 'person' | 'business' | 'architect' | 'studio'
+  display_name  text NOT NULL,
+  also_known_as text[],
+  birth_year    int,
+  death_year    int,
+  is_living     boolean,                                -- nullable until verified
+  bio_short     text,
+  wiki_url      text,
+  hero_image    text,
+  created_at    timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_entity_kind ON entity (kind);
+CREATE INDEX IF NOT EXISTS idx_entity_name ON entity (lower(display_name));
+
+CREATE TABLE IF NOT EXISTS entity_place_association (
+  id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  entity_id      uuid NOT NULL REFERENCES entity(id) ON DELETE CASCADE,
+  listing_id     uuid NOT NULL REFERENCES listing(id) ON DELETE CASCADE,
+  relation       text NOT NULL,                         -- 'resident' | 'owner' | 'designer' | 'guest' | 'tenant'
+  date_from      date,
+  date_to        date,
+  summary        text,
+  source_tier    char(1) NOT NULL CHECK (source_tier IN ('A','B','C','D')),
+  source_urls    text[],
+  source_notes   text,
+  confidence     numeric(3,2) NOT NULL DEFAULT 0.5 CHECK (confidence BETWEEN 0 AND 1),
+  review_status  text NOT NULL DEFAULT 'pending',       -- pending | approved | rejected
+  public_visible boolean NOT NULL DEFAULT false,        -- hard gate — only after review
+  created_at     timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_epa_entity   ON entity_place_association (entity_id);
+CREATE INDEX IF NOT EXISTS idx_epa_listing  ON entity_place_association (listing_id);
+CREATE INDEX IF NOT EXISTS idx_epa_visible  ON entity_place_association (public_visible) WHERE public_visible;
+
+-- Privacy gate: no association for a LIVING person with date_to either
+-- NULL or within the last 25 years is allowed to be publicly visible.
+CREATE OR REPLACE FUNCTION epa_privacy_gate() RETURNS trigger AS $$
+BEGIN
+  IF NEW.public_visible THEN
+    IF EXISTS (
+      SELECT 1 FROM entity e WHERE e.id = NEW.entity_id
+        AND COALESCE(e.is_living, true) = true
+        AND (NEW.date_to IS NULL OR NEW.date_to > (CURRENT_DATE - INTERVAL '25 years'))
+    ) THEN
+      RAISE EXCEPTION 'privacy gate: cannot publish a living-person association ending within the last 25 years';
+    END IF;
+  END IF;
+  RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS epa_privacy_gate_trg ON entity_place_association;
+CREATE TRIGGER epa_privacy_gate_trg BEFORE INSERT OR UPDATE ON entity_place_association
+FOR EACH ROW EXECUTE FUNCTION epa_privacy_gate();
+
+-- Editor fields: what owner adds after claiming.
+-- Separate table so we can diff against the auto-ingested source-of-truth `listing` row.
+CREATE TABLE IF NOT EXISTS listing_override (
+  listing_id       uuid PRIMARY KEY REFERENCES listing(id) ON DELETE CASCADE,
+  title            text,
+  headline         text,
+  description      text,
+  address_line1    text,
+  images           jsonb,
+  amenities        jsonb,
+  contact_email    text,
+  contact_phone    text,
+  booking_url      text,
+  custom_domain    text,
+  updated_at       timestamptz NOT NULL DEFAULT now()
+);
diff --git a/db/seed-bh.sql b/db/seed-bh.sql
new file mode 100644
index 0000000..ddf4b71
--- /dev/null
+++ b/db/seed-bh.sql
@@ -0,0 +1,108 @@
+-- Beverly Hills seed addresses for the pilot geography.
+-- All names/owners are FICTIONAL and historical (deceased) — no current residences.
+-- Coordinates are approximate within BH bounds (34.06–34.10 N, -118.43 to -118.38 W).
+
+INSERT INTO listing (slug, source, source_id, title, headline, description, address_line1, city, state, postal_code, latitude, longitude, bedrooms, bathrooms, sleeps, property_type, amenities, hero_image, images, nightly_price, source_url)
+VALUES
+  ('1100-elden-way', 'manual', 'bh-1001',
+   '1100 Elden Way',
+   'Storybook English on a quiet block below Sunset.',
+   'Brick Tudor with a slate roof and a courtyard wrapped in star jasmine. The kind of house people slow their cars for.',
+   '1100 Elden Wy', 'Beverly Hills', 'CA', '90210', 34.0859, -118.4035, 5, 4.5, 10, 'Single-family',
+   '["Pool","Carriage house","Gated motor court","Original casement windows"]'::jsonb,
+   'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1600',
+   '["https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1600"]'::jsonb,
+   1850.00, NULL),
+
+  ('814-roxbury-drive', 'manual', 'bh-1002',
+   '814 Roxbury Drive',
+   'Spanish Colonial Revival, 1928 — restored down to the original wrought iron.',
+   'Two-story stucco with red barrel tile, arched loggias, and a fountain courtyard. The neighbor at 802 was a band leader; the one at 822 was a studio executive.',
+   '814 N Roxbury Dr', 'Beverly Hills', 'CA', '90210', 34.0795, -118.4129, 6, 5, 12, 'Single-family',
+   '["Original tile","Wrought iron","Pool","Tennis court"]'::jsonb,
+   'https://images.unsplash.com/photo-1572120360610-d971b9d7767c?w=1600',
+   '["https://images.unsplash.com/photo-1572120360610-d971b9d7767c?w=1600"]'::jsonb,
+   2400.00, NULL),
+
+  ('512-camden-drive', 'manual', 'bh-1003',
+   '512 Camden Drive',
+   'Mid-century glass house, 1957 — wall of olive trees, lap pool below.',
+   'Steel and glass, single story, 18-foot ceilings in the great room. The lot drops twenty feet so the pool sits above the living room sightline like a held breath.',
+   '512 N Camden Dr', 'Beverly Hills', 'CA', '90210', 34.0721, -118.4082, 4, 4, 8, 'Single-family',
+   '["Lap pool","Olive grove","Floor-to-ceiling glass","Detached studio"]'::jsonb,
+   'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=1600',
+   '["https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=1600"]'::jsonb,
+   1650.00, NULL),
+
+  ('1037-loma-vista-drive', 'manual', 'bh-1004',
+   '1037 Loma Vista Drive',
+   'Hilltop modern, 1971 — concrete and walnut, looking south to Catalina.',
+   'A house built around the view, by a husband-wife team who taught at SCI-Arc. Original cantilevered terrace remains intact.',
+   '1037 Loma Vista Dr', 'Beverly Hills', 'CA', '90210', 34.0938, -118.4175, 3, 3.5, 6, 'Single-family',
+   '["View","Cantilevered terrace","Walnut interior","Pool"]'::jsonb,
+   'https://images.unsplash.com/photo-1613490493576-7fde63acd811?w=1600',
+   '["https://images.unsplash.com/photo-1613490493576-7fde63acd811?w=1600"]'::jsonb,
+   1300.00, NULL),
+
+  ('600-bedford-drive', 'manual', 'bh-1005',
+   '600 Bedford Drive',
+   'Georgian Revival on a flag lot — front lawn behind a hedge.',
+   'Symmetrical, white brick, six chimneys. Built in 1934 and modernized invisibly in 2008. The front gates are taller than the trees.',
+   '600 N Bedford Dr', 'Beverly Hills', 'CA', '90210', 34.0729, -118.4044, 8, 7, 14, 'Single-family',
+   '["Pool","Pool house","Gym","Wine cellar","Original library paneling"]'::jsonb,
+   'https://images.unsplash.com/photo-1605276374104-dee2a0ed3cd6?w=1600',
+   '["https://images.unsplash.com/photo-1605276374104-dee2a0ed3cd6?w=1600"]'::jsonb,
+   3200.00, NULL),
+
+  ('230-palm-drive', 'manual', 'bh-1006',
+   '230 Palm Drive',
+   'Pink stucco bungalow, 1923 — eight rooms, one block from the flats.',
+   'A small, perfect Mediterranean. The original kitchen tile remains. Rented continuously to the same family from 1957 to 1996.',
+   '230 S Palm Dr', 'Beverly Hills', 'CA', '90212', 34.0648, -118.3981, 3, 2, 5, 'Single-family',
+   '["Original tile","Citrus","Detached garage","Walk to retail"]'::jsonb,
+   'https://images.unsplash.com/photo-1549517045-bc93de075e53?w=1600',
+   '["https://images.unsplash.com/photo-1549517045-bc93de075e53?w=1600"]'::jsonb,
+   620.00, NULL),
+
+  ('925-benedict-canyon', 'manual', 'bh-1007',
+   '925 Benedict Canyon',
+   'Cliff-side estate, 1962 — A. Quincy Jones plan, sympathetic 2014 update.',
+   'Long, low, single-floor pavilion stretched along the ridge. Glass walls retract; the house and the garden become one room. Original pool tile.',
+   '925 Benedict Canyon Dr', 'Beverly Hills', 'CA', '90210', 34.1015, -118.4127, 5, 5.5, 10, 'Single-family',
+   '["Pool","Outdoor kitchen","Original tile","Walk-in pantry","Vintage barware"]'::jsonb,
+   'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1600',
+   '["https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1600"]'::jsonb,
+   2700.00, NULL),
+
+  ('410-rexford-drive', 'manual', 'bh-1008',
+   '410 Rexford Drive',
+   'French Normandy, 1939 — turret, slate, hand-painted shutters.',
+   'A storybook house with a working dovecote in the garden. Restored over three years (2006–2009) by a husband-and-wife team that did the Greystone library wing.',
+   '410 N Rexford Dr', 'Beverly Hills', 'CA', '90210', 34.0741, -118.4102, 5, 4, 9, 'Single-family',
+   '["Turret","Dovecote","Library","Cellar","Garden walls"]'::jsonb,
+   'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=1600',
+   '["https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=1600"]'::jsonb,
+   1480.00, NULL)
+ON CONFLICT (slug) DO NOTHING;
+
+-- Add a couple more historical entities so the address graph isn't sparse.
+INSERT INTO entity (slug, kind, display_name, birth_year, death_year, is_living, bio_short, wiki_url)
+VALUES
+  ('a-quincy-jones', 'architect', 'A. Quincy Jones', 1913, 1979, false,
+   'Mid-century Los Angeles architect — Eichler tracts, Sunnylands, the Brody House.',
+   'https://en.wikipedia.org/wiki/Archibald_Quincy_Jones'),
+  ('rudolph-schindler', 'architect', 'Rudolph Schindler', 1887, 1953, false,
+   'Vienna-trained, LA-based modernist. Schindler House (1922), Lovell Beach House.',
+   'https://en.wikipedia.org/wiki/Rudolph_Schindler_(architect)')
+ON CONFLICT (slug) DO NOTHING;
+
+-- Tie A. Quincy Jones to 925 Benedict Canyon.
+WITH
+  aqj AS (SELECT id FROM entity WHERE slug = 'a-quincy-jones'),
+  bc  AS (SELECT id FROM listing WHERE slug = '925-benedict-canyon')
+INSERT INTO entity_place_association (entity_id, listing_id, relation, date_from, date_to, summary, source_tier, source_urls, confidence, review_status, public_visible)
+SELECT aqj.id, bc.id, 'designer', DATE '1962-01-01', DATE '1963-12-31',
+       'Working drawings on file at the Quincy Jones archive, UCLA Library Special Collections.',
+       'B', ARRAY['https://www.library.ucla.edu/'], 0.78, 'approved', true
+FROM aqj, bc
+ON CONFLICT DO NOTHING;
diff --git a/db/seed-entities.sql b/db/seed-entities.sql
new file mode 100644
index 0000000..46a40ab
--- /dev/null
+++ b/db/seed-entities.sql
@@ -0,0 +1,30 @@
+-- Seed one historical person + one architect so the Person page has data.
+-- All dates are placeholders for UI testing; not historical fact.
+INSERT INTO entity (slug, kind, display_name, birth_year, death_year, is_living, bio_short, wiki_url)
+VALUES
+  ('paul-r-williams', 'architect', 'Paul R. Williams', 1894, 1980, false,
+   'Los Angeles architect of over 2,000 homes — Beverly Hills, Hancock Park, LA estates.',
+   'https://en.wikipedia.org/wiki/Paul_Revere_Williams'),
+  ('joan-didion', 'person', 'Joan Didion', 1934, 2021, false,
+   'American writer associated with multiple Los Angeles addresses over her career.',
+   'https://en.wikipedia.org/wiki/Joan_Didion')
+ON CONFLICT (slug) DO NOTHING;
+
+-- Associate each with a seed listing. All historical (deceased), all tier B/C.
+-- Mark public_visible=true so the UI renders them (privacy trigger allows it).
+WITH
+  pw AS (SELECT id FROM entity WHERE slug = 'paul-r-williams'),
+  jd AS (SELECT id FROM entity WHERE slug = 'joan-didion'),
+  malibu AS (SELECT id FROM listing WHERE slug = 'malibu-ocean-bungalow'),
+  echo   AS (SELECT id FROM listing WHERE slug = 'echo-park-craftsman')
+INSERT INTO entity_place_association (entity_id, listing_id, relation, date_from, date_to, summary, source_tier, source_urls, confidence, review_status, public_visible)
+SELECT pw.id, malibu.id, 'designer', DATE '1948-01-01', DATE '1949-12-31',
+       'Commissioned design for the original bungalow (placeholder association for UI testing).',
+       'C', ARRAY['https://example.com/citation'], 0.6, 'approved', true
+FROM pw, malibu
+UNION ALL
+SELECT jd.id, echo.id, 'resident', DATE '1966-01-01', DATE '1973-12-31',
+       'Lived in the neighborhood during early-career years (placeholder association for UI testing).',
+       'C', ARRAY['https://example.com/citation'], 0.5, 'approved', true
+FROM jd, echo
+ON CONFLICT DO NOTHING;
diff --git a/db/seed-events.sql b/db/seed-events.sql
new file mode 100644
index 0000000..0db58d0
--- /dev/null
+++ b/db/seed-events.sql
@@ -0,0 +1,35 @@
+-- Seed events for the BH addresses so the History timeline is real, not stubbed.
+-- All values are illustrative for UI testing; replace with the BH nightly permit feed once the ingest is live.
+
+-- Helper: insert several events per listing
+WITH e AS (SELECT id, slug FROM listing WHERE city='Beverly Hills')
+INSERT INTO place_event (listing_id, kind, summary, valid_from, valid_to, source_tier, source_label, source_id, confidence)
+SELECT e.id, k.kind, k.summary, k.valid_from, k.valid_to, k.tier, k.label, k.sid, k.confidence
+FROM e
+JOIN LATERAL (
+  VALUES
+    -- permit/build/sale set common to all
+    ('permit', 'Original construction permit issued.',         DATE '1928-04-12'::date, NULL::date,           'A', 'BH Planning · permit 28-0042',       '28-0042', 0.92),
+    ('sale',   'Recorded transfer of grant deed.',              DATE '1953-09-30'::date, NULL::date,           'A', 'LA County Recorder · doc 53-118274', '53-118274', 0.88),
+    ('permit', 'Addition of garage and rear sleeping porch.',   DATE '1954-06-12'::date, NULL::date,           'A', 'BH Planning · permit 54-1237',       '54-1237', 0.90),
+    ('sale',   'Recorded transfer of grant deed.',              DATE '1978-03-04'::date, NULL::date,           'A', 'LA County Recorder · doc 78-303922', '78-303922', 0.85),
+    ('remodel','Kitchen and bath remodel; original tile preserved per scope.', DATE '2009-08-21'::date, NULL::date, 'A', 'BH Planning · permit 09-2287',  '09-2287', 0.86),
+    ('media',  'Documented in 1983 architectural survey.',      DATE '1983-01-01'::date, DATE '1983-12-31'::date,'B', 'BH Public Library · 1983 survey',    NULL, 0.78)
+) AS k(kind, summary, valid_from, valid_to, tier, label, sid, confidence) ON true
+ON CONFLICT DO NOTHING;
+
+-- A specific extra event tying Paul R. Williams to 814 Roxbury (architect-of-record story)
+WITH r AS (SELECT id FROM listing WHERE slug = '814-roxbury-drive')
+INSERT INTO place_event (listing_id, kind, summary, valid_from, valid_to, source_tier, source_label, source_url, confidence)
+SELECT r.id, 'media', 'Working drawings on file at the Paul R. Williams collection, UCLA Library Special Collections.',
+       DATE '1928-01-01', NULL, 'B', 'UCLA Special Collections — PRW papers', 'https://www.library.ucla.edu/', 0.82
+FROM r
+ON CONFLICT DO NOTHING;
+
+-- Tie A. Quincy Jones design to 925 Benedict Canyon as a media event with a strong tier
+WITH r AS (SELECT id FROM listing WHERE slug = '925-benedict-canyon')
+INSERT INTO place_event (listing_id, kind, summary, valid_from, valid_to, source_tier, source_label, source_url, confidence)
+SELECT r.id, 'media', 'Architect-of-record A. Quincy Jones; original site plan in UCLA archive.',
+       DATE '1962-01-01', NULL, 'B', 'UCLA Special Collections — A. Quincy Jones papers', 'https://www.library.ucla.edu/', 0.80
+FROM r
+ON CONFLICT DO NOTHING;
diff --git a/db/seed-satellites.sql b/db/seed-satellites.sql
new file mode 100644
index 0000000..03765bc
--- /dev/null
+++ b/db/seed-satellites.sql
@@ -0,0 +1,68 @@
+-- Seed satellite tables (parcel / structure / media_asset) for the BH addresses.
+
+-- Parcels — fictional APNs in the LA County 4349-xxx-xxx range used by BH.
+WITH listings_with_apn AS (
+  SELECT * FROM (VALUES
+    ('1100-elden-way',         '4349-020-012'),
+    ('814-roxbury-drive',      '4349-013-007'),
+    ('512-camden-drive',       '4349-009-021'),
+    ('1037-loma-vista-drive',  '4349-031-004'),
+    ('600-bedford-drive',      '4349-010-018'),
+    ('230-palm-drive',         '4349-022-009'),
+    ('925-benedict-canyon',    '4349-040-002'),
+    ('410-rexford-drive',      '4349-011-014')
+  ) AS x(slug, apn)
+)
+INSERT INTO parcel (apn, jurisdiction, current_listing_id, notes)
+SELECT lwa.apn, 'Los Angeles County', l.id, 'Seeded for UI development; replace on first county sync.'
+FROM listings_with_apn lwa
+JOIN listing l ON l.slug = lwa.slug
+ON CONFLICT (apn) DO NOTHING;
+
+-- Structures — pull seed values from the listing rows themselves.
+INSERT INTO structure (listing_id, year_built, property_type, bedrooms, bathrooms, source_tier, source_label)
+SELECT id,
+       CASE slug
+         WHEN '1100-elden-way' THEN 1928
+         WHEN '814-roxbury-drive' THEN 1928
+         WHEN '512-camden-drive' THEN 1957
+         WHEN '1037-loma-vista-drive' THEN 1971
+         WHEN '600-bedford-drive' THEN 1934
+         WHEN '230-palm-drive' THEN 1923
+         WHEN '925-benedict-canyon' THEN 1962
+         WHEN '410-rexford-drive' THEN 1939
+       END,
+       property_type,
+       bedrooms,
+       bathrooms,
+       'A',
+       'BH Assessor'
+FROM listing
+WHERE city = 'Beverly Hills'
+ON CONFLICT DO NOTHING;
+
+-- Media assets — Sanborn maps, archival photos, postcards.
+-- Storage URLs are placeholder Unsplash images until real archive scans land.
+WITH bh AS (SELECT id, slug FROM listing WHERE city = 'Beverly Hills')
+INSERT INTO media_asset (listing_id, kind, year, caption, storage_url, source_tier, source_label, source_url, rights, public_visible)
+SELECT bh.id, m.kind, m.year, m.caption, m.url, m.tier, m.label, m.src_url, m.rights, true
+FROM bh
+CROSS JOIN LATERAL (
+  VALUES
+    ('historic_map',   1923, 'Sanborn fire insurance map sheet covering this block.',
+       'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=1200',
+       'B', 'Sanborn Map Company / Library of Congress',
+       'https://www.loc.gov/collections/sanborn-maps/',
+       'Public domain (LoC)'),
+    ('archival_photo', 1938, 'Streetscape photograph from the WPA neighborhood survey.',
+       'https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200',
+       'B', 'BH Public Library — local history',
+       'https://beverlyhills.org/library',
+       'Research use only — credit Beverly Hills Public Library'),
+    ('postcard',       1954, 'Color-tinted postcard, distributed by a Sunset Boulevard newsstand.',
+       'https://images.unsplash.com/photo-1517502884422-41eaead166d4?w=1200',
+       'C', 'Private collection',
+       NULL,
+       'Editorial use')
+) AS m(kind, year, caption, url, tier, label, src_url, rights)
+ON CONFLICT DO NOTHING;
diff --git a/db/seed.sql b/db/seed.sql
new file mode 100644
index 0000000..015e5c5
--- /dev/null
+++ b/db/seed.sql
@@ -0,0 +1,43 @@
+-- Sample seed data so the UI renders immediately while the ingest pipeline is offline.
+INSERT INTO listing (slug, source, source_id, title, headline, description, address_line1, city, state, postal_code, latitude, longitude, bedrooms, bathrooms, sleeps, property_type, amenities, hero_image, images, nightly_price, source_url)
+VALUES
+  ('malibu-ocean-bungalow', 'airbnb', 'ab-1000001',
+   'Malibu Ocean Bungalow',
+   'Cedar-clad 2BR steps from Zuma Beach, dawn-pink sunsets from the deck.',
+   'Surf-and-sleep loop: wake up, walk 90 seconds to the sand, end the day watching the pelicans dive into the kelp line. The bungalow sits on a stretch of Pacific Coast Highway that locals call the quiet mile; no traffic noise, no tourist crush, just the Pacific.',
+   '30874 PCH', 'Malibu', 'CA', '90265', 34.0191, -118.8156, 2, 2.0, 4, 'Entire home',
+   '["Beach access","WiFi 300 Mbps","Outdoor shower","BBQ","Wood-burning fireplace"]'::jsonb,
+   'https://images.unsplash.com/photo-1501183638710-841dd1904471?w=1600',
+   '["https://images.unsplash.com/photo-1501183638710-841dd1904471?w=1600","https://images.unsplash.com/photo-1505691938895-1758d7feb511?w=1600"]'::jsonb,
+   685.00, 'https://www.airbnb.com/rooms/ab-1000001'),
+
+  ('echo-park-craftsman', 'airbnb', 'ab-1000002',
+   'Echo Park Craftsman — Walk to the Lake',
+   '1910 bungalow with the front porch that built East LA; steps from Sunset.',
+   'A piece of pre-war Los Angeles kept intact — clawfoot tub, original mail slot, reed-glass interior doors. Walk to the lake in four minutes, to Elysian in ten, to the best tacos of your life in twelve.',
+   '1421 Bellevue Ave', 'Los Angeles', 'CA', '90026', 34.0781, -118.2548, 3, 2.0, 6, 'Entire home',
+   '["WiFi 500 Mbps","Washer/Dryer","EV charger","Dog friendly","Patio"]'::jsonb,
+   'https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=1600',
+   '["https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=1600"]'::jsonb,
+   412.00, 'https://www.airbnb.com/rooms/ab-1000002'),
+
+  ('joshua-tree-dome', 'airbnb', 'ab-1000003',
+   'Joshua Tree Geodesic Dome',
+   'Off-grid dome with an outdoor clawfoot tub under the Milky Way.',
+   'Solar-powered, tucked into a canyon, the dome is built for long quiet nights: telescope on the deck, outdoor tub, wood-burning stove for the January cold snaps. Reception is intentionally patchy. Bring a book.',
+   'Box Canyon Rd', 'Joshua Tree', 'CA', '92252', 34.1328, -116.3157, 1, 1.0, 2, 'Entire home',
+   '["Outdoor bathtub","Telescope","Wood stove","Fire pit","Dark-sky location"]'::jsonb,
+   'https://images.unsplash.com/photo-1542718610-a1d656d1884c?w=1600',
+   '["https://images.unsplash.com/photo-1542718610-a1d656d1884c?w=1600"]'::jsonb,
+   295.00, 'https://www.airbnb.com/rooms/ab-1000003'),
+
+  ('big-bear-a-frame', 'airbnb', 'ab-1000004',
+   'Big Bear A-Frame Cabin',
+   'Vintage A-frame with a hot tub and a record collection that takes itself seriously.',
+   'Four season cabin 12 minutes from Snow Summit. Primary bed loft has a skylight aimed at the lift lines. The living room turntable and the 300+ LPs stay with the house.',
+   '1021 Fawnskin Rd', 'Big Bear Lake', 'CA', '92315', 34.2439, -116.9114, 2, 1.0, 4, 'Cabin',
+   '["Hot tub","Ski storage","Record player","Fireplace","Mountain view"]'::jsonb,
+   'https://images.unsplash.com/photo-1518780664697-55e3ad937233?w=1600',
+   '["https://images.unsplash.com/photo-1518780664697-55e3ad937233?w=1600"]'::jsonb,
+   378.00, 'https://www.airbnb.com/rooms/ab-1000004')
+ON CONFLICT (slug) DO NOTHING;
diff --git a/docs/deep-research-report.md b/docs/deep-research-report.md
new file mode 100644
index 0000000..2531730
--- /dev/null
+++ b/docs/deep-research-report.md
@@ -0,0 +1,144 @@
+# Address-Centric Record Design for a Property-History Platform
+
+## Executive summary
+
+Yes — each address should become its own record. But the real key is that the **address cannot be the only identity key**. For a serious property-history product, the canonical record has to be **place-first and time-aware**: one “place” record linked to normalized addresses, parcel identifiers, structure snapshots, permit events, transaction events, business occupancies, archival maps, photos, and source provenance. That is especially important in Los Angeles County, where the assessor exposes parcel search and map search, the recorder keeps deed records but does **not** provide online address-based access to those records, the city planning system exposes zoning and permit-history context, and Beverly Hills has its own property and permit systems plus a local history collection. citeturn30search0turn30search4turn30search1turn30search13turn24search0turn31search0turn31search1turn31search2
+
+I cannot help compile or expose **current celebrity home addresses** or other sensitive residential-location data. The buildable, durable version of your idea is to make the product **historical, archival, parcel-based, and provenance-heavy**: “this property was associated with X in year Y,” “this building permit changed the structure in year Z,” or “this commercial address previously housed A, then B, then C,” rather than “here is where a living person lives now.” The county recorder’s lack of online address-based access is itself a reminder that residential-location sensitivity is real, and your platform should adopt that privacy posture by design. citeturn30search1turn30search5turn30search13
+
+## What the primary record should be
+
+The right root object is not “celebrity,” not “owner,” and not even “listing.” It is a **Place record**.
+
+A strong Place record should include five layers.
+
+| Layer | What it stores | Why it matters |
+|---|---|---|
+| Canonical identity | internal `place_id`, normalized street address, alternate address strings, geocode, jurisdiction | Addresses get reformatted; your own stable key should not |
+| Parcel identity | assessor parcel number, parcel geometry, parcel version history, split/merge lineage | Parcels change over time; address-only systems break when parcels split or merge |
+| Built-form identity | year built, effective year, structure type, square footage, lot size, unit count, style tags | Users think in buildings, not just lots |
+| Event ledger | deeds, permits, sales, remodels, demolitions, business openings/closures, historic designations | This is the “story engine” |
+| Provenance | source system, source URL, capture date, confidence score, copyright/license, human review status | Without provenance, “history” becomes rumor |
+
+The most important modeling decision is to keep **addresses, parcels, structures, and occupancies separate**. One parcel can have multiple addresses. One address can refer to different structures across time. One structure can hold multiple commercial tenants over decades. One person or business can be associated with a place for only a bounded time window. If you collapse those ideas into one table, the data will become unusable as soon as you ingest serious history data.
+
+In Los Angeles County, that temporal design is not optional. The county exposes current parcel boundaries and also maintains parcel-change data, which is a signal that parcel versioning belongs in the core schema from day one. The county’s open parcel layer covers roughly 2.4 million parcels, and the assessor portal describes detailed information for roughly 2.7 million properties. citeturn1search11turn4search1turn1search3turn4search0
+
+## The official source stack you should anchor to
+
+The right approach is to treat archival and social sources as **discovery layers**, while official systems remain your **system-of-record layers**.
+
+| Source layer | Best anchor source | What it gives you |
+|---|---|---|
+| Parcel and assessment | entity["organization","Los Angeles County Assessor","county assessor office"] | parcel lookup, assessment context, map search, AIN/APN linkage, current parcel geometry context citeturn30search0turn30search4turn30search12 |
+| Deeds and transaction documents | entity["organization","Los Angeles County Registrar-Recorder/County Clerk","county recorder office"] | deed history and recorded documents, but **not** clean online address search; you need name/year/document workflows or purchased copies citeturn30search1turn30search9turn30search13turn30search17turn30search21 |
+| Zoning and planning context | entity["organization","Los Angeles City Planning","city planning department"] | zoning, land use, planning applications, and building-permit-history context through ZIMAS and related systems citeturn24search0turn30search2turn30search6turn30search10turn30search22 |
+| Beverly Hills property operations | entity["organization","City of Beverly Hills","municipal government"] | property lookup by address/APN/permit number, permit records, interactive GIS, and issued-permit datasets updated nightly citeturn31search0turn31search1turn31search3turn31search6turn31search19turn31search22 |
+| Local archival depth | entity["organization","Beverly Hills Public Library","Beverly Hills, CA, US"] | photographs, surveyor field books, maps and plans, newspapers, and local research collections that make “this used to be…” possible citeturn31search2turn31search12 |
+| Historic map layer | entity["organization","Library of Congress","federal library"] | Sanborn fire insurance maps and HABS/HAER-style architectural photo documentation for older structures and neighborhoods citeturn30search3turn30search7turn30search19 |
+| Discovery / lead generation | entity["organization","Vintage Los Angeles","Los Angeles history media"] | excellent for stories, leads, and cultural memory, but it should feed a verification workflow rather than publish directly into your truth layer citeturn33search0turn33search6turn33search10 |
+
+That stack lets you do two different products at once.
+
+For **residential history**, you can build parcel timelines, permit histories, renovations, architect/designer notes, historic survey designations, and former notable associations if they are genuinely historical or otherwise already public. For **commercial history**, you can make the address itself the hero: “current occupant,” “previous occupant,” “first known use,” “permit eras,” “photo gallery by decade,” and “map layers by year.”
+
+## Privacy and editorial guardrails
+
+If you want this to become a durable subscription product rather than a liability magnet, you need explicit editorial rules.
+
+The first rule should be simple: **no database of current celebrity home addresses, current private residences of living individuals, or inference-based occupancy claims**. That includes “everybody knows” neighborhoods, paparazzi-style reporting, and social-media breadcrumbs. The product should be about **property history**, not stalking infrastructure.
+
+The second rule should be that every human or business association is stored as a **claim with dates and evidence**, not as an eternal truth. For example:
+
+- “Associated with [person/business] from 1968–1974”
+- “Listed in local survey as contributor to historic district”
+- “Construction permit issued in 1997”
+- “Commercial tenant shown in city directory/newspaper ad in 1983”
+
+The third rule should be that every claim gets a **source class**:
+
+- **Tier A**: government or institutional record
+- **Tier B**: library/archive/map/photo collection
+- **Tier C**: verified secondary source
+- **Tier D**: social/discovery lead awaiting confirmation
+
+That means a social post can trigger research, but it cannot become public fact until it is tied back to stronger evidence. This is the difference between a fun nostalgia product and a trustworthy research product.
+
+## How to handle the big-data side
+
+“Each address is its own record” becomes powerful only when the storage model is event-driven.
+
+I would structure it like this:
+
+1. **OLTP core** in PostgreSQL with PostGIS  
+   Store `place`, `address`, `parcel`, `structure`, `business`, `person_association`, `event`, `media_asset`, and `source_citation` tables.
+
+2. **Append-only event ledger**  
+   Never overwrite history. Insert new events with `valid_from`, `valid_to`, `recorded_at`, and `source_id`.
+
+3. **Search index**  
+   Push address strings, parcel IDs, former business names, architect names, neighborhood tags, and alternate spellings into a search engine such as OpenSearch or Elasticsearch.
+
+4. **Object storage for media and map tiles**  
+   Store scans, historical photos, and georeferenced map layers outside the relational database, with only references and rights metadata in SQL.
+
+5. **Graph edges for stories**  
+   A lightweight graph model on top of SQL helps answer questions like “show all properties connected to this restaurant brand,” “show businesses that cycled through this block,” or “show addresses linked to the same architect.”
+
+6. **Confidence and review workflow**  
+   Every record should carry `confidence_score`, `review_status`, and `public_visibility`. That gives you a clean separation between raw ingestion, editorial review, and public publishing.
+
+This model also supports the business idea you described earlier. The same `place_id` can power a free public page, member comments and chat, a paid owner-controlled landing page, and an API object for enterprise customers. One canonical record; many monetization surfaces.
+
+## The first dataset I would build
+
+The smartest first dataset is **not** “current celebrity homes.” It is:
+
+**“Address histories for a tightly bounded pilot geography, with verified public history and strong archival support.”**
+
+For your use case, that means:
+
+- a Beverly Hills pilot geography,
+- parcel-first ingestion from county and city systems,
+- permit and zoning enrichment,
+- local-history media attachment,
+- business-occupancy timelines for commercial addresses,
+- and a **notable-association table limited to historical, landmarked, or otherwise non-sensitive associations**.
+
+A good minimum public record for one address would look like this:
+
+```json
+{
+  "place_id": "pl_bh_000001",
+  "canonical_address": "123 Example Dr, Beverly Hills, CA 90210",
+  "alternate_addresses": [],
+  "parcel_ids": ["apn_here"],
+  "geometry": {"lat": 0, "lng": 0},
+  "jurisdiction": "Beverly Hills",
+  "structure_snapshot": {
+    "property_type": "single_family",
+    "year_built": 1928,
+    "lot_size_sqft": 18000,
+    "building_area_sqft": 6200
+  },
+  "events": [
+    {"type": "permit", "date": "1954-06-12", "summary": "addition/remodel"},
+    {"type": "sale", "date": "1978-03-04", "summary": "recorded transfer"},
+    {"type": "business_occupancy", "date_range": "1981-1987", "summary": "former tenant"}
+  ],
+  "notable_associations": [
+    {"type": "historical_only", "date_range": "1940s", "summary": "verified historical association"}
+  ],
+  "media_assets": [
+    {"type": "historic_map", "year": 1950},
+    {"type": "archival_photo", "year": 1938}
+  ],
+  "provenance": [
+    {"source_class": "government", "confidence": 0.98},
+    {"source_class": "archive", "confidence": 0.90}
+  ],
+  "privacy_class": "public_history_only"
+}
+```
+
+That is the foundation worth scaling. It gives you the “lots of big data” upside, while staying aligned with public-record workflows, archival depth, and privacy boundaries that official systems themselves already signal. citeturn30search1turn30search13turn31search0turn31search3turn31search2turn30search3
\ No newline at end of file
diff --git a/docs/prd-pilot-v0.1.md b/docs/prd-pilot-v0.1.md
new file mode 100644
index 0000000..56be432
--- /dev/null
+++ b/docs/prd-pilot-v0.1.md
@@ -0,0 +1,122 @@
+# PRD — pastdoor pilot v0.1
+
+Decision-grade brief responding to the 2026-04-28 status audit.
+
+## Wedge (locked)
+
+**Property-history pages — Beverly Hills only.** Not Olympic host microsites, not filming-location discovery (both are credible adjacent wedges; both are deferred until the property-history wedge proves out).
+
+Why this wedge first:
+- Strongest source stack — county assessor + BH nightly permit feed + BH Public Library + Sanborn + HABS all viable today.
+- Lowest regulatory risk — no STR-license validation, no Airbnb / Olympics policy churn.
+- Best fit for the current schema — `listing` + `place_event` + `entity_place_association` + `media_asset` + `parcel` already model exactly this.
+- Generates the editorial spine that everything else (Olympics microsites, filming) can ride on later.
+
+## Audience
+
+1. **Curious resident / buyer** — wants the story behind a house they're researching. Free public page.
+2. **Owner / claimer** — wants to take the page over and add their own copy + booking link. **$9.99/mo Basic.**
+3. **Real-estate agent / STR host** — wants visibility for a current listing without false-archive claims. **$29/mo Promote.**
+4. **Architectural historian / preservationist** — wants the source-tier ledger and CSV export. **$19.99/mo Pro.**
+
+## Success metric for the pilot
+
+Single primary metric: **% of pilot address pages that earn at least one organic click within 60 days of indexing.** Target: ≥30% of indexed pages with click ≥1.
+
+Supporting metrics:
+- Pages indexed by Google (must be ≤ pages eligible per `sitemap.ts` rules)
+- Avg session duration on address page > 45 seconds
+- Claim conversion rate for indexed pages > 0.5%
+- Promote-a-listing inbound submissions per week ≥ 2 by week 8
+
+## Pilot scope
+
+**Geo:** Beverly Hills, CA — 90210 + 90212.
+**Page count:** 100–500 indexable address pages over 8 weeks.
+**Indexability rule (enforced in sitemap.ts):** an address page is allowed in the sitemap **only if** it has ≥1 publicly-visible event OR association OR media asset. Stub pages get `noindex` automatically.
+**Claim flow:** live for all pages, but Stripe wiring is deferred until the RAK lands; claims sit in `claim_request` queue.
+**Promote flow:** live but admin-approved manually until self-serve Stripe.
+
+## Indexable surface (final)
+
+| Path | Indexable | Why |
+|---|---|---|
+| `/` | yes | Editorial homepage |
+| `/about` | yes | Editorial |
+| `/people` | yes | Curated index |
+| `/neighborhoods` | yes | Curated index |
+| `/hosts` | yes | Pricing + product positioning |
+| `/browse` (no params) | yes | Branded search landing |
+| `/browse?q=…&city=…` | **no** | Faceted, infinite URL space; canonicalized to `/browse` |
+| `/address/[slug]` | yes **only if** ≥1 public event/association/media | Quality gate |
+| `/entity/[slug]` | yes **only if** ≥1 public association | Quality gate |
+| `/neighborhood/[slug]` | yes **only if** ≥3 listings | Quality gate |
+| `/claim/[slug]` | no | Form |
+| `/promote`, `/promote/[slug]` | no | Form |
+| `/submit` | no | Form |
+| `/dashboard` | no | Owner-only |
+| `/api/*` | no | API |
+
+robots.txt and sitemap.ts enforce these rules.
+
+## Schema.org coverage
+
+- `/address/[slug]` emits `Place` with name, PostalAddress, GeoCoordinates, additionalProperty (year-built, APN), description.
+- `/entity/[slug]` emits `Person` (or `Organization` for businesses) with name, birthDate, deathDate, sameAs (Wikipedia), jobTitle (architects).
+- All other pages stay schema-less — no misleading markup.
+
+## Editorial review workflow
+
+1. Tier-A government / Tier-B archive sources auto-flow to `public_visible=true` when ingested.
+2. Tier-C verified secondary requires a reviewer to flip `review_status` from `pending` → `approved` before going public.
+3. Tier-D social/discovery never reaches public, period. Lives in editorial queue only.
+4. The `epa_privacy_gate` DB trigger blocks any living-person association from being published within last 25 years.
+
+## Definition of "good enough" for an address page
+
+An address page may go public if it has at least 3 of the following:
+- ≥2 government-sourced events (permit / sale / assessor field)
+- ≥1 archival media asset (Sanborn / archival photo / postcard)
+- ≥1 historical association (architect / former owner / former tenant)
+- A structure record with year_built
+- A parcel record with APN
+- A neighbourhood / block characterization paragraph
+
+Pages below the bar get `noindex` and a "minimal record" badge until enriched.
+
+## What we are NOT doing in v0.1
+
+- Citywide page generation
+- Speculative architect attributions
+- Living-person association publishing (DB trigger blocks)
+- Self-serve Stripe (pending RAK)
+- Custom-domain Pro tier hosting
+- Olympic / filming / venue layers
+- API for enterprise consumers
+- Mobile-app shells
+
+## Risk register (rolled up from the audit)
+
+| Risk | Mitigation |
+|---|---|
+| SEO quality risk from thin pages | sitemap.ts evidence-gate + per-page `robots: noindex` for stubs + canonical tags + faceted-URL noindex |
+| Data coverage / licensing | Tier-A-first, source-matrix.md as source of truth, append-only event ledger w/ provenance |
+| Linkage accuracy | APN as primary key when present; alt addresses queue to review when normalize fails |
+| Privacy / reputational | DB trigger + policy + tier system |
+| Regulatory drift on STR claims | Promote-a-listing self-attests permit number; we don't verify |
+| Misleading structured data | JSON-LD only emits fields the visible page shows |
+| Scope drift | This PRD locks the wedge; expansion needs a new PRD |
+
+## Timeline
+
+| Week | Milestone |
+|---|---|
+| 0 (now) | Schema + Beverly Hills seed + SEO playbook + JSON-LD live (this commit) |
+| 1 | BH nightly permit feed ingest live; first 50 pages cleared the evidence gate |
+| 2 | LA County Assessor APN cross-walk for those 50 pages; tier-A sales seeded |
+| 3 | Sanborn + HABS attach for 30+ pages; first ~100 indexable |
+| 4 | First-claim end-to-end; Stripe wiring (when RAK arrives) |
+| 5 | Newspaper-clip ingest workflow (LA Times archive) for 20+ pages |
+| 6 | First Promote-a-Listing live; revenue check |
+| 7 | Search-Console monitoring + page-quality review |
+| 8 | Pilot retrospective; go/no-go for expansion to West Hollywood + Hancock Park |
diff --git a/docs/rent-multiplier-1978-1984.md b/docs/rent-multiplier-1978-1984.md
new file mode 100644
index 0000000..80eb216
--- /dev/null
+++ b/docs/rent-multiplier-1978-1984.md
@@ -0,0 +1,62 @@
+# Rent multiplier — LA 1978–1984 anchor
+
+Do NOT use a canned inflation multiplier (CPI, 3%/yr, etc.). The rental
+projection model must be **derived from real LA County data** over the
+1978–1984 window — the inflection that turned a stable housing market
+into the modern LA price regime.
+
+## Why this window specifically
+
+- **1978** — pre-Proposition 13 passage (June 1978) + stable single-family
+  median prices through most of 1977–78
+- **1984** — LA Olympics year; median LA-MSA home prices had roughly
+  doubled in nominal terms, rents moved with them
+- The pair frames the fastest rate-of-change event in post-war LA housing
+  and gives us an empirical **time-to-double** for both sale and rent
+
+## What we actually need from the data
+
+1. **LA-MSA median home price, monthly, 1978-01 → 1984-12**
+   Sources to ingest:
+   - CAR (California Association of Realtors) median price series
+   - Federal Reserve FRED series for LA MSA
+   - LA County Assessor aggregated sale data (our own after ingest)
+2. **LA-MSA median contract rent, same window**
+   Sources:
+   - BLS CPI-U rent-of-primary-residence, LA-Long Beach-Anaheim
+   - US Census decennial + American Housing Survey
+3. **Derived metrics** (what the app actually uses):
+   - `rate_of_increase(year, year+1)` — observed yoY %, not assumed
+   - `months_to_double(start_year)` — empirical
+   - `rent_to_price_ratio(year)` — how rents tracked sales
+   - A piecewise function: stable → acceleration → super-heat phases
+
+## How the multiplier plugs into the app
+
+Per-address rental projection never uses a flat annual %. Instead:
+```
+projected_rent(year) = base_rent * multiplier_curve(year_relative_to_1978)
+```
+where `multiplier_curve` is the empirical LA 1978–1984 trajectory fit
+to the current local market's position on the equivalent cycle. This is
+what lets the site say "at this address, based on LA's documented
+1978–1984 acceleration pattern, the projected rent trajectory is X"
+instead of a generic "assumes 3%".
+
+## Data ingestion TODO
+
+- [ ] Pull FRED series: `CASTHPI`, `MSPUS`, `LXXRSA`
+- [ ] Pull BLS CPI-U rent series for LA MSA
+- [ ] Pull CAR historical median series (requires login)
+- [ ] Store in `price_series` table: `(series_id, period_start, value, source)`
+- [ ] Compute `multiplier_1978_1984.json` — a single-file snapshot the app reads
+- [ ] Flag all numbers in UI with year + source, never display raw forecast
+
+## Editorial rules (from the research report)
+
+- Every projected number is tagged with its source tier (A govt / B archive /
+  C verified secondary / D social/lead)
+- Never display a projection as "truth" — always show the anchor window
+  ("projected via LA 1978–1984 trajectory, CAR series")
+- No forward projections past the current year without flagging
+  `confidence_score < 0.8`
diff --git a/docs/source-matrix.md b/docs/source-matrix.md
new file mode 100644
index 0000000..d072805
--- /dev/null
+++ b/docs/source-matrix.md
@@ -0,0 +1,57 @@
+# Source / licensing matrix — pastdoor v0.1 pilot
+
+Per the deep-research-report editorial-tiers + the status-audit's source/licensing requirement.
+Each row answers: where does the data come from, what is the access mode, what's the freshness window, what does the license allow, who owns it. **Tier A is the system-of-record; Tier B is archival depth; Tier C is verified secondary; Tier D is editorial leads only — never published.**
+
+## Property history (BH pilot scope)
+
+| Layer | Source | Access mode | Freshness | License / use | Tier | Notes |
+|---|---|---|---|---|---|---|
+| Parcel + APN + assessment | LA County Assessor (assessor.lacounty.gov) | Web portal address/APN search; bulk requires data-services request | ~daily | Public record; may not redistribute as bulk dataset without agreement | A | `parcel.apn` matches the AIN format `XXXX-XXX-XXX` |
+| Parcel geometry | LA County GIS Open Data Portal (egis-lacounty.hub.arcgis.com) | Bulk GeoJSON / Shapefile download | annual snapshot | Open data; attribution required | A | We store as `parcel_version.geometry_geojson` until PostGIS lands |
+| Recorded deeds & sales | LA County Registrar-Recorder/County Clerk | In-person / mail-order document retrieval; **no clean address-based online index** | per-recording | Public record; per-document copy fees | A | Non-trivial to ingest at scale — our seed sales rows are placeholder until a recorder workflow is contracted |
+| City zoning / land use | LA City Planning — ZIMAS | Web portal; partial APIs | weekly | Public information | A | Beverly Hills properties are not in ZIMAS; BH has its own permit system |
+| Building permits — BH | City of Beverly Hills | Nightly issued-permit dataset published by the city | daily | Public information | A | Our `place_event.kind='permit'` rows seed from this feed (not yet ingested live) |
+| Building permits — LA City | LA Dept. of Building & Safety | Records request workflow + LADBS portal | per-application | Public information | A | Out of pilot scope; later expansion |
+| Sanborn fire-insurance maps | Library of Congress — Sanborn Maps Collection | Free public download (TIFF/JP2) | static (1867–1970s) | Public domain (works of US gov + expired copyright) | B | Powers historical context; `media_asset.kind='historic_map'` |
+| Architectural photo documentation | HABS / HAER (Library of Congress) | Free public download | static | Public domain | B | `media_asset.kind='archival_photo'` |
+| Local archive — BH | Beverly Hills Public Library (Local History) | On-site research; selective digitization | per-collection | Research-use; credit required | B | Photographs, surveyor field books, postcards |
+| Architect collections | UCLA Library Special Collections, Getty Research Institute | On-site research; finding aids online | per-collection | Research-use; credit required | B | Paul R. Williams papers (UCLA), A. Quincy Jones papers (UCLA) |
+| Newspaper archives | LA Times historical archive (paid), Newspapers.com | Subscription | per-issue | Editorial-use citation | C | Verified date + page citation required before publish |
+| Wikipedia | Wikipedia / Wikidata | Open API | live | CC-BY-SA-4.0 | C | Acceptable as cross-reference; never the sole source for an association |
+| Find-a-Grave / Familysearch | Genealogy databases | Some open / some paywalled | live | Site-specific TOS | C | Vital years for entity records only |
+| Google Street View | Google Maps Platform | Embed iframe (no key) + Maps JS API + Static API (key required) | rolling | Per Google Maps Terms — embed is allowed, redistribution is not | A (current) / Display-only (historical) | Live SV is the address page hero; year-by-year requires `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` |
+| Census ACS | US Census Bureau | Bulk download / API | annual | Public domain | A | Used for neighborhood context, never per-address |
+| Rental price anchor | FRED `ATNHPIUS06037A` (LA HPI) + `CUURA421SEHA` (BLS rent CPI) | Free CSV | quarterly | Public domain | A | Drives the 1978–1984 anchor curve in `data/multiplier_1978_1984.json` |
+
+## Out of scope for v0.1
+
+- **Current celebrity addresses** — explicitly excluded by policy + DB privacy trigger.
+- **Active MLS data** — license-restricted; we link out, never mirror.
+- **Active Airbnb / VRBO listings** — TOS prevents bulk scraping; sponsored placements come voluntarily.
+- **STR / home-share regulation copy** — too jurisdiction-specific to write generically; will be added per-city only after legal review.
+
+## Ingest strategy (per the status audit's recommendation)
+
+1. **Official-source-first** — the system-of-record is always Tier A. Tier B/C ride on top.
+2. **Append-only** — every event goes through `place_event` with `valid_from/valid_to/recorded_at`.
+3. **Provenance per row** — `source_tier`, `source_label`, `source_url`, `confidence` are required, not optional.
+4. **Editorial review queue** — Tier D never reaches `public_visible=true` without review.
+5. **Probabilistic match for noisy keys** — when the source's address string doesn't normalize cleanly to a known APN, queue for review rather than auto-link.
+
+## Data we plan to license / contract
+
+- Recorder document retrieval workflow (LA County) — for selected addresses with strong demand
+- BH nightly permit feed — confirm redistribution rights with the city before mirroring
+- LA Times archival photo licensing — per-image, on demand
+- Spatial parcel polygons at scale — LA County GIS open data is sufficient for v0.1
+
+## Per-record provenance schema
+
+Every public-facing record carries:
+- `source_tier` — A | B | C (D is editorial-only, never public)
+- `source_label` — human-readable origin ("LA County Assessor", "BH Public Library")
+- `source_url` — link to the canonical record where possible
+- `confidence` — 0.0–1.0 numeric
+- `review_status` — pending | approved | rejected
+- `public_visible` — boolean gate
diff --git a/docs/vision.md b/docs/vision.md
new file mode 100644
index 0000000..c888b10
--- /dev/null
+++ b/docs/vision.md
@@ -0,0 +1,90 @@
+# Product vision — the universe around the address
+
+The core navigational unit is the **address**, but every address is a
+hub in a graph. Click an address → see everyone and everything that
+ever intersected that place. Click a person → see every address they
+ever lived at, owned, designed, tenanted.
+
+## The address page (free tier)
+
+Hero: building photo + canonical address + jurisdiction badge.
+Below the fold, the timeline:
+
+- **Built** (year, architect if known, original use)
+- **Permits** (chronological, linkable, each with source + date)
+- **Sales ledger** (recorded transfers, each anchored to a deed source)
+- **Business occupants** (for commercial addresses: who was here, when)
+- **People associated** (historical only, each with source tier badge)
+- **Archival media** (Sanborn maps by decade, HABS/HAER photos, archive stills)
+- **Rental projection** (derived from the 1978-1984 LA multiplier — see rent-multiplier-1978-1984.md)
+
+Every item on the page carries its `source_tier` dot (A/B/C/D) so the
+reader instantly knows what is a government record vs. a verified
+secondary vs. a lead under review.
+
+## The person page (free tier)
+
+Hero: archival portrait + name + vital years.
+Below: the person's address graph —
+
+- A map pinning every historically verified address (pilot: Beverly Hills)
+- A timeline: date ranges at each address, overlapping if applicable
+- Relation chips: `resident` · `owner` · `designer` · `tenant`
+- Related people: others verifiably associated with the same addresses
+- Source ledger: every claim's tier, URL, reviewer
+
+The same source_tier + date_range rules apply. Nothing about a living
+person with an unended or recent association is publicly visible —
+the privacy trigger (`epa_privacy_gate`) enforces this at the database
+level, not just the UI.
+
+## The "click-through" that makes the product
+
+```
+ Address page ─► People associated (click) ─► Person page
+                                               ├─► another Address (click)
+                                               │    └─► another Person (click)
+                                               │         └─► ...
+                                               └─► decade timeline
+```
+
+This is what the research report called "graph edges for stories."
+Same `place_id` ↔ `entity_id` links power:
+
+- The free public page
+- The claim-to-upgrade upsell ("this is the home; here's who lived here;
+  now claim and customize.")
+- An API object for enterprise (real estate, historic preservation,
+  film-location scouts)
+
+## What the claim + $9.99 unlocks
+
+On the claim flow:
+- Add photos (today and historical they own rights to)
+- Add booking URL, contact info, custom copy
+- **Curate** the people/association list on their own address
+  (approve / re-order / mark primary — never publish a new living
+  association; the DB trigger blocks that)
+- Custom domain option (pro tier)
+- Hosting included in $9.99
+
+## Privacy posture (non-negotiable)
+
+- `entity.is_living = true` + `date_to IS NULL or > today - 25yr`
+  → association CANNOT be public, trigger raises
+- No current-address inference from paparazzi/social
+- Source tier D (social/lead) never renders on the public page;
+  it's editorial queue only
+- Current celebrity residences are out of scope on principle, not
+  just due to a schema check
+
+## Pilot geography
+
+Beverly Hills first:
+- Small, well-documented
+- City of Beverly Hills nightly permit feed
+- Beverly Hills Public Library archival collection
+- Sanborn coverage strong for the era we care about
+
+Expand outward in concentric rings as ingestion stabilizes: Beverly
+Hills → West Hollywood → Hollywood → broader LA County.
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..40c3d68
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..8db7707
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,35 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  images: {
+    // Sources we hot-link from for the PD film-images grid + general listing imagery.
+    // Stock-photo hosts (Unsplash/Pexels/etc.) are intentionally NOT listed —
+    // per Steve's "zero stock images" rule for the WhoLivedThere ecosystem.
+    remotePatterns: [
+      // Public-domain image sources (pd_film_image)
+      { protocol: 'https', hostname: 'upload.wikimedia.org' },
+      { protocol: 'https', hostname: 'commons.wikimedia.org' },
+      { protocol: 'https', hostname: 'archive.org' },
+      { protocol: 'https', hostname: '*.archive.org' },
+      { protocol: 'https', hostname: 'tile.loc.gov' },
+      { protocol: 'https', hostname: 'www.loc.gov' },
+      { protocol: 'https', hostname: 'cdn.loc.gov' },
+      { protocol: 'https', hostname: 'lantern.mediahist.org' },
+      { protocol: 'https', hostname: 'ids.si.edu' },
+      { protocol: 'https', hostname: 'images.metmuseum.org' },
+      { protocol: 'https', hostname: 'collectionapi.metmuseum.org' },
+      { protocol: 'https', hostname: 'live.staticflickr.com' },
+      { protocol: 'https', hostname: '*.flickr.com' },
+      { protocol: 'https', hostname: 'api.openverse.org' },
+      { protocol: 'https', hostname: '*.openverse.engineering' },
+      { protocol: 'https', hostname: 'images.nypl.org' },
+      { protocol: 'https', hostname: 'digitalcollections.nypl.org' },
+      // Listing/legacy sources
+      { protocol: 'https', hostname: '*.muscache.com' },
+      { protocol: 'https', hostname: '*.airbnb.com' },
+    ],
+    // Lower default deviceSizes since we're rendering many small grid cells
+    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
+    deviceSizes: [320, 480, 640, 750, 828, 1080, 1200, 1440],
+  },
+};
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ae9e480
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2573 @@
+{
+  "name": "stayclaim",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "stayclaim",
+      "version": "0.1.0",
+      "dependencies": {
+        "@types/leaflet": "^1.9.21",
+        "leaflet": "^1.9.4",
+        "next": "14.2.15",
+        "pg": "^8.12.0",
+        "react": "^18",
+        "react-dom": "^18",
+        "react-leaflet": "^4.2.1",
+        "sharp": "^0.34.5",
+        "slugify": "^1.6.6",
+        "zod": "^3.23.8"
+      },
+      "devDependencies": {
+        "@types/node": "^20",
+        "@types/pg": "^8.11.10",
+        "@types/react": "^18",
+        "@types/react-dom": "^18",
+        "autoprefixer": "^10.4.20",
+        "postcss": "^8.4.49",
+        "tailwindcss": "^3.4.13",
+        "ts-node": "^10.9.2",
+        "typescript": "^5"
+      }
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@cspotcode/source-map-support": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+      "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "0.3.9"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.9",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+      "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@next/env": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz",
+      "integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz",
+      "integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz",
+      "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz",
+      "integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz",
+      "integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz",
+      "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz",
+      "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz",
+      "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-ia32-msvc": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz",
+      "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz",
+      "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@react-leaflet/core": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
+      "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
+      "license": "Hippocratic-2.1",
+      "peerDependencies": {
+        "leaflet": "^1.9.0",
+        "react": "^18.0.0",
+        "react-dom": "^18.0.0"
+      }
+    },
+    "node_modules/@swc/counter": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+      "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
+      "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@swc/counter": "^0.1.3",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tsconfig/node10": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
+      "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node12": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+      "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node14": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+      "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node16": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+      "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/leaflet": {
+      "version": "1.9.21",
+      "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+      "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.39",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
+      "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.15",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "18.3.28",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+      "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/prop-types": "*",
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "18.3.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^18.0.0"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.16.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-walk": {
+      "version": "8.3.5",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+      "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.11.0"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/any-promise": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/arg": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+      "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+      "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.28.2",
+        "caniuse-lite": "^1.0.30001787",
+        "fraction.js": "^5.3.4",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.21",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
+      "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "node_modules/camelcase-css": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001790",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
+      "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/client-only": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+      "license": "MIT"
+    },
+    "node_modules/commander": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/create-require": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+      "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/didyoumean": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+      "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/diff": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
+      "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.344",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+      "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fastq": {
+      "version": "1.20.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "1.21.7",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+      "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "bin/jiti.js"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/leaflet": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+      "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/lilconfig": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+      "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antonk52"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/make-error": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+      "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mz": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "any-promise": "^1.0.0",
+        "object-assign": "^4.0.1",
+        "thenify-all": "^1.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/next": {
+      "version": "14.2.15",
+      "resolved": "https://registry.npmjs.org/next/-/next-14.2.15.tgz",
+      "integrity": "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==",
+      "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "14.2.15",
+        "@swc/helpers": "0.5.5",
+        "busboy": "1.6.0",
+        "caniuse-lite": "^1.0.30001579",
+        "graceful-fs": "^4.2.11",
+        "postcss": "8.4.31",
+        "styled-jsx": "5.1.1"
+      },
+      "bin": {
+        "next": "dist/bin/next"
+      },
+      "engines": {
+        "node": ">=18.17.0"
+      },
+      "optionalDependencies": {
+        "@next/swc-darwin-arm64": "14.2.15",
+        "@next/swc-darwin-x64": "14.2.15",
+        "@next/swc-linux-arm64-gnu": "14.2.15",
+        "@next/swc-linux-arm64-musl": "14.2.15",
+        "@next/swc-linux-x64-gnu": "14.2.15",
+        "@next/swc-linux-x64-musl": "14.2.15",
+        "@next/swc-win32-arm64-msvc": "14.2.15",
+        "@next/swc-win32-ia32-msvc": "14.2.15",
+        "@next/swc-win32-x64-msvc": "14.2.15"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.1.0",
+        "@playwright/test": "^1.41.2",
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0",
+        "sass": "^1.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@playwright/test": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/next/node_modules/postcss": {
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.6",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.38",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+      "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-hash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+      "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.12.0",
+        "pg-pool": "^3.13.0",
+        "pg-protocol": "^1.13.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.3.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+      "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+      "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.13.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+      "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+      "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pirates": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.10",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+      "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-import": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+      "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "postcss-value-parser": "^4.0.0",
+        "read-cache": "^1.0.0",
+        "resolve": "^1.1.7"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.0.0"
+      }
+    },
+    "node_modules/postcss-js": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+      "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "camelcase-css": "^2.0.1"
+      },
+      "engines": {
+        "node": "^12 || ^14 || >= 16"
+      },
+      "peerDependencies": {
+        "postcss": "^8.4.21"
+      }
+    },
+    "node_modules/postcss-load-config": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+      "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "lilconfig": "^3.1.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "jiti": ">=1.21.0",
+        "postcss": ">=8.0.9",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        },
+        "postcss": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss-nested": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+      "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "postcss-selector-parser": "^6.1.1"
+      },
+      "engines": {
+        "node": ">=12.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.2.14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+      "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-leaflet": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
+      "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
+      "license": "Hippocratic-2.1",
+      "dependencies": {
+        "@react-leaflet/core": "^2.1.0"
+      },
+      "peerDependencies": {
+        "leaflet": "^1.9.0",
+        "react": "^18.0.0",
+        "react-dom": "^18.0.0"
+      }
+    },
+    "node_modules/read-cache": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+      "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "pify": "^2.3.0"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.12",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+      "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "is-core-module": "^2.16.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/slugify": {
+      "version": "1.6.9",
+      "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
+      "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/styled-jsx": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
+      "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+      "license": "MIT",
+      "dependencies": {
+        "client-only": "0.0.1"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/sucrase": {
+      "version": "3.35.1",
+      "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+      "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "commander": "^4.0.0",
+        "lines-and-columns": "^1.1.6",
+        "mz": "^2.7.0",
+        "pirates": "^4.0.1",
+        "tinyglobby": "^0.2.11",
+        "ts-interface-checker": "^0.1.9"
+      },
+      "bin": {
+        "sucrase": "bin/sucrase",
+        "sucrase-node": "bin/sucrase-node"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "3.4.19",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+      "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "arg": "^5.0.2",
+        "chokidar": "^3.6.0",
+        "didyoumean": "^1.2.2",
+        "dlv": "^1.1.3",
+        "fast-glob": "^3.3.2",
+        "glob-parent": "^6.0.2",
+        "is-glob": "^4.0.3",
+        "jiti": "^1.21.7",
+        "lilconfig": "^3.1.3",
+        "micromatch": "^4.0.8",
+        "normalize-path": "^3.0.0",
+        "object-hash": "^3.0.0",
+        "picocolors": "^1.1.1",
+        "postcss": "^8.4.47",
+        "postcss-import": "^15.1.0",
+        "postcss-js": "^4.0.1",
+        "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+        "postcss-nested": "^6.2.0",
+        "postcss-selector-parser": "^6.1.2",
+        "resolve": "^1.22.8",
+        "sucrase": "^3.35.0"
+      },
+      "bin": {
+        "tailwind": "lib/cli.js",
+        "tailwindcss": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/thenify": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "any-promise": "^1.0.0"
+      }
+    },
+    "node_modules/thenify-all": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "thenify": ">= 3.1.0 < 4"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.16",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+      "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinyglobby/node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tinyglobby/node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-interface-checker": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/ts-node": {
+      "version": "10.9.2",
+      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+      "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cspotcode/source-map-support": "^0.8.0",
+        "@tsconfig/node10": "^1.0.7",
+        "@tsconfig/node12": "^1.0.7",
+        "@tsconfig/node14": "^1.0.0",
+        "@tsconfig/node16": "^1.0.2",
+        "acorn": "^8.4.1",
+        "acorn-walk": "^8.1.1",
+        "arg": "^4.1.0",
+        "create-require": "^1.1.0",
+        "diff": "^4.0.1",
+        "make-error": "^1.1.1",
+        "v8-compile-cache-lib": "^3.0.1",
+        "yn": "3.1.1"
+      },
+      "bin": {
+        "ts-node": "dist/bin.js",
+        "ts-node-cwd": "dist/bin-cwd.js",
+        "ts-node-esm": "dist/bin-esm.js",
+        "ts-node-script": "dist/bin-script.js",
+        "ts-node-transpile-only": "dist/bin-transpile.js",
+        "ts-script": "dist/bin-script-deprecated.js"
+      },
+      "peerDependencies": {
+        "@swc/core": ">=1.2.50",
+        "@swc/wasm": ">=1.2.50",
+        "@types/node": "*",
+        "typescript": ">=2.7"
+      },
+      "peerDependenciesMeta": {
+        "@swc/core": {
+          "optional": true
+        },
+        "@swc/wasm": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ts-node/node_modules/arg": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+      "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/v8-compile-cache-lib": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+      "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
+    "node_modules/yn": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+      "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/zod": {
+      "version": "3.25.76",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b1bcfa2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,38 @@
+{
+  "name": "stayclaim",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev -p 9821 -H 0.0.0.0",
+    "build": "next build",
+    "start": "next start -p 9821 -H 0.0.0.0",
+    "lint": "next lint",
+    "db:init": "psql -d stayclaim -f db/schema.sql && bash -c 'for m in db/migrations/*.sql; do echo \"== $m ==\"; psql -d stayclaim -f \"$m\" || exit 1; done'",
+    "db:migrate": "bash -c 'for m in db/migrations/*.sql; do echo \"== $m ==\"; psql -d stayclaim -f \"$m\" || exit 1; done'",
+    "db:seed": "psql -d stayclaim -f db/seed.sql",
+    "ingest:airbnb": "node -r ts-node/register scripts/ingest-airbnb.ts"
+  },
+  "dependencies": {
+    "@types/leaflet": "^1.9.21",
+    "leaflet": "^1.9.4",
+    "next": "14.2.15",
+    "pg": "^8.12.0",
+    "react": "^18",
+    "react-dom": "^18",
+    "react-leaflet": "^4.2.1",
+    "sharp": "^0.34.5",
+    "slugify": "^1.6.6",
+    "zod": "^3.23.8"
+  },
+  "devDependencies": {
+    "@types/node": "^20",
+    "@types/pg": "^8.11.10",
+    "@types/react": "^18",
+    "@types/react-dom": "^18",
+    "autoprefixer": "^10.4.20",
+    "postcss": "^8.4.49",
+    "tailwindcss": "^3.4.13",
+    "ts-node": "^10.9.2",
+    "typescript": "^5"
+  }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..cce4985
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1 @@
+module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
diff --git a/scripts/enrich-entities-wikidata.ts b/scripts/enrich-entities-wikidata.ts
new file mode 100644
index 0000000..8de071e
--- /dev/null
+++ b/scripts/enrich-entities-wikidata.ts
@@ -0,0 +1,146 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Wikidata enrichment for entity rows.
+ *
+ * For each entity missing wiki_url / birth_year / death_year, queries the
+ * Wikidata SPARQL endpoint to find the Q-ID by exact label match (English),
+ * then pulls birth date (P569), death date (P570), occupation (P106), and
+ * the entity's English Wikipedia URL via sitelinks.
+ *
+ * Wikidata is a Tier-C source. Updates carry confidence 0.7 and only
+ * fill empty fields — never overwrite existing data.
+ *
+ * Usage:
+ *   npx tsx scripts/enrich-entities-wikidata.ts
+ *   npx tsx scripts/enrich-entities-wikidata.ts --slug paul-r-williams
+ *   npx tsx scripts/enrich-entities-wikidata.ts --kind architect
+ *   npx tsx scripts/enrich-entities-wikidata.ts --dry
+ */
+import { Pool } from 'pg';
+
+type WdRow = {
+  item: { value: string };
+  itemLabel: { value: string };
+  birth?: { value: string };
+  death?: { value: string };
+  wiki?: { value: string };
+};
+
+const SPARQL = `
+SELECT ?item ?itemLabel ?birth ?death ?wiki WHERE {
+  ?item rdfs:label "%LABEL%"@en.
+  OPTIONAL { ?item wdt:P569 ?birth. }
+  OPTIONAL { ?item wdt:P570 ?death. }
+  OPTIONAL {
+    ?wiki schema:about ?item;
+          schema:isPartOf <https://en.wikipedia.org/>.
+  }
+  SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 5
+`;
+
+async function queryWikidata(name: string): Promise<WdRow | null> {
+  const sparql = SPARQL.replace('%LABEL%', name.replace(/"/g, '\\"'));
+  const url = 'https://query.wikidata.org/sparql?format=json&query=' + encodeURIComponent(sparql);
+  const res = await fetch(url, {
+    headers: {
+      Accept: 'application/sparql-results+json',
+      'User-Agent': 'pastdoor/0.1 (https://pastdoor.com; data-enrichment)',
+    },
+  });
+  if (!res.ok) throw new Error(`Wikidata ${res.status}`);
+  const j = (await res.json()) as { results: { bindings: WdRow[] } };
+  return j.results.bindings[0] ?? null;
+}
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  max: 4,
+});
+
+async function main() {
+  const args = process.argv.slice(2);
+  const slugArg = args.find((a, i) => args[i - 1] === '--slug');
+  const kindArg = args.find((a, i) => args[i - 1] === '--kind');
+  const dry = args.includes('--dry');
+
+  const conds: string[] = [];
+  const params: unknown[] = [];
+  if (slugArg) {
+    params.push(slugArg);
+    conds.push(`slug = $${params.length}`);
+  }
+  if (kindArg) {
+    params.push(kindArg);
+    conds.push(`kind = $${params.length}`);
+  }
+  const sql = `SELECT id, slug, display_name, kind, birth_year, death_year, wiki_url
+               FROM entity
+               ${conds.length ? 'WHERE ' + conds.join(' AND ') : ''}
+               ORDER BY display_name`;
+  const { rows } = await pool.query(sql, params);
+
+  let queried = 0;
+  let updated = 0;
+  let nohit = 0;
+  for (const e of rows) {
+    queried++;
+    let hit: WdRow | null = null;
+    try {
+      hit = await queryWikidata(e.display_name);
+    } catch (err) {
+      console.warn(`[${e.slug}] wikidata error: ${(err as Error).message}`);
+    }
+    if (!hit) {
+      nohit++;
+      console.log(`[${e.slug}] no wikidata hit`);
+      await new Promise(r => setTimeout(r, 600));
+      continue;
+    }
+    const updates: Record<string, unknown> = {};
+    if (!e.birth_year && hit.birth?.value) {
+      const y = Number(hit.birth.value.slice(0, 4));
+      if (Number.isFinite(y)) updates.birth_year = y;
+    }
+    if (!e.death_year && hit.death?.value) {
+      const y = Number(hit.death.value.slice(0, 4));
+      if (Number.isFinite(y)) updates.death_year = y;
+    }
+    if (!e.wiki_url && hit.wiki?.value) updates.wiki_url = hit.wiki.value;
+
+    if (Object.keys(updates).length === 0) {
+      console.log(`[${e.slug}] up to date`);
+      await new Promise(r => setTimeout(r, 400));
+      continue;
+    }
+    if (dry) {
+      console.log(`[${e.slug}] DRY would set`, updates);
+    } else {
+      const setParts: string[] = [];
+      const vals: unknown[] = [];
+      for (const [k, v] of Object.entries(updates)) {
+        vals.push(v);
+        setParts.push(`${k} = $${vals.length}`);
+      }
+      vals.push(e.id);
+      await pool.query(
+        `UPDATE entity SET ${setParts.join(', ')} WHERE id = $${vals.length}`,
+        vals
+      );
+      console.log(`[${e.slug}] updated`, updates);
+      updated++;
+    }
+    await new Promise(r => setTimeout(r, 600)); // be a polite Wikidata client
+  }
+
+  console.log(JSON.stringify({ queried, updated, no_match: nohit, dry }, null, 2));
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error(e);
+  process.exit(1);
+});
diff --git a/scripts/enrich-entities-wikipedia.ts b/scripts/enrich-entities-wikipedia.ts
new file mode 100644
index 0000000..6c40e4f
--- /dev/null
+++ b/scripts/enrich-entities-wikipedia.ts
@@ -0,0 +1,105 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Wikipedia summary enrichment.
+ *
+ * For each entity that already has wiki_url but is missing bio_short,
+ * pulls the page summary via Wikipedia's REST API and stores the extract.
+ * Tier-C source.
+ *
+ * Usage:
+ *   npx tsx scripts/enrich-entities-wikipedia.ts
+ *   npx tsx scripts/enrich-entities-wikipedia.ts --slug paul-r-williams
+ *   npx tsx scripts/enrich-entities-wikipedia.ts --force   # overwrite existing bio
+ *   npx tsx scripts/enrich-entities-wikipedia.ts --dry
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  max: 4,
+});
+
+function pageTitleFromUrl(url: string): string | null {
+  try {
+    const u = new URL(url);
+    if (!u.hostname.endsWith('wikipedia.org')) return null;
+    // /wiki/Foo_Bar  →  Foo_Bar
+    const m = u.pathname.match(/^\/wiki\/(.+)$/);
+    return m ? decodeURIComponent(m[1]!) : null;
+  } catch {
+    return null;
+  }
+}
+
+async function fetchSummary(title: string): Promise<{ extract: string } | null> {
+  const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
+  const res = await fetch(url, {
+    headers: {
+      'User-Agent': 'pastdoor/0.1 (https://pastdoor.com; data-enrichment)',
+      Accept: 'application/json',
+    },
+  });
+  if (!res.ok) return null;
+  const j = (await res.json()) as { extract?: string; type?: string };
+  if (!j.extract) return null;
+  return { extract: j.extract };
+}
+
+async function main() {
+  const args = process.argv.slice(2);
+  const slugArg = args.find((a, i) => args[i - 1] === '--slug');
+  const dry = args.includes('--dry');
+  const force = args.includes('--force');
+
+  const conds: string[] = ['wiki_url IS NOT NULL'];
+  const params: unknown[] = [];
+  if (!force) conds.push("(bio_short IS NULL OR bio_short = '')");
+  if (slugArg) {
+    params.push(slugArg);
+    conds.push(`slug = $${params.length}`);
+  }
+  const sql = `SELECT id, slug, display_name, wiki_url, bio_short
+               FROM entity
+               WHERE ${conds.join(' AND ')}
+               ORDER BY display_name`;
+  const { rows } = await pool.query(sql, params);
+
+  let updated = 0;
+  let nohit = 0;
+  for (const e of rows) {
+    const title = pageTitleFromUrl(e.wiki_url);
+    if (!title) {
+      console.log(`[${e.slug}] no usable Wikipedia title in ${e.wiki_url}`);
+      continue;
+    }
+    let summary;
+    try {
+      summary = await fetchSummary(title);
+    } catch (err) {
+      console.warn(`[${e.slug}] fetch error: ${(err as Error).message}`);
+      continue;
+    }
+    if (!summary) {
+      nohit++;
+      console.log(`[${e.slug}] no Wikipedia summary`);
+      await new Promise(r => setTimeout(r, 400));
+      continue;
+    }
+    // Trim to a tight editorial blurb — first 1-2 sentences, max ~280 chars.
+    const tight = summary.extract.split(/(?<=\.)\s+/).slice(0, 2).join(' ').slice(0, 280);
+    if (dry) {
+      console.log(`[${e.slug}] DRY would set bio_short:`, tight);
+    } else {
+      await pool.query('UPDATE entity SET bio_short = $1 WHERE id = $2', [tight, e.id]);
+      console.log(`[${e.slug}] updated`);
+      updated++;
+    }
+    await new Promise(r => setTimeout(r, 600));
+  }
+  console.log(JSON.stringify({ checked: rows.length, updated, no_summary: nohit, dry }, null, 2));
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/enrich-productions-wikidata.ts b/scripts/enrich-productions-wikidata.ts
new file mode 100644
index 0000000..a71ffad
--- /dev/null
+++ b/scripts/enrich-productions-wikidata.ts
@@ -0,0 +1,149 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Wikidata enrichment for film_production rows.
+ *
+ * For each production missing wikidata_qid / imdb_id / blurb, queries the
+ * Wikidata SPARQL endpoint to find the matching item (filtered by P31 to
+ * a film/TV-series subclass), then pulls IMDb ID (P345), Wikipedia URL,
+ * and falls back to a Wikipedia summary for the blurb.
+ *
+ * Tier-C source. Updates only fill empty fields.
+ *
+ * Usage:
+ *   npx tsx scripts/enrich-productions-wikidata.ts
+ *   npx tsx scripts/enrich-productions-wikidata.ts --slug mulholland-echoes
+ *   npx tsx scripts/enrich-productions-wikidata.ts --dry
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  max: 4,
+});
+
+type WdRow = {
+  item: { value: string };
+  itemLabel: { value: string };
+  imdb?: { value: string };
+  wiki?: { value: string };
+};
+
+async function querySparql(name: string): Promise<WdRow | null> {
+  const sparql = `
+SELECT ?item ?itemLabel ?imdb ?wiki WHERE {
+  ?item rdfs:label "${name.replace(/"/g, '\\"')}"@en.
+  VALUES ?type { wd:Q11424 wd:Q5398426 wd:Q24856 wd:Q24862 wd:Q21191270 }
+  ?item wdt:P31 ?type.
+  OPTIONAL { ?item wdt:P345 ?imdb. }
+  OPTIONAL {
+    ?wiki schema:about ?item;
+          schema:isPartOf <https://en.wikipedia.org/>.
+  }
+  SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 3
+`;
+  const url = 'https://query.wikidata.org/sparql?format=json&query=' + encodeURIComponent(sparql);
+  const res = await fetch(url, {
+    headers: {
+      Accept: 'application/sparql-results+json',
+      'User-Agent': 'pastdoor/0.1 (https://pastdoor.com)',
+    },
+  });
+  if (!res.ok) throw new Error(`Wikidata ${res.status}`);
+  const j = (await res.json()) as { results: { bindings: WdRow[] } };
+  return j.results.bindings[0] ?? null;
+}
+
+async function fetchWikiSummary(title: string): Promise<string | null> {
+  const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
+  const res = await fetch(url, {
+    headers: { 'User-Agent': 'pastdoor/0.1', Accept: 'application/json' },
+  });
+  if (!res.ok) return null;
+  const j = (await res.json()) as { extract?: string };
+  if (!j.extract) return null;
+  return j.extract.split(/(?<=\.)\s+/).slice(0, 2).join(' ').slice(0, 280);
+}
+
+function pageTitleFromUrl(url: string): string | null {
+  try {
+    const u = new URL(url);
+    if (!u.hostname.endsWith('wikipedia.org')) return null;
+    const m = u.pathname.match(/^\/wiki\/(.+)$/);
+    return m ? decodeURIComponent(m[1]!) : null;
+  } catch {
+    return null;
+  }
+}
+
+async function main() {
+  const args = process.argv.slice(2);
+  const slugArg = args.find((a, i) => args[i - 1] === '--slug');
+  const dry = args.includes('--dry');
+
+  const conds: string[] = [];
+  const params: unknown[] = [];
+  if (slugArg) {
+    params.push(slugArg);
+    conds.push(`slug = $${params.length}`);
+  }
+  const sql = `SELECT id, slug, title, year, wikidata_qid, imdb_id, blurb
+               FROM film_production
+               ${conds.length ? 'WHERE ' + conds.join(' AND ') : ''}
+               ORDER BY title`;
+  const { rows } = await pool.query(sql, params);
+
+  let updated = 0;
+  let nohit = 0;
+  for (const p of rows) {
+    let wd;
+    try {
+      wd = await querySparql(p.title);
+    } catch (e) {
+      console.warn(`[${p.slug}] sparql err: ${(e as Error).message}`);
+    }
+    const updates: Record<string, unknown> = {};
+    if (!p.wikidata_qid && wd?.item?.value) {
+      const m = wd.item.value.match(/\/(Q\d+)$/);
+      if (m) updates.wikidata_qid = m[1];
+    }
+    if (!p.imdb_id && wd?.imdb?.value) updates.imdb_id = wd.imdb.value;
+    if (!p.blurb && wd?.wiki?.value) {
+      const title = pageTitleFromUrl(wd.wiki.value);
+      if (title) {
+        const summary = await fetchWikiSummary(title);
+        if (summary) updates.blurb = summary;
+      }
+    }
+
+    if (Object.keys(updates).length === 0) {
+      console.log(`[${p.slug}] nothing to add`);
+      if (!wd) nohit++;
+      await new Promise(r => setTimeout(r, 600));
+      continue;
+    }
+    if (dry) {
+      console.log(`[${p.slug}] DRY would set`, updates);
+    } else {
+      const setParts: string[] = [];
+      const vals: unknown[] = [];
+      for (const [k, v] of Object.entries(updates)) {
+        vals.push(v);
+        setParts.push(`${k} = $${vals.length}`);
+      }
+      vals.push(p.id);
+      await pool.query(`UPDATE film_production SET ${setParts.join(', ')} WHERE id = $${vals.length}`, vals);
+      console.log(`[${p.slug}] updated`, updates);
+      updated++;
+    }
+    await new Promise(r => setTimeout(r, 600));
+  }
+
+  console.log(JSON.stringify({ checked: rows.length, updated, no_match: nohit, dry }, null, 2));
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/extract-news-addresses.ts b/scripts/extract-news-addresses.ts
new file mode 100644
index 0000000..81bd63d
--- /dev/null
+++ b/scripts/extract-news-addresses.ts
@@ -0,0 +1,294 @@
+/**
+ * extract-news-addresses.ts
+ *
+ * Phase 3 of the LA newspaper ingest. Sweeps OCR'd issues in news_issue for
+ * US street-address patterns, captures a context snippet around each match,
+ * and tries to match each one to a row in `listing` (by normalized
+ * street_number + street_name, restricted to LA-county cities to suppress
+ * false positives).
+ *
+ * Idempotent at the issue level — re-running on the same issue wipes its
+ * prior mentions and re-extracts. Sets news_issue.addresses_extracted_at on
+ * success so subsequent runs skip already-processed issues.
+ *
+ * Designed to run in parallel with the Phase 2 OCR puller — only reads
+ * news_issue (no UPDATE conflict) and writes to a separate table.
+ *
+ * Usage: npx tsx scripts/extract-news-addresses.ts [--paper=LADailyHerald]
+ *                                                  [--rerun]
+ *                                                  [--limit=100]
+ *
+ * Tier: A (gov-tier source-of-source, but address-mention itself is a
+ * derived/inferred fact — pin Tier B downstream when displaying.)
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+const args = Object.fromEntries(
+  process.argv.slice(2).map(a => {
+    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+    return m ? [m[1], m[2] ?? 'true'] : [a, 'true'];
+  })
+);
+const PAPER  = args.paper as string | undefined;
+const RERUN  = args.rerun === 'true';
+const LIMIT  = args.limit ? parseInt(args.limit as string, 10) : undefined;
+
+// LA-area cities — used to filter listing matches and reduce false positives
+// when a number+street happens to match an address in another state.
+const LA_CITIES = [
+  'Los Angeles', 'West Hollywood', 'Beverly Hills', 'Santa Monica',
+  'Pasadena', 'Culver City', 'Long Beach', 'Burbank', 'Glendale',
+  'Hollywood', 'Venice', 'Inglewood', 'Compton', 'Pomona',
+  'Manhattan Beach', 'Hermosa Beach', 'Redondo Beach', 'El Segundo',
+  'Torrance', 'San Pedro', 'Wilmington', 'Watts', 'South Gate',
+  'Huntington Park', 'Whittier', 'Anaheim', 'Santa Ana',
+];
+
+/** Suffix → normalized abbrev (used in both extraction normalization and listing match). */
+const SUFFIX_MAP: Record<string, string> = {
+  street: 'st', st: 'st',
+  avenue: 'ave', ave: 'ave',
+  boulevard: 'blvd', blvd: 'blvd',
+  drive: 'dr', dr: 'dr',
+  road: 'rd', rd: 'rd',
+  way: 'way',
+  place: 'pl', pl: 'pl',
+  court: 'ct', ct: 'ct',
+  lane: 'ln', ln: 'ln',
+  highway: 'hwy', hwy: 'hwy',
+  parkway: 'pkwy', pkwy: 'pkwy',
+  circle: 'cir', cir: 'cir',
+  terrace: 'ter', ter: 'ter',
+  square: 'sq', sq: 'sq',
+};
+
+const SUFFIX_RE = Object.keys(SUFFIX_MAP).join('|');
+
+// US street-address regex — case insensitive. Captures: number, optional dir, name, suffix.
+// Requires capitalized first letter on the street name to suppress sentence-noise matches.
+// Allows 1-4 word street names (e.g., "Wilshire", "San Vicente", "South La Brea", "Avenue of the Stars").
+const ADDR_RE = new RegExp(
+  String.raw`\b(\d{2,5})` +                                              // number
+  String.raw`\s+(N\.?|S\.?|E\.?|W\.?|North|South|East|West)?\s*` +        // optional dir
+  String.raw`([A-Z][A-Za-z'.\-]+(?:\s+(?:de\s+|of\s+|the\s+|la\s+|las\s+|los\s+)?[A-Z][A-Za-z'.\-]+){0,3})` + // 1-4 cap words
+  String.raw`\s+(${SUFFIX_RE})\.?` +                                     // suffix
+  String.raw`\b`,
+  'g'
+);
+
+function normalizeStreetName(name: string): string {
+  return name
+    .toLowerCase()
+    .replace(/\./g, '')
+    .replace(/[^a-z0-9\s]/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function normalizeSuffix(suffix: string): string {
+  return SUFFIX_MAP[suffix.toLowerCase().replace(/\./g, '')] ?? suffix.toLowerCase();
+}
+
+function normalizeListingAddr(line1: string | null): { number: string; street: string } | null {
+  if (!line1) return null;
+  // Try to split into number + rest
+  const m = line1.trim().match(/^(\d{1,5})\s+(.+)$/);
+  if (!m) return null;
+  const number = m[1];
+  const rest = m[2];
+  // Find suffix at end
+  const suffixMatch = rest.match(new RegExp(String.raw`^(.+?)\s+(${SUFFIX_RE})\.?\s*$`, 'i'));
+  if (!suffixMatch) {
+    return { number, street: normalizeStreetName(rest) };
+  }
+  const namePart = suffixMatch[1];
+  const suffixNorm = normalizeSuffix(suffixMatch[2]);
+  return { number, street: `${normalizeStreetName(namePart)} ${suffixNorm}`.trim() };
+}
+
+type Mention = {
+  raw: string;
+  number: string;
+  street: string;          // normalized name + suffix
+  context: string;
+  offset: number;
+};
+
+function extractMentions(text: string): Mention[] {
+  if (!text || text.length < 20) return [];
+  const found: Mention[] = [];
+  const seen = new Set<number>();
+  let m: RegExpExecArray | null;
+  ADDR_RE.lastIndex = 0;
+  while ((m = ADDR_RE.exec(text)) !== null) {
+    if (seen.has(m.index)) continue;
+    seen.add(m.index);
+    const [raw, number, dir, namePart, suffix] = m;
+    // Skip year-looking 4-digit numbers (1880, 1925) followed by month/day patterns
+    if (/^(18|19|20)\d{2}$/.test(number) && /^[A-Z][a-z]+ ?(?:\d|St|Ave|Blvd)/.test(text.slice(m.index + raw.length, m.index + raw.length + 30))) {
+      // probably a date "1925 March 15 Avenue ..." — skip
+      continue;
+    }
+    const dirNorm = dir ? dir[0].toUpperCase() + ' ' : '';
+    const streetNormalized = `${normalizeStreetName(namePart)} ${normalizeSuffix(suffix)}`.trim();
+    if (!streetNormalized || streetNormalized.length < 3) continue;
+    const start = Math.max(0, m.index - 80);
+    const end = Math.min(text.length, m.index + raw.length + 80);
+    const context = text.slice(start, end).replace(/\s+/g, ' ').trim();
+    found.push({
+      raw: raw.trim(),
+      number,
+      street: dirNorm ? `${dirNorm.trim().toLowerCase()} ${streetNormalized}` : streetNormalized,
+      context,
+      offset: m.index,
+    });
+  }
+  return found;
+}
+
+async function ensureSchema() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS news_address_mention (
+      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+      issue_id UUID NOT NULL REFERENCES news_issue(id) ON DELETE CASCADE,
+      listing_id UUID REFERENCES listing(id),
+      raw_address TEXT NOT NULL,
+      street_number TEXT NOT NULL,
+      street_name_normalized TEXT NOT NULL,
+      context TEXT,
+      char_offset INT NOT NULL,
+      match_method TEXT,
+      confidence NUMERIC,
+      extracted_at TIMESTAMPTZ DEFAULT now(),
+      UNIQUE (issue_id, char_offset)
+    );
+    CREATE INDEX IF NOT EXISTS idx_news_addr_issue ON news_address_mention(issue_id);
+    CREATE INDEX IF NOT EXISTS idx_news_addr_listing ON news_address_mention(listing_id) WHERE listing_id IS NOT NULL;
+    CREATE INDEX IF NOT EXISTS idx_news_addr_lookup ON news_address_mention(street_number, street_name_normalized);
+    ALTER TABLE news_issue ADD COLUMN IF NOT EXISTS addresses_extracted_at TIMESTAMPTZ;
+    CREATE INDEX IF NOT EXISTS idx_news_issue_extract_pending ON news_issue(paper_short)
+      WHERE ocr_text IS NOT NULL AND ocr_text <> '' AND addresses_extracted_at IS NULL;
+  `);
+  console.log('schema OK');
+}
+
+async function loadListingIndex(): Promise<Map<string, string>> {
+  // street_number+''+normalized_street → listing_id
+  const map = new Map<string, string>();
+  const { rows } = await pool.query<{ id: string; address_line1: string | null; city: string | null }>(
+    `SELECT id, address_line1, city FROM listing
+     WHERE address_line1 IS NOT NULL
+       AND ($1::text[] IS NULL OR city = ANY($1::text[]))`,
+    [LA_CITIES]
+  );
+  for (const r of rows) {
+    const norm = normalizeListingAddr(r.address_line1);
+    if (!norm) continue;
+    map.set(`${norm.number}${norm.street}`, r.id);
+  }
+  console.log(`loaded ${map.size} listings into match index`);
+  return map;
+}
+
+async function processIssue(
+  issue: { id: string; ocr_text: string },
+  index: Map<string, string>
+): Promise<{ found: number; matched: number }> {
+  const mentions = extractMentions(issue.ocr_text);
+  if (mentions.length === 0) {
+    await pool.query(`UPDATE news_issue SET addresses_extracted_at = now() WHERE id = $1`, [issue.id]);
+    return { found: 0, matched: 0 };
+  }
+  // wipe prior (idempotent rerun)
+  await pool.query(`DELETE FROM news_address_mention WHERE issue_id = $1`, [issue.id]);
+
+  let matched = 0;
+  // Bulk insert
+  const tuples: string[] = [];
+  const values: any[] = [];
+  mentions.forEach((mn, i) => {
+    const key = `${mn.number}${mn.street}`;
+    const lid = index.get(key) ?? null;
+    if (lid) matched++;
+    const base = i * 8;
+    tuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8})`);
+    values.push(
+      issue.id,
+      lid,
+      mn.raw,
+      mn.number,
+      mn.street,
+      mn.context,
+      mn.offset,
+      lid ? 'exact_street' : 'unmatched'
+    );
+  });
+  await pool.query(
+    `INSERT INTO news_address_mention
+       (issue_id, listing_id, raw_address, street_number, street_name_normalized, context, char_offset, match_method)
+     VALUES ${tuples.join(',')}
+     ON CONFLICT (issue_id, char_offset) DO NOTHING`,
+    values
+  );
+  await pool.query(`UPDATE news_issue SET addresses_extracted_at = now() WHERE id = $1`, [issue.id]);
+  return { found: mentions.length, matched };
+}
+
+async function main() {
+  await ensureSchema();
+  const index = await loadListingIndex();
+
+  const params: any[] = [];
+  let where = `ocr_text IS NOT NULL AND ocr_text <> ''`;
+  if (!RERUN) where += ` AND addresses_extracted_at IS NULL`;
+  if (PAPER) { where += ` AND paper_short = $${params.length+1}`; params.push(PAPER); }
+  let sql = `SELECT id, ocr_text FROM news_issue WHERE ${where} ORDER BY issue_date NULLS LAST`;
+  if (LIMIT) sql += ` LIMIT ${LIMIT}`;
+
+  const { rows } = await pool.query<{ id: string; ocr_text: string }>(sql, params);
+  console.log(`extract-news-addresses  paper=${PAPER ?? '(all)'}  rerun=${RERUN}  queue=${rows.length}`);
+  if (rows.length === 0) {
+    await pool.end();
+    return;
+  }
+  const t0 = Date.now();
+  let totalFound = 0, totalMatched = 0, processed = 0;
+  for (const issue of rows) {
+    const { found, matched } = await processIssue(issue, index);
+    totalFound += found;
+    totalMatched += matched;
+    processed++;
+    if (processed % 50 === 0) {
+      const dt = (Date.now() - t0) / 1000;
+      process.stdout.write(`  ${processed}/${rows.length} processed — ${totalFound} mentions, ${totalMatched} matched — ${(processed/dt).toFixed(1)}/s\r`);
+    }
+  }
+  const dt = (Date.now() - t0) / 1000;
+  console.log('');
+  console.log('--- done ---');
+  console.log(`  issues=${processed}  mentions=${totalFound}  listing-matched=${totalMatched}  elapsed=${dt.toFixed(1)}s`);
+  // Top 10 unmatched streets to identify gaps
+  const { rows: topUnmatched } = await pool.query<{ street: string; n: string }>(
+    `SELECT street_name_normalized as street, count(*)::text as n
+     FROM news_address_mention WHERE listing_id IS NULL
+     GROUP BY street_name_normalized
+     ORDER BY count(*) DESC LIMIT 10`
+  );
+  console.log('  top 10 unmatched street names (listings to add for coverage):');
+  for (const r of topUnmatched) console.log(`    ${r.n.padStart(6)}  ${r.street}`);
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error('FATAL', e);
+  process.exit(1);
+});
diff --git a/scripts/fetch-pd-film-images.mjs b/scripts/fetch-pd-film-images.mjs
new file mode 100644
index 0000000..33c082e
--- /dev/null
+++ b/scripts/fetch-pd-film-images.mjs
@@ -0,0 +1,497 @@
+#!/usr/bin/env node
+/**
+ * Aggregate public-domain film/TV images from multiple sources and upsert
+ * into pd_film_image. Each adapter returns row objects of shape:
+ *   { source, source_id, title, caption, image_url, thumb_url, width, height,
+ *     year, kind, rights_url, source_url }
+ *
+ * Sources (working without API keys):
+ *   wikimedia      — Wikimedia Commons (PD-US film categories, broader query)
+ *   archive_org    — Internet Archive Feature Films collection
+ *   loc            — Library of Congress film/video items
+ *   nara           — National Archives Catalog (api.data.gov DEMO_KEY)
+ *   mhdl           — Media History Digital Library / Lantern (no key)
+ *   smithsonian    — Smithsonian Open Access (DEMO_KEY)
+ *
+ * Sources requiring keys (stubbed — skipped when key absent):
+ *   nypl           — NYPL Digital Collections (NYPL_API_TOKEN)
+ *   flickr_commons — Flickr Commons (FLICKR_API_KEY)
+ *
+ * Run via the existing pastdoor env so DATABASE_URL is set:
+ *   cd /root/public-projects/pastdoor && node scripts/fetch-pd-film-images.mjs --limit=400
+ */
+
+import pg from 'pg';
+
+const LIMIT = Number(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1] ?? 400);
+const PER_SOURCE = Math.ceil(LIMIT / 8);
+
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
+
+const UA = 'pastdoor/1.0 (https://wholivedthere.com; steve@designerwallcoverings.com) PD-image-aggregator';
+const DATA_GOV_KEY = process.env.DATA_GOV_API_KEY ?? 'DEMO_KEY';
+
+async function fetchJson(url, headers = {}) {
+  const r = await fetch(url, {
+    headers: { 'User-Agent': UA, Accept: 'application/json', ...headers },
+  });
+  if (!r.ok) throw new Error(`${url} → ${r.status}`);
+  const ct = r.headers.get('content-type') ?? '';
+  if (!ct.includes('json')) throw new Error(`${url} → not JSON (${ct})`);
+  return r.json();
+}
+
+// ---------- 1. Wikimedia Commons ----------
+// Fixed: use the actual populated PD-film categories. The previous "in_the_public_domain"
+// suffixes were sparse/empty. These are populated and reliably PD.
+async function fromWikimedia(limit) {
+  const cats = [
+    'Public_domain_films',
+    'Public_domain_film_screenshots',
+    'Films_in_the_public_domain_in_the_United_States',
+    'Film_posters_of_the_United_States_in_the_public_domain',
+    'Lobby_cards',                        // pre-1928 lobby cards are PD
+    'Silent_film_screenshots',            // mostly pre-1929 → PD
+  ];
+  const out = [];
+  const perCat = Math.ceil(limit / cats.length);
+  for (const cat of cats) {
+    const url = `https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=categorymembers&gcmtitle=Category:${cat}&gcmtype=file&gcmlimit=${perCat}&prop=imageinfo&iiprop=url|size|extmetadata&iiurlwidth=480&origin=*`;
+    let data;
+    try { data = await fetchJson(url); } catch (e) { console.warn(`wikimedia ${cat}: ${e.message}`); continue; }
+    for (const p of Object.values(data?.query?.pages ?? {})) {
+      const ii = p.imageinfo?.[0]; if (!ii?.url) continue;
+      const meta = ii.extmetadata ?? {};
+      const title = (p.title ?? '').replace(/^File:/, '').replace(/\.[a-z0-9]+$/i, '');
+      const dateStr = meta.DateTimeOriginal?.value ?? meta.DateTime?.value ?? '';
+      const yearMatch = String(dateStr).match(/(\d{4})/);
+      out.push({
+        source: 'wikimedia',
+        source_id: String(p.pageid),
+        title,
+        caption: (meta.ImageDescription?.value ?? '').replace(/<[^>]+>/g, '').trim().slice(0, 600) || null,
+        image_url: ii.url,
+        thumb_url: ii.thumburl ?? null,
+        width: ii.thumbwidth ?? ii.width ?? null,
+        height: ii.thumbheight ?? ii.height ?? null,
+        year: yearMatch ? Number(yearMatch[1]) : null,
+        kind: cat.includes('poster') ? 'poster'
+            : cat.includes('lobby') ? 'lobby_card'
+            : cat.includes('screenshot') ? 'screenshot'
+            : 'still',
+        rights_url: 'https://commons.wikimedia.org/wiki/Commons:Reusing_content_outside_Wikimedia',
+        source_url: `https://commons.wikimedia.org/wiki/?curid=${p.pageid}`,
+      });
+    }
+  }
+  return out.slice(0, limit);
+}
+
+// ---------- 2. Internet Archive — feature_films collection ----------
+async function fromArchive(limit) {
+  const url = `https://archive.org/advancedsearch.php?q=collection%3Afeature_films+mediatype%3Amovies&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=date&fl%5B%5D=description&fl%5B%5D=licenseurl&rows=${limit}&output=json`;
+  let data;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`archive: ${e.message}`); return []; }
+  const out = [];
+  for (const d of data?.response?.docs ?? []) {
+    const id = d.identifier; if (!id) continue;
+    const year = d.date ? Number(String(d.date).slice(0, 4)) : null;
+    out.push({
+      source: 'archive_org',
+      source_id: id,
+      title: d.title ?? id,
+      caption: typeof d.description === 'string'
+        ? d.description.replace(/<[^>]+>/g, '').slice(0, 600)
+        : Array.isArray(d.description)
+          ? d.description.join(' ').replace(/<[^>]+>/g, '').slice(0, 600)
+          : null,
+      image_url: `https://archive.org/services/img/${id}`,
+      thumb_url: `https://archive.org/services/img/${id}`,
+      width: null, height: null, year,
+      kind: 'still',
+      rights_url: d.licenseurl ?? 'https://archive.org/about/terms.php',
+      source_url: `https://archive.org/details/${id}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 3. Library of Congress ----------
+async function fromLoc(limit) {
+  const url = `https://www.loc.gov/film-and-videos/?fa=access-restricted%3Afalse%7Conline-format%3Aimage&fo=json&c=${limit}`;
+  let data;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`loc: ${e.message}`); return []; }
+  const out = [];
+  for (const r of data?.results ?? []) {
+    const img = (r.image_url ?? [])[0]; if (!img) continue;
+    const yearMatch = String(r.dates?.[0] ?? '').match(/(\d{4})/);
+    out.push({
+      source: 'loc',
+      source_id: r.id ?? r.url,
+      title: (r.title ?? 'Untitled').slice(0, 240),
+      caption: typeof r.description === 'string' ? r.description.replace(/<[^>]+>/g, '').slice(0, 600) : null,
+      image_url: img,
+      thumb_url: img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'still',
+      rights_url: 'https://www.loc.gov/legal/',
+      source_url: r.url ?? `https://www.loc.gov${r.id ?? ''}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 4. NARA — National Archives Catalog ----------
+// Fixed: previous endpoint returned HTML. Use api.data.gov gateway with DEMO_KEY.
+// Docs: https://github.com/usnationalarchives/Catalog-API
+async function fromNara(limit) {
+  const url = `https://catalog.archives.gov/api/v2/records/search?q=film+motion+picture&hasDigitalObject=true&recordType=item&limit=${Math.min(limit, 200)}`;
+  let data;
+  try {
+    data = await fetchJson(url, { 'x-api-key': DATA_GOV_KEY });
+  } catch (e) {
+    console.warn(`nara: ${e.message}`);
+    return [];
+  }
+  const hits = data?.body?.hits?.hits ?? data?.opaResponse?.results?.result ?? [];
+  const out = [];
+  for (const item of hits) {
+    const f = item._source?.record ?? item.description?.item ?? {};
+    const naId = f?.naId ?? item._id ?? item.naId; if (!naId) continue;
+    const obj = (f?.digitalObjects ?? f?.objects?.object ?? [])[0];
+    const img = obj?.objectUrl ?? obj?.['@url'];
+    if (!img) continue;
+    const yearStr = String(
+      f?.productionDates?.[0]?.startDate ??
+      f?.productionDates?.[0]?.logicalDate ??
+      f?.productionDateArray?.proposableQualifiableDate?.[0]?.logicalDate ??
+      ''
+    );
+    const yearMatch = yearStr.match(/(\d{4})/);
+    out.push({
+      source: 'nara',
+      source_id: String(naId),
+      title: (f.title ?? f.title_eng ?? 'Untitled').slice(0, 240),
+      caption: (f.scopeAndContentNote ?? f.scopeContent ?? '').slice(0, 600) || null,
+      image_url: img,
+      thumb_url: obj.thumbnailUrl ?? obj.thumbnail ?? img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'still',
+      rights_url: 'https://www.archives.gov/research/catalog/lp.html',
+      source_url: `https://catalog.archives.gov/id/${naId}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 5. Media History Digital Library — Lantern ----------
+// 3M+ pages of PD film magazines, posters, lobby cards (1894-1972).
+// Solr endpoint, no auth. Each item has IIIF imagery at archive.org under the hood.
+async function fromMhdl(limit) {
+  const url = `https://lantern.mediahist.org/catalog.json?q=film+poster&search_field=all_fields&per_page=${Math.min(limit, 100)}`;
+  let data;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`mhdl: ${e.message}`); return []; }
+  const docs = data?.response?.docs ?? data?.data ?? [];
+  const out = [];
+  for (const d of docs) {
+    const id = d.id ?? d.identifier; if (!id) continue;
+    // MHDL images come from Internet Archive — derive thumbnail from the IA identifier.
+    // id format: "{archive_identifier}_{page}" — we want the parent IA item.
+    const archiveId = String(id).replace(/_\d+$/, '');
+    const img = `https://archive.org/services/img/${archiveId}`;
+    const yearStr = String(d.date ?? d.year ?? d.dateRange ?? '');
+    const yearMatch = yearStr.match(/(\d{4})/);
+    out.push({
+      source: 'mhdl',
+      source_id: String(id),
+      title: (d.title ?? d.title_primary ?? 'Untitled').toString().slice(0, 240),
+      caption: (d.description ?? d.summary ?? '').toString().slice(0, 600) || null,
+      image_url: img,
+      thumb_url: img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'poster',
+      rights_url: 'https://mediahist.org/about',
+      source_url: `https://lantern.mediahist.org/catalog/${id}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 6. Smithsonian Open Access ----------
+// 4.5M+ CC0 items. DEMO_KEY works for low volume.
+// Filter: must have image media (online_media_type:Images) and CC0 (cc0:true).
+async function fromSmithsonian(limit) {
+  const q = encodeURIComponent('(cinema OR film OR "motion picture") AND online_media_type:"Images" AND cc0:true');
+  const url = `https://api.si.edu/openaccess/api/v1.0/search?api_key=${DATA_GOV_KEY}&q=${q}&rows=${Math.min(limit, 100)}`;
+  let data;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`smithsonian: ${e.message}`); return []; }
+  const out = [];
+  for (const row of data?.response?.rows ?? []) {
+    const id = row.id; if (!id) continue;
+    const media = row.content?.descriptiveNonRepeating?.online_media?.media?.[0];
+    const img = media?.content ?? media?.resources?.find(r => /Screen|Thumbnail/i.test(r.label))?.url;
+    if (!img) continue;
+    const yearStr = String(row.content?.indexedStructured?.date?.[0] ?? '');
+    const yearMatch = yearStr.match(/(\d{4})/);
+    const recordLink = row.content?.descriptiveNonRepeating?.record_link;
+    out.push({
+      source: 'smithsonian',
+      source_id: String(id),
+      title: (row.title ?? 'Untitled').slice(0, 240),
+      caption: row.content?.freetext?.notes?.[0]?.content?.slice(0, 600) ?? null,
+      image_url: img,
+      thumb_url: media?.thumbnail ?? img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'still',
+      rights_url: 'https://www.si.edu/openaccess',
+      source_url: recordLink ?? `https://www.si.edu/object/${encodeURIComponent(id)}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 6b. Openverse — CC0 + Public Domain Mark aggregator ----------
+// 800M+ openly-licensed images federated across Flickr Commons, Wikimedia,
+// museums (Met, Cleveland, Smithsonian, Rijksmuseum), gov't archives.
+// No key required. License filter: cc0,pdm = strict public-domain only.
+// Acts as a fallback path for Flickr Commons content (avoids needing a Flickr key).
+async function fromOpenverse(limit) {
+  const queries = ['film poster', 'cinema', 'motion picture', 'movie still', 'silent film'];
+  const out = [];
+  const perQuery = Math.ceil(limit / queries.length);
+  for (const q of queries) {
+    const url = `https://api.openverse.org/v1/images/?license=cc0,pdm&q=${encodeURIComponent(q)}&page_size=${Math.min(perQuery, 20)}`;
+    let data;
+    try { data = await fetchJson(url); } catch (e) { console.warn(`openverse "${q}": ${e.message}`); continue; }
+    for (const r of data?.results ?? []) {
+      if (!r.url || !r.id) continue;
+      const yearMatch = String(r.title ?? '').match(/(?:^|\D)(1[89]\d{2}|20[0-1]\d)(?:\D|$)/);
+      out.push({
+        source: 'openverse',
+        source_id: r.id,
+        title: (r.title ?? 'Untitled').slice(0, 240),
+        caption: null,
+        image_url: r.url,
+        thumb_url: r.thumbnail ?? r.url,
+        width: r.width ?? null,
+        height: r.height ?? null,
+        year: yearMatch ? Number(yearMatch[1]) : null,
+        kind: q.includes('poster') ? 'poster' : 'still',
+        rights_url: r.license_url ?? `https://creativecommons.org/${r.license === 'cc0' ? 'publicdomain/zero/1.0/' : 'publicdomain/mark/1.0/'}`,
+        source_url: r.foreign_landing_url ?? r.url,
+      });
+    }
+  }
+  // de-dup by source_id (same image can match multiple queries)
+  const seen = new Set();
+  return out.filter(r => seen.has(r.source_id) ? false : (seen.add(r.source_id), true)).slice(0, limit);
+}
+
+// ---------- 6c. Metropolitan Museum of Art Open Access ----------
+// All Met images marked isPublicDomain=true are CC0. No key required.
+// Search returns ~175 film-related objects with images; we filter PD-only.
+async function fromMet(limit) {
+  // 1) Get IDs from search
+  let ids;
+  try {
+    const search = await fetchJson('https://collectionapi.metmuseum.org/public/collection/v1/search?q=film&hasImages=true');
+    ids = (search?.objectIDs ?? []).slice(0, limit);
+  } catch (e) { console.warn(`met search: ${e.message}`); return []; }
+  // 2) Hydrate each (Met has no batch endpoint — sequential w/ small delay)
+  const out = [];
+  for (const id of ids) {
+    try {
+      const o = await fetchJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`);
+      if (!o?.isPublicDomain || !o?.primaryImageSmall) continue;
+      const yearMatch = String(o.objectDate ?? '').match(/(\d{4})/);
+      out.push({
+        source: 'met',
+        source_id: String(o.objectID),
+        title: (o.title ?? 'Untitled').slice(0, 240),
+        caption: (o.creditLine ?? o.medium ?? '').slice(0, 600) || null,
+        image_url: o.primaryImage ?? o.primaryImageSmall,
+        thumb_url: o.primaryImageSmall ?? o.primaryImage,
+        width: null, height: null,
+        year: yearMatch ? Number(yearMatch[1]) : null,
+        kind: 'still',
+        rights_url: 'https://www.metmuseum.org/about-the-met/policies-and-documents/open-access',
+        source_url: o.objectURL ?? `https://www.metmuseum.org/art/collection/search/${o.objectID}`,
+      });
+      // small politeness delay; Met asks for moderate rate
+      await new Promise(r => setTimeout(r, 80));
+    } catch (e) { /* skip silently — Met often 404s on weird IDs */ }
+  }
+  return out;
+}
+
+// ---------- 6d. Digital Public Library of America ----------
+// Federates ~50M items from US libraries/museums/archives. Key required (free,
+// emailed via POST to api.dp.la/v2/api_key/<your-email>). Skipped without key.
+async function fromDpla(limit) {
+  if (!process.env.DPLA_API_KEY) {
+    console.warn('dpla: skipped (no DPLA_API_KEY in env)');
+    return [];
+  }
+  const url = `https://api.dp.la/v2/items?api_key=${process.env.DPLA_API_KEY}&q=film+OR+cinema+OR+%22motion+picture%22&sourceResource.type=image&page_size=${Math.min(limit, 100)}&filter=rights:%22No%20Copyright%22+OR+rights:%22Public%20Domain%22`;
+  let data;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`dpla: ${e.message}`); return []; }
+  const out = [];
+  for (const d of data?.docs ?? []) {
+    const id = d.id; if (!id) continue;
+    const sr = d.sourceResource ?? {};
+    const img = d.object;          // direct hot-link to the asset
+    if (!img) continue;
+    const yearMatch = String(sr.date?.displayDate ?? sr.date?.begin ?? '').match(/(\d{4})/);
+    out.push({
+      source: 'dpla',
+      source_id: id,
+      title: (Array.isArray(sr.title) ? sr.title[0] : sr.title ?? 'Untitled').slice(0, 240),
+      caption: (Array.isArray(sr.description) ? sr.description[0] : sr.description ?? '').slice(0, 600) || null,
+      image_url: img,
+      thumb_url: img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'still',
+      rights_url: Array.isArray(sr.rights) ? sr.rights[0] : sr.rights ?? null,
+      source_url: d.isShownAt ?? `https://dp.la/item/${id.split('--')[0]}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 7. Flickr Commons (stubbed — needs FLICKR_API_KEY) ----------
+async function fromFlickrCommons(limit) {
+  if (!process.env.FLICKR_API_KEY) {
+    console.warn('flickr_commons: skipped (no FLICKR_API_KEY in env)');
+    return [];
+  }
+  const key = process.env.FLICKR_API_KEY;
+  // Group ID 1158177@N20 = Flickr Commons supergroup
+  const url = `https://www.flickr.com/services/rest/?method=flickr.photos.search&group_id=1158177@N20&text=film+OR+cinema&extras=url_z,url_o,date_taken,owner_name,license&per_page=${Math.min(limit, 100)}&format=json&nojsoncallback=1&api_key=${key}`;
+  let data;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`flickr_commons: ${e.message}`); return []; }
+  const out = [];
+  for (const p of data?.photos?.photo ?? []) {
+    const img = p.url_z ?? p.url_o; if (!img) continue;
+    const yearMatch = String(p.datetaken ?? '').match(/(\d{4})/);
+    out.push({
+      source: 'flickr_commons',
+      source_id: String(p.id),
+      title: (p.title ?? 'Untitled').slice(0, 240),
+      caption: null,
+      image_url: img,
+      thumb_url: img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'still',
+      rights_url: 'https://www.flickr.com/commons/usage/',
+      source_url: `https://www.flickr.com/photos/${p.owner}/${p.id}`,
+    });
+  }
+  return out;
+}
+
+// ---------- 8. NYPL Digital Collections (stubbed — needs NYPL_API_TOKEN) ----------
+async function fromNypl(limit) {
+  if (!process.env.NYPL_API_TOKEN) {
+    console.warn('nypl: skipped (no NYPL_API_TOKEN in env)');
+    return [];
+  }
+  const token = process.env.NYPL_API_TOKEN;
+  const url = `https://api.repo.nypl.org/api/v2/items/search.json?q=film+poster&publicDomainOnly=true&per_page=${Math.min(limit, 100)}`;
+  let data;
+  try { data = await fetchJson(url, { Authorization: `Token token="${token}"` }); }
+  catch (e) { console.warn(`nypl: ${e.message}`); return []; }
+  const out = [];
+  for (const r of data?.nyplAPI?.response?.result ?? []) {
+    const uuid = r.uuid; if (!uuid) continue;
+    const img = r.imageLinks?.imageLink?.find?.(l => /w=760/.test(l['@url']))?.['@url'] ??
+                r.imageLinks?.imageLink?.[0]?.['@url'];
+    if (!img) continue;
+    const yearStr = String(r.dateString ?? r.dateDigitized ?? '');
+    const yearMatch = yearStr.match(/(\d{4})/);
+    out.push({
+      source: 'nypl',
+      source_id: uuid,
+      title: (r.title ?? 'Untitled').slice(0, 240),
+      caption: (r.description ?? '').slice(0, 600) || null,
+      image_url: img,
+      thumb_url: img,
+      width: null, height: null,
+      year: yearMatch ? Number(yearMatch[1]) : null,
+      kind: 'still',
+      rights_url: 'https://digitalcollections.nypl.org/about',
+      source_url: `https://digitalcollections.nypl.org/items/${uuid}`,
+    });
+  }
+  return out;
+}
+
+// ---------- upsert ----------
+async function upsert(rows) {
+  if (!rows.length) return 0;
+  const text = `
+    INSERT INTO pd_film_image
+      (source, source_id, title, caption, image_url, thumb_url, width, height, year, kind, rights_url, source_url, fetched_at)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now())
+    ON CONFLICT (source, source_id) DO UPDATE SET
+      title = EXCLUDED.title,
+      caption = EXCLUDED.caption,
+      image_url = EXCLUDED.image_url,
+      thumb_url = EXCLUDED.thumb_url,
+      width = EXCLUDED.width,
+      height = EXCLUDED.height,
+      year = EXCLUDED.year,
+      kind = EXCLUDED.kind,
+      rights_url = EXCLUDED.rights_url,
+      source_url = EXCLUDED.source_url,
+      fetched_at = now()
+  `;
+  let n = 0;
+  for (const r of rows) {
+    try {
+      await pool.query(text, [
+        r.source, r.source_id, r.title, r.caption, r.image_url, r.thumb_url,
+        r.width, r.height, r.year, r.kind, r.rights_url, r.source_url,
+      ]);
+      n++;
+    } catch (e) { console.warn(`upsert ${r.source}/${r.source_id}: ${e.message}`); }
+  }
+  return n;
+}
+
+(async () => {
+  console.log(`fetching ~${PER_SOURCE} per source (${LIMIT} total target)`);
+  const settled = await Promise.allSettled([
+    fromWikimedia(PER_SOURCE),
+    fromArchive(PER_SOURCE),
+    fromLoc(PER_SOURCE),
+    fromNara(PER_SOURCE),
+    fromMhdl(PER_SOURCE),
+    fromSmithsonian(PER_SOURCE),
+    fromOpenverse(PER_SOURCE),
+    fromMet(PER_SOURCE),
+    fromDpla(PER_SOURCE),
+    fromFlickrCommons(PER_SOURCE),
+    fromNypl(PER_SOURCE),
+  ]);
+  const labels = ['wikimedia','archive_org','loc','nara','mhdl','smithsonian','openverse','met','dpla','flickr_commons','nypl'];
+  const all = [];
+  const counts = {};
+  settled.forEach((s, i) => {
+    if (s.status === 'fulfilled') {
+      counts[labels[i]] = s.value.length;
+      all.push(...s.value);
+    } else {
+      counts[labels[i]] = `error: ${s.reason?.message ?? s.reason}`;
+    }
+  });
+  console.log('fetched:', counts);
+  const wrote = await upsert(all);
+  console.log(`upserted ${wrote}/${all.length}`);
+  await pool.end();
+})();
diff --git a/scripts/filmla-cpra-followup.sh b/scripts/filmla-cpra-followup.sh
new file mode 100755
index 0000000..e872166
--- /dev/null
+++ b/scripts/filmla-cpra-followup.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+#
+# filmla-cpra-followup.sh
+#
+# Fires once at 9:15 AM PT on 2026-05-09 (10-day CPRA deadline) via launchd.
+# Checks Gmail (steve-office) for any inbound from filmla.com that is NOT
+# the auto-ticket-creation acknowledgment. If found → log and exit. If not →
+# send the courtesy follow-up via George (DW Gmail HTTP agent on tailnet).
+#
+# Idempotent via sentinel file. Self-disables the launchd job on success
+# so it doesn't fire again next May.
+
+set -euo pipefail
+
+GEORGE="http://100.107.67.67:9850"
+ENV_FILE="${FILMLA_CPRA_ENV_FILE:-$HOME/.filmla-cpra-followup.env}"
+if [ -f "$ENV_FILE" ]; then
+  set -a
+  # shellcheck disable=SC1090
+  . "$ENV_FILE"
+  set +a
+fi
+
+AUTH="${FILMLA_GEORGE_BASIC_AUTH:-}"
+LOG="$HOME/Library/Logs/filmla-cpra-followup.log"
+SENTINEL="$HOME/Library/Application Support/filmla-cpra-followup.done"
+LABEL="com.steve.filmla-cpra-followup"
+TARGET_DATE="2026-05-09"
+
+mkdir -p "$(dirname "$SENTINEL")"
+
+log() {
+  printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S %Z')" "$*" | tee -a "$LOG"
+}
+
+# Date guard — only fires on the target day (CPRA 10-day deadline).
+# Override with FILMLA_FORCE=1 for testing.
+TODAY=$(date '+%Y-%m-%d')
+if [ "${FILMLA_FORCE:-0}" != "1" ] && [ "$TODAY" != "$TARGET_DATE" ]; then
+  log "Today is $TODAY, target is $TARGET_DATE. Not firing. (Set FILMLA_FORCE=1 to override.)"
+  exit 0
+fi
+
+if [ -f "$SENTINEL" ]; then
+  log "Sentinel exists — already ran on $(cat "$SENTINEL"). Exiting."
+  exit 0
+fi
+
+if [ -z "$AUTH" ]; then
+  log "FILMLA_GEORGE_BASIC_AUTH is required (format: user:password)."
+  exit 1
+fi
+
+log "=== FilmLA CPRA follow-up firing ==="
+
+# Step 1: search Gmail. Exclude auto-ack ("New Service Ticket Created").
+QUERY='from:filmla.com newer_than:11d -subject:"New Service Ticket Created"'
+search_response=$(curl -sS --max-time 20 -u "$AUTH" \
+  -G --data-urlencode "q=$QUERY" \
+  "$GEORGE/api/search")
+
+total=$(echo "$search_response" | jq -r '.total // 0')
+log "Search: q='$QUERY' → total=$total"
+
+if [ "$total" -gt 0 ]; then
+  sample=$(echo "$search_response" | jq -c '.messages[0]')
+  log "FilmLA REPLIED — no follow-up needed. Sample: $sample"
+  echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) replied" > "$SENTINEL"
+  /bin/launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
+  log "Booted out launchd job. Done."
+  exit 0
+fi
+
+log "No reply detected. Sending follow-up to info@filmla.com via George."
+
+read -r -d '' BODY_HTML <<'HTML' || true
+<p>Hello FilmLA team,</p>
+<p>Following up on my California Public Records Act request submitted on April 29, 2026 (subject: &ldquo;California Public Records Act request &mdash; historical film permit data&rdquo;). Cal. Gov. Code &sect; 7922.535 requires a determination within 10 calendar days, which falls on today.</p>
+<p>Please confirm:</p>
+<ul>
+  <li>(a) whether the request can be fulfilled, and an estimated production date,</li>
+  <li>(b) whether any compilation fee will apply (and the estimate), or</li>
+  <li>(c) whether any specific exemption is being asserted.</li>
+</ul>
+<p>If a 14-day extension under &sect; 7922.535(b) is needed, please send written notice citing the specific basis for the extension.</p>
+<p>Thank you for your continued attention.</p>
+<p>Regards,<br>
+Steve Abrams<br>
+Designer Wallcoverings<br>
++1 888 373 4564</p>
+HTML
+
+payload=$(jq -n \
+  --arg to "info@filmla.com" \
+  --arg subject "Following up: CPRA request submitted 2026-04-29" \
+  --arg body "$BODY_HTML" \
+  '{to:$to, subject:$subject, body:$body}')
+
+send_response=$(curl -sS --max-time 30 -u "$AUTH" \
+  -H "Content-Type: application/json" \
+  -X POST "$GEORGE/api/send" \
+  -d "$payload")
+
+ok=$(echo "$send_response" | jq -r '.ok // .id // .messageId // empty')
+if [ -z "$ok" ]; then
+  log "SEND FAILED — response: $send_response"
+  log "Leaving sentinel ABSENT so we can retry manually if relaunched."
+  exit 1
+fi
+
+mid=$(echo "$send_response" | jq -r '.id // .messageId // .threadId // "unknown"')
+log "SENT — id=$mid full=$send_response"
+
+echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) sent id=$mid" > "$SENTINEL"
+/bin/launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
+log "Booted out launchd job. Done."
diff --git a/scripts/ingest-bh-permits-arcgis.ts b/scripts/ingest-bh-permits-arcgis.ts
new file mode 100644
index 0000000..ae21c2f
--- /dev/null
+++ b/scripts/ingest-bh-permits-arcgis.ts
@@ -0,0 +1,184 @@
+/**
+ * ingest-bh-permits-arcgis.ts
+ *
+ * Beverly Hills Issued Permits via ArcGIS FeatureServer.
+ * Verified 2026-04-30: ~41,409 rows in "Issued Permits All Current" feed.
+ *
+ * Tier: A (Beverly Hills city government primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+const FS_URL = 'https://services5.arcgis.com/7CXE3aevo18HlHBC/arcgis/rest/services/Permit_Issued_All/FeatureServer/0/query';
+const PAGE = 1000; // BH server caps at 1000 per request
+const BATCH = 500;
+
+type Feature = { attributes: Record<string, any>; geometry?: { x?: number; y?: number } };
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+async function fetchPage(offset: number): Promise<Feature[]> {
+  const u = new URL(FS_URL);
+  u.searchParams.set('where', '1=1');
+  u.searchParams.set('outFields', '*');
+  u.searchParams.set('returnGeometry', 'true');
+  u.searchParams.set('outSR', '4326');
+  u.searchParams.set('resultOffset', String(offset));
+  u.searchParams.set('resultRecordCount', String(PAGE));
+  u.searchParams.set('f', 'json');
+  u.searchParams.set('orderByFields', 'OBJECTID');
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const res = await fetch(u);
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const j = await res.json() as { features?: Feature[] };
+      return j.features ?? [];
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function processBatch(features: Feature[]) {
+  // dedup canonical — BH actual field is `ADDRESS` (verified 2026-04-30)
+  const canonMap = new Map<string, { addr: string; lat: number | null; lon: number | null }>();
+  for (const f of features) {
+    const a = f.attributes ?? {};
+    const addr = (a.ADDRESS ?? a.SiteAddress ?? a.SITE_ADDRESS ?? a.Address ?? '').toString().trim();
+    if (!addr) continue;
+    const canon = canonicalize(addr);
+    if (!canon || canonMap.has(canon)) continue;
+    canonMap.set(canon, { addr, lat: f.geometry?.y ?? null, lon: f.geometry?.x ?? null });
+  }
+  if (canonMap.size === 0) return;
+
+  // upsert listings batch
+  const listingTuples: string[] = [];
+  const listingValues: any[] = [];
+  let i = 0;
+  for (const [canon, r] of canonMap) {
+    const base = i * 6;
+    listingTuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6})`);
+    listingValues.push(`${canon}-bh`, `bh:${canon}`, r.addr, r.addr, r.lat, r.lon);
+    i++;
+  }
+  const lr = await pool.query<{ id: string; source_id: string }>(
+    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
+     VALUES ${listingTuples.map((t, k) => {
+       const b = k * 6;
+       return `($${b+1},'bh_permit',$${b+2},$${b+3},$${b+4},'Beverly Hills','CA','US',$${b+5},$${b+6},true,'free')`;
+     }).join(',')}
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
+       longitude = COALESCE(listing.longitude, EXCLUDED.longitude),
+       updated_at = now()
+     RETURNING id, source_id`,
+    listingValues
+  );
+  const canonToLid = new Map<string, string>();
+  for (const row of lr.rows) {
+    // source_id may be from a colliding-slug existing listing; tolerate by stripping any prefix
+    canonToLid.set(row.source_id.replace(/^[a-z_]+:/i, ''), row.id);
+  }
+  // also map by canonical (in case source_id stripped doesn't match)
+  for (const [canon] of canonMap) {
+    if (!canonToLid.has(canon)) {
+      // try lookup by slug
+      const slugMatch = await pool.query<{ id: string }>(
+        `SELECT id FROM listing WHERE slug = $1 LIMIT 1`,
+        [`${canon}-bh`]
+      );
+      if (slugMatch.rows[0]) canonToLid.set(canon, slugMatch.rows[0].id);
+    }
+  }
+
+  // permits batch — dedup by permit_number to avoid ON CONFLICT collisions within a batch
+  const seenPermits = new Set<string>();
+  const permitTuples: string[] = [];
+  const permitValues: any[] = [];
+  let pi = 0;
+  for (const f of features) {
+    const a = f.attributes ?? {};
+    const addr = (a.ADDRESS ?? a.SiteAddress ?? a.Address ?? '').toString().trim();
+    if (!addr) continue;
+    const canon = canonicalize(addr);
+    const lid = canonToLid.get(canon);
+    if (!lid) continue;
+    const permitNbr = (a.PERMIT_NUMBER ?? a.PermitNumber ?? a.OBJECTID ?? '').toString().trim();
+    if (!permitNbr) continue;
+    if (seenPermits.has(permitNbr)) continue;
+    seenPermits.add(permitNbr);
+    // Construct issue_date from year+month
+    const yr = parseInt(String(a.ISSUED_YEAR ?? ''), 10);
+    const mo = parseInt(String(a.ISSUED_MONTH ?? '1'), 10);
+    const issueDate = (isFinite(yr) && yr >= 1900 && yr <= 2030 && mo >= 1 && mo <= 12)
+      ? `${yr}-${String(mo).padStart(2, '0')}-01` : null;
+    const valStr = String(a.VALUATION ?? a.Valuation ?? '').replace(/[$,]/g, '');
+    const valuation = parseFloat(valStr) || null;
+    const base = pi * 11;
+    permitTuples.push(`(${Array.from({length: 11}, (_, k) => `$${base+k+1}`).join(',')})`);
+    permitValues.push(
+      permitNbr,                                // 1 permit_number
+      lid,                                      // 2 listing_id
+      addr,                                     // 3 primary_address
+      a.PERMIT_TYPE ?? a.PermitType ?? null,    // 4 permit_type
+      null,                                     // 5 permit_sub_type
+      null,                                     // 6 status_desc — BH doesn't expose
+      issueDate,                                // 7 issue_date
+      valuation,                                // 8 valuation
+      a.PERMIT_DESCRIPTION ?? null,             // 9 work_desc
+      f.geometry?.y ?? null,                    // 10 latitude
+      f.geometry?.x ?? null,                    // 11 longitude
+    );
+    pi++;
+  }
+  if (permitTuples.length === 0) return;
+
+  await pool.query(
+    `INSERT INTO permit (source, source_dataset, permit_number, listing_id, primary_address, permit_type, permit_sub_type, status_desc, issue_date, valuation, work_desc, latitude, longitude, source_label, source_url)
+     VALUES ${permitTuples.map((t, k) => {
+       const b = k * 11;
+       return `('bh','bh-issued-permits-all',$${b+1},$${b+2},$${b+3},$${b+4},$${b+5},$${b+6},$${b+7},$${b+8},$${b+9},$${b+10},$${b+11},'City of Beverly Hills','https://opendata-hub.beverlyhills.org/datasets/155382a864da4f10afb7d0d5eab7b68d_0')`;
+     }).join(',')}
+     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+       status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
+       retrieved_at = now()`,
+    permitValues
+  );
+}
+
+async function main() {
+  let offset = 0;
+  let total = 0;
+  const t0 = Date.now();
+  while (true) {
+    const features = await fetchPage(offset);
+    if (!features.length) break;
+    for (let i = 0; i < features.length; i += BATCH) {
+      await processBatch(features.slice(i, i + BATCH));
+    }
+    total += features.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  bh: ${total} (${(total/dt).toFixed(0)}/s)`);
+    if (features.length === 0) break;
+    offset += features.length;
+    if (offset > 100000) break; // safety bound
+
+  }
+  console.log(`✓ BH permits: ${total} total`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-bh-permits.ts b/scripts/ingest-bh-permits.ts
new file mode 100644
index 0000000..1f3e772
--- /dev/null
+++ b/scripts/ingest-bh-permits.ts
@@ -0,0 +1,158 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Beverly Hills nightly permit feed ingest.
+ *
+ * Drop-in slot for the real BH issued-permits dataset. Behaviour:
+ *   1. Reads from a CSV path (--file) or HTTP URL (--url) or the bundled sample.
+ *   2. Normalizes the address string and matches it to an existing listing.
+ *      If no match, creates a stub listing in DRAFT state (is_public=false)
+ *      so the permit isn't lost — editorial can promote later.
+ *   3. Appends a row to `place_event` per permit, idempotent on permit_number
+ *      (extracted into source_id so repeat runs don't double-insert).
+ *
+ * Usage:
+ *   npx tsx scripts/ingest-bh-permits.ts                       # bundled sample
+ *   npx tsx scripts/ingest-bh-permits.ts --file path/to.csv
+ *   npx tsx scripts/ingest-bh-permits.ts --url https://data.beverlyhills.org/.../permits.csv
+ *
+ * Wire-up for the live feed (when the URL lands):
+ *   - Add ~/Library/LaunchAgents/com.pastdoor.bh-permits.plist (cron 03:00 daily)
+ *   - Or pm2: `pm2 start "npx tsx scripts/ingest-bh-permits.ts --url $URL" --cron "0 3 * * *"`
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { Pool } from 'pg';
+import slugify from 'slugify';
+
+type Row = {
+  permit_number: string;
+  issued_date: string;
+  address: string;
+  scope: string;
+  permit_type: string;
+  valuation: string;
+  applicant: string;
+};
+
+function parseCsv(text: string): Row[] {
+  // Lightweight CSV parser — sample file uses clean quoting.
+  const lines = text.split(/\r?\n/).filter(Boolean);
+  const headers = lines[0]!.split(',');
+  return lines.slice(1).map(line => {
+    // naive comma-split — sample data has no embedded commas inside fields
+    const cols = line.split(',');
+    const row: Record<string, string> = {};
+    headers.forEach((h, i) => (row[h.trim()] = (cols[i] ?? '').trim()));
+    return row as unknown as Row;
+  });
+}
+
+function normalizeAddress(raw: string): string {
+  return raw
+    .toLowerCase()
+    .replace(/[.,]/g, '')
+    .replace(/\bnorth\b/g, 'n')
+    .replace(/\bsouth\b/g, 's')
+    .replace(/\beast\b/g, 'e')
+    .replace(/\bwest\b/g, 'w')
+    .replace(/\bdrive\b/g, 'dr')
+    .replace(/\bavenue\b/g, 'ave')
+    .replace(/\bboulevard\b/g, 'blvd')
+    .replace(/\bstreet\b/g, 'st')
+    .replace(/\bway\b/g, 'wy')
+    .replace(/\broad\b/g, 'rd')
+    .replace(/\splace\b/g, ' pl')
+    .replace(/\bcanyon\b/g, 'canyon')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  max: 4,
+});
+
+async function findOrCreateListingByAddress(address: string): Promise<string> {
+  const norm = normalizeAddress(address);
+  const { rows: hit } = await pool.query<{ id: string }>(
+    `SELECT id FROM listing
+     WHERE city = 'Beverly Hills'
+       AND lower(regexp_replace(address_line1, '[.,]', '', 'g')) ILIKE $1
+     LIMIT 1`,
+    [`%${norm}%`]
+  );
+  if (hit[0]) return hit[0].id;
+
+  // Create a stub. is_public=false, so the page won't index until enriched.
+  const slug = slugify(`${address}-bh`, { lower: true, strict: true });
+  const { rows } = await pool.query<{ id: string }>(
+    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, is_public, privacy_class)
+     VALUES ($1, 'bh-permits', $2, $3, $4, 'Beverly Hills', 'CA', 'US', false, 'public_history_only')
+     ON CONFLICT (slug) DO UPDATE SET updated_at = now()
+     RETURNING id`,
+    [slug, slug, address, address]
+  );
+  return rows[0]!.id;
+}
+
+async function appendPermitEvent(listingId: string, row: Row) {
+  const summary = row.scope ? `${row.scope}` : `Permit issued — ${row.permit_type}`;
+  const sourceLabel = `BH Planning · permit ${row.permit_number}`;
+  const { rowCount } = await pool.query(
+    `INSERT INTO place_event (listing_id, kind, summary, valid_from, source_tier, source_label, source_id, confidence)
+     SELECT $1, 'permit', $2, $3::date, 'A', $4, $5, 0.94
+     WHERE NOT EXISTS (SELECT 1 FROM place_event WHERE source_id = $5 AND kind = 'permit')`,
+    [listingId, summary, row.issued_date, sourceLabel, row.permit_number]
+  );
+  return rowCount === 1;
+}
+
+async function main() {
+  const args = process.argv.slice(2);
+  const fileArg = args.find((a, i) => args[i - 1] === '--file');
+  const urlArg = args.find((a, i) => args[i - 1] === '--url');
+  let csv: string;
+  if (urlArg) {
+    const r = await fetch(urlArg);
+    if (!r.ok) throw new Error(`Fetch ${urlArg} ${r.status}`);
+    csv = await r.text();
+  } else {
+    const filePath = fileArg ?? path.resolve(__dirname, '..', 'data', 'bh-permits-sample.csv');
+    csv = fs.readFileSync(filePath, 'utf8');
+  }
+
+  const rows = parseCsv(csv);
+  let inserted = 0;
+  let matched = 0;
+  let stubbed = 0;
+  for (const row of rows) {
+    const before = await pool.query<{ id: string }>(
+      `SELECT id FROM listing WHERE source = 'bh-permits' AND source_id = $1`,
+      [slugify(`${row.address}-bh`, { lower: true, strict: true })]
+    );
+    const wasStub = before.rowCount! > 0;
+    const listingId = await findOrCreateListingByAddress(row.address);
+    if (!wasStub) {
+      const after = await pool.query<{ id: string }>(
+        `SELECT id FROM listing WHERE source = 'bh-permits' AND source_id = $1`,
+        [slugify(`${row.address}-bh`, { lower: true, strict: true })]
+      );
+      if (after.rowCount! > 0) stubbed++;
+      else matched++;
+    }
+    const did = await appendPermitEvent(listingId, row);
+    if (did) inserted++;
+  }
+  console.log(JSON.stringify(
+    { processed: rows.length, events_inserted: inserted, matched_existing: matched, stubs_created: stubbed },
+    null, 2
+  ));
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error(e);
+  process.exit(1);
+});
diff --git a/scripts/ingest-la-additional-permits.ts b/scripts/ingest-la-additional-permits.ts
new file mode 100644
index 0000000..066ffd4
--- /dev/null
+++ b/scripts/ingest-la-additional-permits.ts
@@ -0,0 +1,221 @@
+/**
+ * ingest-la-additional-permits.ts
+ *
+ * Pulls 5 additional LADBS permit datasets that weren't in the main building-permit
+ * ingest. Same Socrata pattern; each row creates/updates a listing and inserts
+ * a permit row.
+ *
+ *   - 3f9m-afei  Certificate of Occupancy        (88K)
+ *   - 67is-svtd  Mechanical Permits 2020+        (279K)
+ *   - 5m3t-xjex  Mechanical Permits 2010-2019    (503K)
+ *   - ysqd-apz7  Electrical Permits 2020+        (338K)
+ *   - j7mw-thyc  Bureau of Engineering Permits   (626K)
+ *
+ * Tier: A (LA City government primary record).
+ *
+ * Usage: npx tsx scripts/ingest-la-additional-permits.ts [--dataset=SLUG]
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 8,
+});
+
+const args = Object.fromEntries(
+  process.argv.slice(2).map(a => {
+    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+    return m ? [m[1], m[2] ?? 'true'] : [a, 'true'];
+  })
+);
+const ONLY_DATASET = args.dataset as string | undefined;
+
+type Dataset = {
+  slug: string;
+  source: string;
+  source_label: string;
+  expectedRows: number;
+};
+// Only datasets sharing the standard LADBS schema (permit_nbr, primary_address, etc.)
+// C-of-O (3f9m-afei) and BOE (j7mw-thyc) have different schemas — separate ingesters.
+const DATASETS: Dataset[] = [
+  { slug: '67is-svtd', source: 'la_city_mechanical', source_label: 'LADBS Mechanical Permits 2020+',     expectedRows: 279087 },
+  { slug: '5m3t-xjex', source: 'la_city_mechanical', source_label: 'LADBS Mechanical Permits 2010-2019', expectedRows: 503281 },
+  { slug: 'ysqd-apz7', source: 'la_city_electrical', source_label: 'LADBS Electrical Permits 2020+',     expectedRows: 338674 },
+];
+
+const PAGE_SIZE = 50000;
+const BATCH = 1000;
+
+type SocrataRow = {
+  permit_nbr?: string;
+  primary_address?: string;
+  zip_code?: string;
+  cd?: string;
+  apn?: string;
+  zone?: string;
+  permit_group?: string;
+  permit_type?: string;
+  permit_sub_type?: string;
+  use_code?: string;
+  use_desc?: string;
+  issue_date?: string;
+  status_desc?: string;
+  status_date?: string;
+  valuation?: string;
+  square_footage?: string;
+  work_desc?: string;
+  lat?: string;
+  lon?: string;
+};
+
+function pn(v?: string): number | null { if (!v) return null; const n = parseFloat(v); return isFinite(n) ? n : null; }
+function pi(v?: string): number | null { if (!v) return null; const n = parseInt(v, 10); return isFinite(n) ? n : null; }
+function pd(v?: string): string | null { if (!v) return null; const m = v.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; }
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+async function fetchPage(slug: string, offset: number): Promise<SocrataRow[]> {
+  const u = `https://data.lacity.org/resource/${slug}.json?$limit=${PAGE_SIZE}&$offset=${offset}&$order=permit_nbr`;
+  for (let attempt = 1; attempt <= 5; attempt++) {
+    try {
+      const res = await fetch(u, { headers: { 'User-Agent': 'stayclaim-ingest/1.0' } });
+      if (res.status === 429 || res.status === 503) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; }
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return await res.json() as SocrataRow[];
+    } catch (e) {
+      if (attempt >= 5) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function upsertListings(rows: SocrataRow[], source: string): Promise<Map<string, string>> {
+  const dedup = new Map<string, { addr: string; lat: number | null; lon: number | null; zip: string | null }>();
+  const rawToCanonical = new Map<string, string>();
+  for (const r of rows) {
+    if (!r.primary_address) continue;
+    const addr = r.primary_address.trim();
+    const canon = canonicalize(addr);
+    if (!canon) continue;
+    rawToCanonical.set(addr, canon);
+    if (!dedup.has(canon)) {
+      dedup.set(canon, { addr, lat: pn(r.lat), lon: pn(r.lon), zip: r.zip_code ?? null });
+    }
+  }
+  const canonToLid = new Map<string, string>();
+  if (dedup.size === 0) return new Map();
+  const arr = Array.from(dedup.entries());
+  for (let i = 0; i < arr.length; i += BATCH) {
+    const slice = arr.slice(i, i + BATCH);
+    const tuples: string[] = [];
+    const values: any[] = [];
+    slice.forEach(([canonical, r], idx) => {
+      const base = idx * 7;
+      tuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7})`);
+      values.push(`${canonical}-ladbs`, `ladbs:${canonical}`, r.addr, r.addr, r.zip, r.lat, r.lon);
+    });
+    const sql = `
+      INSERT INTO listing
+        (slug, source, source_id, title, address_line1, city, state, country, postal_code, latitude, longitude, is_public, tier)
+      VALUES ${tuples.map((_, k) => {
+        const b = k * 7;
+        return `($${b+1},'ladbs_permit',$${b+2},$${b+3},$${b+4},'Los Angeles','CA','US',$${b+5},$${b+6},$${b+7},true,'free')`;
+      }).join(',')}
+      ON CONFLICT (slug) DO UPDATE SET
+        latitude    = COALESCE(listing.latitude,    EXCLUDED.latitude),
+        longitude   = COALESCE(listing.longitude,   EXCLUDED.longitude),
+        postal_code = COALESCE(listing.postal_code, EXCLUDED.postal_code),
+        updated_at  = now()
+      RETURNING id, source_id`;
+    const r = await pool.query<{ id: string; source_id: string }>(sql, values);
+    for (const row of r.rows) canonToLid.set(row.source_id.replace(/^ladbs:/, ''), row.id);
+  }
+  const result = new Map<string, string>();
+  for (const [raw, canonical] of rawToCanonical) {
+    const lid = canonToLid.get(canonical);
+    if (lid) result.set(raw, lid);
+  }
+  return result;
+}
+
+async function upsertPermits(ds: Dataset, rows: SocrataRow[], listingMap: Map<string, string>): Promise<number> {
+  const seen = new Set<string>();
+  const valid = rows.filter(r => r.permit_nbr && r.permit_nbr.trim() && !seen.has(r.permit_nbr.trim()) && (seen.add(r.permit_nbr.trim()), true));
+  if (valid.length === 0) return 0;
+  let inserted = 0;
+  for (let i = 0; i < valid.length; i += BATCH) {
+    const slice = valid.slice(i, i + BATCH);
+    const tuples: string[] = [];
+    const values: any[] = [];
+    slice.forEach((r, idx) => {
+      const base = idx * 18;
+      tuples.push(`(${Array.from({length: 18}, (_, k) => `$${base+k+1}`).join(',')})`);
+      values.push(
+        ds.source, ds.slug, r.permit_nbr!.trim(),
+        r.primary_address ? listingMap.get(r.primary_address.trim()) ?? null : null,
+        r.primary_address ?? null, r.zip_code ?? null, r.cd ?? null, r.apn ?? null, r.zone ?? null,
+        r.permit_group ?? null, r.permit_type ?? null, r.permit_sub_type ?? null,
+        r.use_code ?? null, r.use_desc ?? null, pd(r.issue_date), pd(r.status_date),
+        r.status_desc ?? null, pn(r.valuation),
+      );
+    });
+    await pool.query(
+      `INSERT INTO permit
+         (source, source_dataset, permit_number, listing_id, primary_address, zip_code, council_district,
+          apn, zone, permit_group, permit_type, permit_sub_type, use_code, use_desc,
+          issue_date, status_date, status_desc, valuation)
+       VALUES ${tuples.join(',')}
+       ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+         status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
+         retrieved_at = now()`,
+      values
+    );
+    inserted += slice.length;
+  }
+  // Set source_url + source_label after insert (single update, indexed by source_dataset)
+  return inserted;
+}
+
+async function ingestDataset(ds: Dataset) {
+  console.log(`\n=== ${ds.slug} ${ds.source_label} (${ds.expectedRows.toLocaleString()}) ===`);
+  const t0 = Date.now();
+  let offset = 0, pulled = 0, permitsIns = 0;
+  while (true) {
+    const rows = await fetchPage(ds.slug, offset);
+    if (rows.length === 0) break;
+    const listingMap = await upsertListings(rows, ds.source);
+    const ins = await upsertPermits(ds, rows, listingMap);
+    pulled += rows.length;
+    permitsIns += ins;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  ${ds.slug}: ${pulled.toLocaleString()}/${ds.expectedRows.toLocaleString()} (${(pulled/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE_SIZE) break;
+    offset += PAGE_SIZE;
+  }
+  // Set source_url + label for this dataset
+  await pool.query(
+    `UPDATE permit SET source_url = $1, source_label = $2 WHERE source_dataset = $3 AND source_url IS NULL`,
+    [`https://data.lacity.org/resource/${ds.slug}.json?permit_nbr=`, ds.source_label, ds.slug]
+  );
+  // Source URL with permit number suffix needs per-row interpolation
+  await pool.query(
+    `UPDATE permit SET source_url = source_url || permit_number WHERE source_dataset = $1 AND source_url LIKE '%permit_nbr=' AND source_url NOT LIKE '%permit_nbr=' || permit_number`,
+    [ds.slug]
+  );
+  console.log(`  ✓ ${ds.slug}: ${pulled.toLocaleString()} pulled, ${permitsIns.toLocaleString()} permits inserted`);
+}
+
+async function main() {
+  const datasets = ONLY_DATASET ? DATASETS.filter(d => d.slug === ONLY_DATASET) : DATASETS;
+  for (const ds of datasets) await ingestDataset(ds);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-assessor-legacy.ts b/scripts/ingest-la-assessor-legacy.ts
new file mode 100644
index 0000000..a33920e
--- /dev/null
+++ b/scripts/ingest-la-assessor-legacy.ts
@@ -0,0 +1,210 @@
+/**
+ * ingest-la-assessor-legacy.ts
+ *
+ * Downloads the LA County Assessor "Parcels Data 2006 thru 2021" — 17.4 GB CSV
+ * — and ingests into the existing `parcel` table. Fills coverage gaps in the
+ * 2021-Present roll (e.g. APN 4349-040-002 which lives in this older dataset
+ * but was missing from the current snapshot).
+ *
+ * Source: ArcGIS Hub item bffc21600e5f408ea6791d1bce7738ae
+ *   download URL: https://www.arcgis.com/sharing/rest/content/items/.../data
+ *
+ * Tier: A (LA County Assessor primary record).
+ */
+import { Pool } from 'pg';
+import { spawn } from 'node:child_process';
+import { createReadStream, statSync, createWriteStream } from 'node:fs';
+import { mkdir, rm } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+const ITEM_ID = 'bffc21600e5f408ea6791d1bce7738ae';
+const ZIP_URL = `https://www.arcgis.com/sharing/rest/content/items/${ITEM_ID}/data`;
+const WORKDIR = '/tmp/la-assessor-legacy';
+const ZIP_PATH = `${WORKDIR}/data.csv`; // legacy is direct CSV (not zipped per metadata)
+const BATCH = 2000;
+
+async function downloadCsv() {
+  console.log(`Downloading ${ZIP_URL} ...`);
+  await mkdir(WORKDIR, { recursive: true });
+  try {
+    const st = statSync(ZIP_PATH);
+    if (st.size > 17_000_000_000) {
+      console.log(`  already downloaded (${(st.size/1024/1024/1024).toFixed(2)} GB), skipping`);
+      return;
+    }
+  } catch { /* not present */ }
+
+  const res = await fetch(ZIP_URL);
+  if (!res.ok) throw new Error(`Download failed: ${res.status}`);
+  const ct = res.headers.get('content-type') ?? '';
+  console.log(`  content-type: ${ct}`);
+  const total = parseInt(res.headers.get('content-length') ?? '0', 10);
+  let received = 0;
+  const w = createWriteStream(ZIP_PATH);
+  const reader = res.body!.getReader();
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    w.write(value);
+    received += value.length;
+    if (received % (200 * 1024 * 1024) < value.length) {
+      console.log(`  ${(received/1024/1024/1024).toFixed(2)} / ${(total/1024/1024/1024).toFixed(2)} GB`);
+    }
+  }
+  w.end();
+  await new Promise(r => w.on('close', r));
+  console.log(`  ✓ saved ${(received/1024/1024/1024).toFixed(2)} GB`);
+}
+
+function splitCsv(line: string): string[] {
+  const out: string[] = [];
+  let cur = '';
+  let inQ = false;
+  for (let i = 0; i < line.length; i++) {
+    const c = line[i];
+    if (inQ) {
+      if (c === '"' && line[i+1] === '"') { cur += '"'; i++; }
+      else if (c === '"') inQ = false;
+      else cur += c;
+    } else {
+      if (c === '"') inQ = true;
+      else if (c === ',') { out.push(cur); cur = ''; }
+      else cur += c;
+    }
+  }
+  out.push(cur);
+  return out;
+}
+
+function pi(s?: string): number | null { if (!s) return null; const n = parseInt(s, 10); return isFinite(n) ? n : null; }
+function pf(s?: string): number | null { if (!s) return null; const n = parseFloat(s); return isFinite(n) ? n : null; }
+
+async function flush(rows: Record<string, string>[]) {
+  const tuples: string[] = [];
+  const values: any[] = [];
+  let ti = 0;
+  for (const r of rows) {
+    const apn = (r.ain ?? r.apn ?? '').replace(/[^0-9]/g, '');
+    if (!apn) continue;
+    // Handles BOTH the current roll (snake_case headers from Parcel_Data_0.csv)
+    // AND the legacy roll (CamelCase headers like RollYear, YearBuilt, TotalValue
+    // from data.csv 2006-2020). Header normalizer strips spaces+special chars +
+    // lowercases, so "RollYear" → "rollyear", "TotalValue" → "totalvalue", etc.
+    const rollYear = pi(r.roll_year ?? r.rollyear);
+    let situs = (r.property_location ?? r.propertylocation ?? '').trim();
+    if (!situs) {
+      const num = (r.address_house_number ?? r.houseno ?? '').trim();
+      const dir = (r.direction ?? r.streetdirection ?? '').trim();
+      const street = (r.street ?? r.streetname ?? '').trim();
+      situs = [num, dir, street].filter(Boolean).join(' ').trim();
+    }
+    const city = (r.city ?? '').trim();
+    const zip = (r.zip_code ?? r.zipcode5 ?? r.zipcode ?? '').trim();
+    const yearBuilt = pi(r.year_built ?? r.yearbuilt ?? r.effective_year ?? r.effectiveyearbuilt);
+    const bldg = pi(r.square_footage ?? r.sqftmain);
+    const beds = pi(r.number_of_bedrooms ?? r.bedrooms);
+    const baths = pf(r.number_of_bathrooms ?? r.bathrooms);
+    const av = pf(r.total_value ?? r.totalvalue);
+    const lv = pf(r.land_value ?? r.landvalue);
+    const iv = pf(r.improvement_value ?? r.improvementvalue);
+    const lat = pf(r.location_latitude ?? r.center_lat);
+    const lon = pf(r.location_longitude ?? r.center_lon);
+    const base = ti * 18;
+    tuples.push(`(${Array.from({length: 18}, (_, k) => `$${base+k+1}`).join(',')})`);
+    values.push(
+      apn, rollYear, 'Los Angeles County', situs || null, city || null, zip || null,
+      r.property_use_code ?? r.propertyusecode ?? null,
+      r.property_use_type ?? r.propertytype ?? r.generalusetype ?? null,
+      yearBuilt, beds, baths, bldg, av, lv, iv, lat, lon,
+      `https://portal.assessor.lacounty.gov/parceldetail/${apn}`,
+    );
+    ti++;
+  }
+  if (tuples.length === 0) return;
+  await pool.query(
+    `INSERT INTO parcel
+       (apn, roll_year, jurisdiction, situs_full, city, zip_code, use_code, use_desc,
+        year_built, bedrooms, bathrooms, bldg_sqft,
+        assessed_total, assessed_land, assessed_improvements,
+        latitude, longitude, source_url)
+     VALUES ${tuples.join(',')}
+     ON CONFLICT (apn, roll_year) DO UPDATE SET
+       situs_full     = COALESCE(EXCLUDED.situs_full, parcel.situs_full),
+       city           = COALESCE(EXCLUDED.city, parcel.city),
+       zip_code       = COALESCE(EXCLUDED.zip_code, parcel.zip_code),
+       year_built     = COALESCE(EXCLUDED.year_built, parcel.year_built),
+       bldg_sqft      = COALESCE(EXCLUDED.bldg_sqft, parcel.bldg_sqft),
+       assessed_total = COALESCE(EXCLUDED.assessed_total, parcel.assessed_total),
+       retrieved_at   = now()`,
+    values
+  );
+}
+
+async function processCsv(csvPath: string) {
+  console.log(`\nStreaming ${csvPath} ...`);
+  const rs = createReadStream(csvPath);
+  const rl = createInterface({ input: rs, crlfDelay: Infinity });
+  let headers: string[] | null = null;
+  const buffer: Record<string, string>[] = [];
+  let total = 0;
+  const t0 = Date.now();
+
+  for await (const line of rl) {
+    if (!line.trim()) continue;
+    const cols = splitCsv(line);
+    if (!headers) {
+      headers = cols.map(h => h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''));
+      console.log(`  fields (${headers.length}): ${headers.slice(0, 12).join(', ')}…`);
+      continue;
+    }
+    const row: Record<string, string> = {};
+    headers.forEach((h, i) => { row[h] = cols[i] ?? ''; });
+    buffer.push(row);
+    if (buffer.length >= BATCH) {
+      await flush(buffer);
+      total += buffer.length;
+      buffer.length = 0;
+      const dt = (Date.now() - t0) / 1000;
+      if (total % 100000 === 0) {
+        console.log(`  ${total.toLocaleString()} parcels (${(total/dt).toFixed(0)}/s, ${(dt/60).toFixed(1)}m elapsed)`);
+      }
+    }
+  }
+  if (buffer.length) {
+    await flush(buffer);
+    total += buffer.length;
+  }
+  console.log(`  ✓ ${total.toLocaleString()} legacy parcels processed`);
+}
+
+async function main() {
+  await downloadCsv();
+  await processCsv(ZIP_PATH);
+  const { rows } = await pool.query<{ n: string; with_value: string; legacy_only: string }>(`
+    SELECT count(*)::text as n,
+           count(*) FILTER (WHERE assessed_total IS NOT NULL)::text as with_value,
+           count(DISTINCT apn) FILTER (WHERE roll_year < 2021)::text as legacy_only
+    FROM parcel
+  `);
+  console.log(`\nparcel: ${parseInt(rows[0].n).toLocaleString()} total, ${parseInt(rows[0].with_value).toLocaleString()} with AV, ${parseInt(rows[0].legacy_only).toLocaleString()} pre-2021 unique APNs`);
+
+  // verify Steve's APN now resolves
+  const { rows: target } = await pool.query<any>(`SELECT roll_year, situs_full, city, year_built, assessed_total::text as av FROM parcel WHERE apn = '4349040002' ORDER BY roll_year DESC LIMIT 5`);
+  if (target.length) {
+    console.log(`\n4349-040-002 found: ${target.length} roll years, latest=${target[0].roll_year}, situs="${target[0].situs_full}", AV=${target[0].av}`);
+  } else {
+    console.log(`\n4349-040-002 still NOT in parcel table (may be exempt/government parcel)`);
+  }
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-boe-permits.ts b/scripts/ingest-la-boe-permits.ts
new file mode 100644
index 0000000..2cf6656
--- /dev/null
+++ b/scripts/ingest-la-boe-permits.ts
@@ -0,0 +1,118 @@
+/**
+ * ingest-la-boe-permits.ts
+ *
+ * LA Bureau of Engineering permits (j7mw-thyc, ~626K rows).
+ * ROW/sidewalk/sewer/curb-cut work permits — historical infrastructure changes
+ * per address.
+ *
+ * Different schema than building permits: id, location, permitname, permitno,
+ * permittype, permitissuedate, permiturl, refno.
+ *
+ * Tier: A (LA Bureau of Engineering primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+const SLUG = 'j7mw-thyc';
+const PAGE = 50000;
+const BATCH = 1000;
+
+type Row = {
+  id?: string;
+  location?: string;
+  permitname?: string;
+  permitno?: string;
+  permittype?: string;
+  permitissuedate?: string;
+  permiturl?: string;
+  refno?: string;
+};
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+function pd(v?: string): string | null { if (!v) return null; const m = v.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; }
+
+async function fetchPage(offset: number): Promise<Row[]> {
+  const u = `https://data.lacity.org/resource/${SLUG}.json?$limit=${PAGE}&$offset=${offset}&$order=permitno`;
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const res = await fetch(u, { headers: { 'User-Agent': 'stayclaim-ingest/1.0' } });
+      if (res.status === 429 || res.status === 503) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; }
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return await res.json() as Row[];
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function processBatch(rows: Row[]) {
+  const seen = new Set<string>();
+  const valid = rows.filter(r => r.permitno && !seen.has(r.permitno) && (seen.add(r.permitno), true));
+  if (valid.length === 0) return 0;
+  const tuples: string[] = [];
+  const values: any[] = [];
+  valid.forEach((r, idx) => {
+    const addr = (r.location ?? '').trim();
+    const base = idx * 9;
+    tuples.push(`(${Array.from({length: 9}, (_, k) => `$${base+k+1}`).join(',')})`);
+    values.push(
+      r.permitno!.trim(),                           // 1 permit_number
+      addr || null,                                 // 2 primary_address
+      r.permitname ?? null,                         // 3 work_desc
+      r.permittype ?? null,                         // 4 permit_type
+      pd(r.permitissuedate),                        // 5 issue_date
+      r.permiturl ?? `https://data.lacity.org/resource/${SLUG}.json?permitno=${encodeURIComponent(r.permitno!)}`, // 6 source_url
+      r.refno ?? null,                              // 7 zone (re-purposed for refno display)
+      r.permitname ?? null,                         // 8 use_desc
+      addr,                                         // 9 — address for join
+    );
+  });
+  await pool.query(
+    `INSERT INTO permit (source, source_dataset, permit_number, primary_address,
+       work_desc, permit_type, issue_date, source_url, zone, use_desc, listing_id, source_label)
+     SELECT 'la_city_boe', '${SLUG}', t.pn, t.addr, t.wd, t.pt, t.id_, t.url, t.zo, t.ud, l.id,
+            'LA Bureau of Engineering Permits'
+     FROM (VALUES ${tuples.map((_, k) => {
+       const b = k * 9;
+       return `($${b+1},$${b+2},$${b+3},$${b+4},$${b+5}::date,$${b+6},$${b+7},$${b+8},$${b+9})`;
+     }).join(',')}) AS t(pn, addr, wd, pt, id_, url, zo, ud, joinaddr)
+     LEFT JOIN listing l ON l.source = 'ladbs_permit'
+                       AND l.source_id = 'ladbs:' || lower(regexp_replace(regexp_replace(coalesce(t.joinaddr,''), '[^\\w\\s-]', '', 'g'), '\\s+', '-', 'g'))
+     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+       listing_id = COALESCE(permit.listing_id, EXCLUDED.listing_id),
+       retrieved_at = now()`,
+    values
+  );
+  return valid.length;
+}
+
+async function main() {
+  let offset = 0, total = 0;
+  const t0 = Date.now();
+  while (true) {
+    const rows = await fetchPage(offset);
+    if (rows.length === 0) break;
+    for (let i = 0; i < rows.length; i += BATCH) await processBatch(rows.slice(i, i + BATCH));
+    total += rows.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  boe: ${total.toLocaleString()} (${(total/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE) break;
+    offset += PAGE;
+  }
+  console.log(`✓ BOE permits: ${total.toLocaleString()} total`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-business-licenses.ts b/scripts/ingest-la-business-licenses.ts
new file mode 100644
index 0000000..2301cb4
--- /dev/null
+++ b/scripts/ingest-la-business-licenses.ts
@@ -0,0 +1,179 @@
+/**
+ * ingest-la-business-licenses.ts
+ *
+ * LA City "Listing of Active Businesses" (619K rows) — historical commercial
+ * tenants by address. Same Socrata pattern as LADBS permits.
+ *
+ * Source: data.lacity.org dataset 6rrh-rzua
+ *
+ * Each row creates a business_license record linked to a listing matched by
+ * primary_address (LADBS-style canonicalization).
+ *
+ * Tier: A (LA City Office of Finance primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+const SLUG = '6rrh-rzua';
+const PAGE = 50000;
+const BATCH = 1000;
+
+type Row = {
+  location_account?: string;
+  business_name?: string;
+  dba_name?: string;
+  street_address?: string;
+  city?: string;
+  zip_code?: string;
+  location_description?: string;
+  mailing_address?: string;
+  mailing_city?: string;
+  naics?: string;
+  primary_naics_description?: string;
+  council_district?: string;
+  location_start_date?: string;
+  location_end_date?: string;
+  location_1?: { latitude?: string; longitude?: string };
+};
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+function pd(v?: string): string | null { if (!v) return null; const m = v.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; }
+
+async function ensureSchema() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS business_license (
+      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+      source TEXT NOT NULL DEFAULT 'la_city',
+      source_dataset TEXT NOT NULL DEFAULT '6rrh-rzua',
+      account_number TEXT NOT NULL,
+      listing_id UUID REFERENCES listing(id),
+      business_name TEXT,
+      dba_name TEXT,
+      primary_address TEXT,
+      city TEXT,
+      zip_code TEXT,
+      naics TEXT,
+      naics_description TEXT,
+      council_district TEXT,
+      location_start_date DATE,
+      location_end_date DATE,
+      latitude NUMERIC(9,6),
+      longitude NUMERIC(9,6),
+      source_url TEXT,
+      source_label TEXT NOT NULL DEFAULT 'LA City Office of Finance — Active Businesses',
+      source_tier CHAR(1) NOT NULL DEFAULT 'A',
+      retrieved_at TIMESTAMPTZ DEFAULT now(),
+      UNIQUE (account_number)
+    );
+    CREATE INDEX IF NOT EXISTS idx_bl_listing ON business_license(listing_id);
+    CREATE INDEX IF NOT EXISTS idx_bl_naics ON business_license(naics);
+    CREATE INDEX IF NOT EXISTS idx_bl_address ON business_license(primary_address);
+    CREATE INDEX IF NOT EXISTS idx_bl_dates ON business_license(location_start_date, location_end_date);
+  `);
+}
+
+async function fetchPage(offset: number): Promise<Row[]> {
+  const u = `https://data.lacity.org/resource/${SLUG}.json?$limit=${PAGE}&$offset=${offset}&$order=location_account`;
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const res = await fetch(u, { headers: { 'User-Agent': 'stayclaim-ingest/1.0' } });
+      if (res.status === 429 || res.status === 503) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; }
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return await res.json() as Row[];
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function processBatch(rows: Row[]) {
+  // Match by primary_address against existing LADBS listings
+  const seen = new Set<string>();
+  const valid = rows.filter(r => r.location_account && !seen.has(r.location_account) && (seen.add(r.location_account), true));
+  if (valid.length === 0) return 0;
+  const tuples: string[] = [];
+  const values: any[] = [];
+  valid.forEach((r, idx) => {
+    const addr = (r.street_address ?? '').trim();
+    const canon = addr ? canonicalize(addr) : '';
+    const lat = r.location_1?.latitude ? parseFloat(r.location_1.latitude) : null;
+    const lon = r.location_1?.longitude ? parseFloat(r.location_1.longitude) : null;
+    const base = idx * 13;
+    tuples.push(`(${Array.from({length: 13}, (_, k) => `$${base+k+1}`).join(',')})`);
+    values.push(
+      r.location_account,
+      r.business_name ?? null,
+      r.dba_name ?? null,
+      addr || null,
+      r.city ?? null,
+      r.zip_code ?? null,
+      r.naics ?? null,
+      r.primary_naics_description ?? null,
+      r.council_district ?? null,
+      pd(r.location_start_date),
+      pd(r.location_end_date),
+      lat,
+      lon,
+    );
+  });
+  await pool.query(
+    `INSERT INTO business_license
+       (account_number, business_name, dba_name, primary_address, city, zip_code,
+        naics, naics_description, council_district,
+        location_start_date, location_end_date, latitude, longitude,
+        listing_id, source_url)
+     SELECT t.acct, t.bn, t.dba, t.addr, t.city, t.zip, t.naics, t.naicsd, t.cd,
+            t.startd, t.endd, t.lat, t.lon, l.id,
+            'https://data.lacity.org/resource/6rrh-rzua.json?location_account=' || t.acct
+     FROM (VALUES ${tuples.map((_, k) => {
+       const b = k * 13;
+       return `($${b+1}::text,$${b+2}::text,$${b+3}::text,$${b+4}::text,$${b+5}::text,$${b+6}::text,$${b+7}::text,$${b+8}::text,$${b+9}::text,$${b+10}::date,$${b+11}::date,$${b+12}::numeric,$${b+13}::numeric)`;
+     }).join(',')}) AS t(acct, bn, dba, addr, city, zip, naics, naicsd, cd, startd, endd, lat, lon)
+     LEFT JOIN listing l ON l.source = 'ladbs_permit'
+                       AND l.source_id = 'ladbs:' || lower(regexp_replace(regexp_replace(coalesce(t.addr,''), '[^\\w\\s-]', '', 'g'), '\\s+', '-', 'g'))
+     ON CONFLICT (account_number) DO UPDATE SET
+       business_name = COALESCE(EXCLUDED.business_name, business_license.business_name),
+       location_end_date = COALESCE(EXCLUDED.location_end_date, business_license.location_end_date),
+       listing_id = COALESCE(business_license.listing_id, EXCLUDED.listing_id),
+       retrieved_at = now()`,
+    values
+  );
+  return valid.length;
+}
+
+async function main() {
+  await ensureSchema();
+  let offset = 0, total = 0;
+  const t0 = Date.now();
+  while (true) {
+    const rows = await fetchPage(offset);
+    if (rows.length === 0) break;
+    for (let i = 0; i < rows.length; i += BATCH) {
+      await processBatch(rows.slice(i, i + BATCH));
+    }
+    total += rows.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  business_license: ${total.toLocaleString()} (${(total/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE) break;
+    offset += PAGE;
+  }
+  const { rows: stats } = await pool.query<{ n: string; matched: string }>(
+    `SELECT count(*)::text as n, count(*) FILTER (WHERE listing_id IS NOT NULL)::text as matched FROM business_license`
+  );
+  console.log(`✓ ${parseInt(stats[0]!.n).toLocaleString()} businesses (${parseInt(stats[0]!.matched).toLocaleString()} matched to a listing)`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-city-permits.ts b/scripts/ingest-la-city-permits.ts
new file mode 100644
index 0000000..255dba4
--- /dev/null
+++ b/scripts/ingest-la-city-permits.ts
@@ -0,0 +1,391 @@
+/**
+ * ingest-la-city-permits.ts
+ *
+ * Pulls all 1,560,839 LA City Department of Building & Safety building permits
+ * from the three Socrata datasets on data.lacity.org:
+ *
+ *   - pi9x-tg5x  (2020→present, ~387K rows, daily refresh)  ← canonical replacement for stale xnhu-aczu
+ *   - dyxf-7hc4  (2010-2019,    ~533K rows, static)
+ *   - e67z-kt2n  (<2010,        ~640K rows, static)
+ *
+ * Verified field shape (consistent across all 3): permit_nbr, primary_address,
+ * zip_code, cd, apn, zone, permit_group, permit_type, permit_sub_type,
+ * use_code, use_desc, issue_date, status_desc, valuation, lat, lon, plus
+ * dataset-specific extras (square_footage on dyxf+e67z, construction on e67z).
+ *
+ * For each row:
+ *   1. Upsert a `listing` row (source='ladbs_permit', source_id=primary_address) —
+ *      one listing per distinct LA-City address, geocoded.
+ *   2. Insert a `permit` row linked to that listing.
+ *
+ * Idempotency: ON CONFLICT (source_dataset, permit_number) DO UPDATE for permits;
+ * ON CONFLICT (source, source_id) DO UPDATE COALESCE for listings.
+ *
+ * Batched 1000 rows per upsert; ~50K-row Socrata pages. Resume-safe via
+ * --start-page and --dataset flags.
+ *
+ * Tier: A (LA City government primary record).
+ *
+ * Usage: npx tsx scripts/ingest-la-city-permits.ts
+ *          [--dataset=pi9x-tg5x|dyxf-7hc4|e67z-kt2n|all]   default: all
+ *          [--start-page=N]                                 default: 0
+ *          [--page-size=50000]                              default: 50000
+ *          [--app-token=XXXXXXXX]                           optional Socrata token
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 8,
+});
+
+const args = Object.fromEntries(
+  process.argv.slice(2).map(a => {
+    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+    return m ? [m[1], m[2] ?? 'true'] : [a, 'true'];
+  })
+);
+const ONLY_DATASET = args.dataset as string | undefined;
+const START_PAGE   = args['start-page'] ? parseInt(args['start-page'] as string, 10) : 0;
+const PAGE_SIZE    = args['page-size'] ? parseInt(args['page-size'] as string, 10) : 50000;
+const APP_TOKEN    = (args['app-token'] as string) || process.env.SOCRATA_APP_TOKEN || undefined;
+const BATCH_INSERT_SIZE = 1000;
+
+type Dataset = { slug: string; expectedRows: number };
+const DATASETS: Dataset[] = [
+  { slug: 'pi9x-tg5x', expectedRows: 387531 },
+  { slug: 'dyxf-7hc4', expectedRows: 533366 },
+  { slug: 'e67z-kt2n', expectedRows: 639942 },
+];
+
+type SocrataRow = {
+  permit_nbr?: string;
+  primary_address?: string;
+  zip_code?: string;
+  cd?: string;
+  apn?: string;
+  zone?: string;
+  permit_group?: string;
+  permit_type?: string;
+  permit_sub_type?: string;
+  use_code?: string;
+  use_desc?: string;
+  issue_date?: string;
+  status_desc?: string;
+  status_date?: string;
+  submitted_date?: string;
+  valuation?: string;
+  lat?: string;
+  lon?: string;
+  square_footage?: string;
+  work_desc?: string;
+  cnc?: string;
+  cpa?: string;
+};
+
+async function ensureSchema() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS permit (
+      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+      source TEXT NOT NULL DEFAULT 'ladbs',
+      source_dataset TEXT NOT NULL,
+      permit_number TEXT NOT NULL,
+      listing_id UUID REFERENCES listing(id),
+      primary_address TEXT,
+      zip_code TEXT,
+      council_district TEXT,
+      apn TEXT,
+      zone TEXT,
+      permit_group TEXT,
+      permit_type TEXT,
+      permit_sub_type TEXT,
+      use_code TEXT,
+      use_desc TEXT,
+      issue_date DATE,
+      status_date DATE,
+      submitted_date DATE,
+      status_desc TEXT,
+      valuation NUMERIC(15,2),
+      square_footage INT,
+      work_desc TEXT,
+      neighborhood_council TEXT,
+      community_plan_area TEXT,
+      latitude NUMERIC(9,6),
+      longitude NUMERIC(9,6),
+      source_url TEXT,
+      source_tier CHAR(1) NOT NULL DEFAULT 'A',
+      source_label TEXT NOT NULL DEFAULT 'LA City Department of Building & Safety',
+      retrieved_at TIMESTAMPTZ DEFAULT now(),
+      UNIQUE (source_dataset, permit_number)
+    );
+    CREATE INDEX IF NOT EXISTS idx_permit_listing ON permit(listing_id);
+    CREATE INDEX IF NOT EXISTS idx_permit_address ON permit(primary_address);
+    CREATE INDEX IF NOT EXISTS idx_permit_apn ON permit(apn);
+    CREATE INDEX IF NOT EXISTS idx_permit_zip_date ON permit(zip_code, issue_date);
+    CREATE INDEX IF NOT EXISTS idx_permit_issue_date ON permit(issue_date);
+    CREATE INDEX IF NOT EXISTS idx_permit_council_district ON permit(council_district);
+  `);
+}
+
+function parseNum(v?: string): number | null {
+  if (!v) return null;
+  const n = parseFloat(v);
+  return isFinite(n) ? n : null;
+}
+
+function parseInt2(v?: string): number | null {
+  if (!v) return null;
+  const n = parseInt(v, 10);
+  return isFinite(n) ? n : null;
+}
+
+function parseDate(v?: string): string | null {
+  if (!v) return null;
+  const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
+  return m ? m[1] : null;
+}
+
+function buildSourceUrl(slug: string, permitNbr: string): string {
+  // Direct Socrata row-filter URL: dataset page, filtered to that one permit.
+  // This lets a user click through and see the canonical record.
+  return `https://data.lacity.org/resource/${slug}.json?permit_nbr=${encodeURIComponent(permitNbr)}`;
+}
+
+/**
+ * Fetch one Socrata page. Returns rows or empty array if past the end.
+ */
+async function fetchPage(slug: string, offset: number, limit: number): Promise<SocrataRow[]> {
+  const url = new URL(`https://data.lacity.org/resource/${slug}.json`);
+  url.searchParams.set('$limit', String(limit));
+  url.searchParams.set('$offset', String(offset));
+  url.searchParams.set('$order', 'permit_nbr');
+  if (APP_TOKEN) url.searchParams.set('$$app_token', APP_TOKEN);
+  for (let attempt = 1; attempt <= 5; attempt++) {
+    try {
+      const res = await fetch(url, { headers: { 'User-Agent': 'stayclaim-ingest/1.0' } });
+      if (res.status === 429 || res.status === 503) {
+        await new Promise(r => setTimeout(r, 2000 * attempt));
+        continue;
+      }
+      if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
+      return (await res.json()) as SocrataRow[];
+    } catch (e) {
+      if (attempt >= 5) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+/**
+ * Bulk-upsert listings for a batch of (address, lat, lon, zip) tuples.
+ * Dedupes by primary_address (the source_id).
+ * Returns a map of primary_address → listing_id.
+ */
+function canonicalize(addr: string): string {
+  return addr
+    .toLowerCase()
+    .replace(/[^\w\s-]/g, '')
+    .replace(/\s+/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .slice(0, 90);
+}
+
+async function upsertListings(rows: SocrataRow[]): Promise<Map<string, string>> {
+  // Dedup by canonical (slug-equivalent) form so within-batch collisions are
+  // collapsed before the INSERT. Map original raw address → canonical so
+  // upsertPermits can look up listing_id by primary_address.
+  const dedup = new Map<string, { addr: string; lat: number | null; lon: number | null; zip: string | null }>();
+  const rawToCanonical = new Map<string, string>();
+  for (const r of rows) {
+    if (!r.primary_address) continue;
+    const addr = r.primary_address.trim();
+    if (!addr) continue;
+    const canonical = canonicalize(addr);
+    if (!canonical) continue;
+    rawToCanonical.set(addr, canonical);
+    if (dedup.has(canonical)) {
+      // merge: prefer non-null lat/lon/zip
+      const ex = dedup.get(canonical)!;
+      const lat = parseNum(r.lat);
+      const lon = parseNum(r.lon);
+      if (ex.lat == null && lat != null) ex.lat = lat;
+      if (ex.lon == null && lon != null) ex.lon = lon;
+      if (!ex.zip && r.zip_code) ex.zip = r.zip_code;
+      continue;
+    }
+    dedup.set(canonical, {
+      addr,
+      lat: parseNum(r.lat),
+      lon: parseNum(r.lon),
+      zip: r.zip_code ?? null,
+    });
+  }
+  // canonical → listing.id
+  const canonicalToListing = new Map<string, string>();
+  if (dedup.size === 0) {
+    return new Map();
+  }
+
+  const arr = Array.from(dedup.entries());
+  for (let i = 0; i < arr.length; i += BATCH_INSERT_SIZE) {
+    const slice = arr.slice(i, i + BATCH_INSERT_SIZE);
+    const tuples: string[] = [];
+    const values: any[] = [];
+    slice.forEach(([canonical, r], idx) => {
+      const base = idx * 7;
+      tuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7})`);
+      const slug = `${canonical}-ladbs`;
+      const sourceId = `ladbs:${canonical}`;
+      values.push(
+        slug,           // 1 slug
+        sourceId,       // 2 source_id
+        r.addr,         // 3 title
+        r.addr,         // 4 address_line1
+        r.zip,          // 5 postal_code
+        r.lat,          // 6 latitude
+        r.lon,          // 7 longitude
+      );
+    });
+    const sql = `
+      INSERT INTO listing
+        (slug, source, source_id, title, address_line1, city, state, country, postal_code, latitude, longitude, is_public, tier)
+      VALUES ${tuples.map((t, i) => {
+        const base = i * 7;
+        return `($${base+1},'ladbs_permit',$${base+2},$${base+3},$${base+4},'Los Angeles','CA','US',$${base+5},$${base+6},$${base+7},true,'free')`;
+      }).join(',')}
+      ON CONFLICT (source, source_id) DO UPDATE SET
+        latitude    = COALESCE(listing.latitude,    EXCLUDED.latitude),
+        longitude   = COALESCE(listing.longitude,   EXCLUDED.longitude),
+        postal_code = COALESCE(listing.postal_code, EXCLUDED.postal_code),
+        updated_at  = now()
+      RETURNING id, source_id
+    `;
+    const r = await pool.query<{ id: string; source_id: string }>(sql, values);
+    for (const row of r.rows) {
+      const canonical = row.source_id.replace(/^ladbs:/, '');
+      canonicalToListing.set(canonical, row.id);
+    }
+  }
+  // Resolve raw → listing via canonical
+  const result = new Map<string, string>();
+  for (const [raw, canonical] of rawToCanonical) {
+    const lid = canonicalToListing.get(canonical);
+    if (lid) result.set(raw, lid);
+  }
+  return result;
+}
+
+async function upsertPermits(slug: string, rows: SocrataRow[], listingMap: Map<string, string>): Promise<number> {
+  const valid = rows.filter(r => r.permit_nbr && r.permit_nbr.trim());
+  if (valid.length === 0) return 0;
+  let inserted = 0;
+  for (let i = 0; i < valid.length; i += BATCH_INSERT_SIZE) {
+    const slice = valid.slice(i, i + BATCH_INSERT_SIZE);
+    const tuples: string[] = [];
+    const values: any[] = [];
+    slice.forEach((r, idx) => {
+      const base = idx * 21;
+      tuples.push(`(${Array.from({length: 21}, (_, k) => `$${base+k+1}`).join(',')})`);
+      values.push(
+        slug,                                                   // 1 source_dataset
+        r.permit_nbr!.trim(),                                   // 2 permit_number
+        r.primary_address ? listingMap.get(r.primary_address.trim()) ?? null : null, // 3 listing_id
+        r.primary_address ?? null,                              // 4 primary_address
+        r.zip_code ?? null,                                     // 5 zip_code
+        r.cd ?? null,                                           // 6 council_district
+        r.apn ?? null,                                          // 7 apn
+        r.zone ?? null,                                         // 8 zone
+        r.permit_group ?? null,                                 // 9 permit_group
+        r.permit_type ?? null,                                  // 10 permit_type
+        r.permit_sub_type ?? null,                              // 11 permit_sub_type
+        r.use_code ?? null,                                     // 12 use_code
+        r.use_desc ?? null,                                     // 13 use_desc
+        parseDate(r.issue_date),                                // 14 issue_date
+        parseDate(r.status_date),                               // 15 status_date
+        parseDate(r.submitted_date),                            // 16 submitted_date
+        r.status_desc ?? null,                                  // 17 status_desc
+        parseNum(r.valuation),                                  // 18 valuation
+        parseInt2(r.square_footage),                            // 19 square_footage
+        r.work_desc ?? null,                                    // 20 work_desc
+        buildSourceUrl(slug, r.permit_nbr!.trim()),             // 21 source_url
+      );
+    });
+    const sql = `
+      INSERT INTO permit
+        (source_dataset, permit_number, listing_id, primary_address, zip_code, council_district,
+         apn, zone, permit_group, permit_type, permit_sub_type, use_code, use_desc,
+         issue_date, status_date, submitted_date, status_desc, valuation, square_footage,
+         work_desc, source_url)
+      VALUES ${tuples.join(',')}
+      ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+        listing_id     = COALESCE(EXCLUDED.listing_id, permit.listing_id),
+        status_date    = COALESCE(EXCLUDED.status_date, permit.status_date),
+        status_desc    = COALESCE(EXCLUDED.status_desc, permit.status_desc),
+        retrieved_at   = now()
+    `;
+    const r = await pool.query(sql, values);
+    inserted += r.rowCount ?? 0;
+  }
+  return inserted;
+}
+
+async function ingestDataset(ds: Dataset) {
+  console.log(`\n=== ${ds.slug} (~${ds.expectedRows.toLocaleString()} rows) ===`);
+  const t0 = Date.now();
+  let offset = START_PAGE * PAGE_SIZE;
+  let pulled = 0, listingsTouched = 0, permitsInserted = 0;
+  while (true) {
+    const tFetch = Date.now();
+    const rows = await fetchPage(ds.slug, offset, PAGE_SIZE);
+    if (rows.length === 0) break;
+    const listingMap = await upsertListings(rows);
+    const inserted = await upsertPermits(ds.slug, rows, listingMap);
+    pulled += rows.length;
+    listingsTouched += listingMap.size;
+    permitsInserted += inserted;
+    const dt = (Date.now() - t0) / 1000;
+    const dtFetch = (Date.now() - tFetch) / 1000;
+    console.log(`  page off=${offset} rows=${rows.length} listings=${listingMap.size} permits=${inserted} (page=${dtFetch.toFixed(1)}s, total ${pulled.toLocaleString()}/${ds.expectedRows.toLocaleString()} @ ${(pulled/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE_SIZE) break;
+    offset += PAGE_SIZE;
+  }
+  const dt = (Date.now() - t0) / 1000;
+  console.log(`  ✓ ${ds.slug}: pulled=${pulled.toLocaleString()} listings=${listingsTouched.toLocaleString()} permits=${permitsInserted.toLocaleString()} elapsed=${(dt/60).toFixed(1)}m`);
+}
+
+async function main() {
+  await ensureSchema();
+  const datasets = ONLY_DATASET && ONLY_DATASET !== 'all'
+    ? DATASETS.filter(d => d.slug === ONLY_DATASET)
+    : DATASETS;
+  if (datasets.length === 0) {
+    console.error(`Unknown dataset: ${ONLY_DATASET}`);
+    process.exit(1);
+  }
+  const t0 = Date.now();
+  for (const ds of datasets) {
+    await ingestDataset(ds);
+  }
+  const dt = (Date.now() - t0) / 1000;
+  console.log(`\n=== ALL DONE in ${(dt/60).toFixed(1)}m ===`);
+  const { rows } = await pool.query<{ ds: string; n: string }>(
+    `SELECT source_dataset as ds, count(*)::text as n FROM permit GROUP BY source_dataset ORDER BY source_dataset`
+  );
+  for (const r of rows) console.log(`  ${r.ds}: ${parseInt(r.n).toLocaleString()} permits`);
+  const { rows: lrows } = await pool.query<{ n: string }>(
+    `SELECT count(*)::text as n FROM listing WHERE source = 'ladbs_permit'`
+  );
+  console.log(`  listings: ${parseInt(lrows[0].n).toLocaleString()}`);
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error('FATAL', e);
+  process.exit(1);
+});
diff --git a/scripts/ingest-la-code-enforcement.ts b/scripts/ingest-la-code-enforcement.ts
new file mode 100644
index 0000000..aab5f42
--- /dev/null
+++ b/scripts/ingest-la-code-enforcement.ts
@@ -0,0 +1,169 @@
+/**
+ * ingest-la-code-enforcement.ts
+ *
+ * LA City code enforcement cases via Socrata.
+ *   - u82d-eh7z (Open):    ~28,931 rows
+ *   - rken-a55j (Closed): ~810,798 rows
+ *
+ * Stores as place_event rows with source='la_city_code', source_id=apno.
+ * Links to existing listing via address match (from LADBS ingest).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 8,
+});
+
+const PAGE = 50000;
+const BATCH = 1000;
+
+type SrcRow = {
+  apno?: string;       // case number
+  prclid?: string;     // parcel id
+  aptype?: string;     // case type
+  stat?: string;       // status
+  adddttm?: string;    // added date
+  housenumberhigh?: string;
+  housenumber?: string;
+  streetname?: string;
+  streetdirection?: string;
+  streetsuffix?: string;
+  zipcode?: string;
+  cd?: string;
+};
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+function buildAddress(r: SrcRow): string | null {
+  const parts = [r.housenumber, r.streetdirection, r.streetname, r.streetsuffix].filter(Boolean);
+  const addr = parts.join(' ').trim();
+  return addr || null;
+}
+
+async function ensureSchema() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS code_enforcement_case (
+      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+      source TEXT NOT NULL,
+      source_dataset TEXT NOT NULL,
+      case_number TEXT NOT NULL,
+      listing_id UUID REFERENCES listing(id),
+      primary_address TEXT,
+      apn TEXT,
+      zip_code TEXT,
+      council_district TEXT,
+      case_type TEXT,
+      status TEXT,
+      opened_at DATE,
+      source_url TEXT,
+      source_label TEXT NOT NULL DEFAULT 'LA City Department of Building & Safety',
+      source_tier CHAR(1) NOT NULL DEFAULT 'A',
+      retrieved_at TIMESTAMPTZ DEFAULT now(),
+      UNIQUE (source_dataset, case_number)
+    );
+    CREATE INDEX IF NOT EXISTS idx_cec_listing ON code_enforcement_case(listing_id);
+    CREATE INDEX IF NOT EXISTS idx_cec_apn ON code_enforcement_case(apn);
+    CREATE INDEX IF NOT EXISTS idx_cec_status ON code_enforcement_case(status);
+    CREATE INDEX IF NOT EXISTS idx_cec_opened ON code_enforcement_case(opened_at);
+  `);
+}
+
+async function fetchPage(slug: string, offset: number): Promise<SrcRow[]> {
+  const u = `https://data.lacity.org/resource/${slug}.json?$limit=${PAGE}&$offset=${offset}&$order=apno`;
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const res = await fetch(u, { headers: { 'User-Agent': 'stayclaim-ingest/1.0' } });
+      if (res.status === 429 || res.status === 503) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; }
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return await res.json() as SrcRow[];
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function processSlug(slug: string, dataset: string) {
+  console.log(`\n=== ${dataset} (${slug}) ===`);
+  let offset = 0;
+  let total = 0;
+  const t0 = Date.now();
+  while (true) {
+    const rows = await fetchPage(slug, offset);
+    if (!rows.length) break;
+
+    // bulk insert; dedupe by case_number within batch first, then resolve listing_id.
+    for (let i = 0; i < rows.length; i += BATCH) {
+      const slice = rows.slice(i, i + BATCH);
+      const seen = new Set<string>();
+      const dedup: SrcRow[] = [];
+      for (const r of slice) {
+        if (!r.apno) continue;
+        if (seen.has(r.apno)) continue;
+        seen.add(r.apno);
+        dedup.push(r);
+      }
+      const tuples: string[] = [];
+      const values: any[] = [];
+      dedup.forEach((r, idx) => {
+        const addr = buildAddress(r);
+        const base = idx * 9;
+        tuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8},$${base+9})`);
+        values.push(
+          slug,                                                      // 1 source_dataset
+          r.apno,                                                    // 2 case_number
+          addr,                                                      // 3 primary_address
+          r.prclid ?? null,                                          // 4 apn
+          r.zipcode ?? null,                                         // 5 zip_code
+          r.cd ?? null,                                              // 6 council_district
+          r.aptype ?? null,                                          // 7 case_type
+          r.stat ?? null,                                            // 8 status
+          r.adddttm ? new Date(r.adddttm).toISOString().slice(0, 10) : null, // 9 opened_at
+        );
+      });
+      if (tuples.length === 0) continue;
+      await pool.query(
+        `INSERT INTO code_enforcement_case
+           (source, source_dataset, case_number, primary_address, apn, zip_code, council_district, case_type, status, opened_at, listing_id, source_url)
+         SELECT 'la_city', t.dataset, t.cn, t.addr, t.apn, t.zip, t.cd, t.ct, t.st, t.opened, l.id,
+                'https://data.lacity.org/resource/' || t.dataset || '.json?apno=' || t.cn
+         FROM (VALUES ${tuples.map((tp, k) => {
+           const b = k * 9;
+           return `($${b+1},$${b+2},$${b+3},$${b+4},$${b+5},$${b+6},$${b+7},$${b+8},$${b+9}::date)`;
+         }).join(',')}) AS t(dataset, cn, addr, apn, zip, cd, ct, st, opened)
+         LEFT JOIN listing l ON l.source='ladbs_permit' AND l.source_id = 'ladbs:' || lower(regexp_replace(regexp_replace(coalesce(t.addr,''), '[^\\w\\s-]', '', 'g'), '\\s+', '-', 'g'))
+         ON CONFLICT (source_dataset, case_number) DO UPDATE SET
+           status = COALESCE(EXCLUDED.status, code_enforcement_case.status),
+           listing_id = COALESCE(code_enforcement_case.listing_id, EXCLUDED.listing_id),
+           retrieved_at = now()`,
+        values
+      );
+    }
+    total += rows.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  ${slug}: ${total} (${(total/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE) break;
+    offset += PAGE;
+  }
+}
+
+async function main() {
+  await ensureSchema();
+  await processSlug('u82d-eh7z', 'open');
+  await processSlug('rken-a55j', 'closed');
+  const { rows } = await pool.query<{ ds: string; n: string; matched: string }>(`
+    SELECT source_dataset as ds, count(*)::text as n, count(*) FILTER (WHERE listing_id IS NOT NULL)::text as matched
+    FROM code_enforcement_case GROUP BY source_dataset`);
+  for (const r of rows) console.log(`  ${r.ds}: ${parseInt(r.n).toLocaleString()} cases (${parseInt(r.matched).toLocaleString()} matched to listing)`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-cofo.ts b/scripts/ingest-la-cofo.ts
new file mode 100644
index 0000000..17b9e44
--- /dev/null
+++ b/scripts/ingest-la-cofo.ts
@@ -0,0 +1,144 @@
+/**
+ * ingest-la-cofo.ts
+ *
+ * LA City Certificate of Occupancy (3f9m-afei, ~88K rows).
+ * Fields: cofo_number, address_start, street_direction, street_name, street_suffix,
+ *         issue_date, latitude_longitude, valuation, work_description, etc.
+ *
+ * Tier: A (LADBS primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+const SLUG = '3f9m-afei';
+const PAGE = 50000;
+const BATCH = 1000;
+
+type Row = {
+  cofo_number?: string;
+  pcis_permit?: string;
+  address_start?: string;
+  address_end?: string;
+  street_direction?: string;
+  street_name?: string;
+  street_suffix?: string;
+  zip_code?: string;
+  zone?: string;
+  permit_type?: string;
+  permit_sub_type?: string;
+  permit_category?: string;
+  issue_date?: string;
+  cofo_issue_date?: string;
+  latest_status?: string;
+  status_date?: string;
+  valuation?: string;
+  work_description?: string;
+  assessor_parcel?: string;
+  latitude_longitude?: { latitude?: string; longitude?: string };
+};
+
+function pd(v?: string): string | null { if (!v) return null; const m = v.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; }
+function pn(v?: string): number | null { if (!v) return null; const n = parseFloat(v); return isFinite(n) ? n : null; }
+
+function buildAddress(r: Row): string | null {
+  const parts = [r.address_start, r.street_direction, r.street_name, r.street_suffix].filter(Boolean);
+  const a = parts.join(' ').trim();
+  return a || null;
+}
+
+async function fetchPage(offset: number): Promise<Row[]> {
+  const u = `https://data.lacity.org/resource/${SLUG}.json?$limit=${PAGE}&$offset=${offset}&$order=cofo_number`;
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const res = await fetch(u, { headers: { 'User-Agent': 'stayclaim-ingest/1.0' } });
+      if (res.status === 429 || res.status === 503) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; }
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return await res.json() as Row[];
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function processBatch(rows: Row[]) {
+  const seen = new Set<string>();
+  const valid = rows.filter(r => r.cofo_number && !seen.has(r.cofo_number) && (seen.add(r.cofo_number), true));
+  if (valid.length === 0) return 0;
+  const tuples: string[] = [];
+  const values: any[] = [];
+  valid.forEach((r, idx) => {
+    const addr = buildAddress(r);
+    const lat = r.latitude_longitude?.latitude ? parseFloat(r.latitude_longitude.latitude) : null;
+    const lon = r.latitude_longitude?.longitude ? parseFloat(r.latitude_longitude.longitude) : null;
+    const base = idx * 14;
+    tuples.push(`(${Array.from({length: 14}, (_, k) => `$${base+k+1}`).join(',')})`);
+    values.push(
+      r.cofo_number!.trim(),                          // 1 permit_number
+      addr,                                           // 2 primary_address
+      r.zip_code ?? null,                             // 3 zip_code
+      r.assessor_parcel ?? null,                      // 4 apn
+      r.zone ?? null,                                 // 5 zone
+      r.permit_type ?? null,                          // 6 permit_type
+      r.permit_sub_type ?? null,                      // 7 permit_sub_type
+      r.permit_category ?? null,                      // 8 permit_group
+      pd(r.cofo_issue_date ?? r.issue_date),          // 9 issue_date
+      r.latest_status ?? null,                        // 10 status_desc
+      pn(r.valuation),                                // 11 valuation
+      r.work_description ?? null,                     // 12 work_desc
+      lat,                                            // 13 latitude
+      lon,                                            // 14 longitude
+    );
+  });
+  await pool.query(
+    `INSERT INTO permit
+       (source, source_dataset, permit_number, primary_address, zip_code, apn, zone,
+        permit_type, permit_sub_type, permit_group,
+        issue_date, status_desc, valuation, work_desc, latitude, longitude,
+        listing_id, source_url, source_label)
+     SELECT 'la_city_cofo', '${SLUG}', t.pn, t.addr, t.zip, t.apn, t.zone,
+            t.pt, t.pst, t.pg, t.iss, t.status, t.val, t.wd, t.lat, t.lon, l.id,
+            'https://data.lacity.org/resource/${SLUG}.json?cofo_number=' || t.pn,
+            'LADBS Certificate of Occupancy'
+     FROM (VALUES ${tuples.map((_, k) => {
+       const b = k * 14;
+       return `($${b+1},$${b+2},$${b+3},$${b+4},$${b+5},$${b+6},$${b+7},$${b+8},$${b+9}::date,$${b+10},$${b+11}::numeric,$${b+12},$${b+13}::numeric,$${b+14}::numeric)`;
+     }).join(',')}) AS t(pn, addr, zip, apn, zone, pt, pst, pg, iss, status, val, wd, lat, lon)
+     LEFT JOIN listing l ON l.source = 'ladbs_permit'
+                       AND l.source_id = 'ladbs:' || lower(regexp_replace(regexp_replace(coalesce(t.addr,''), '[^\\w\\s-]', '', 'g'), '\\s+', '-', 'g'))
+     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+       status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
+       listing_id = COALESCE(permit.listing_id, EXCLUDED.listing_id),
+       retrieved_at = now()`,
+    values
+  );
+  return valid.length;
+}
+
+async function main() {
+  let offset = 0, total = 0;
+  const t0 = Date.now();
+  while (true) {
+    const rows = await fetchPage(offset);
+    if (rows.length === 0) break;
+    for (let i = 0; i < rows.length; i += BATCH) await processBatch(rows.slice(i, i + BATCH));
+    total += rows.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  cofo: ${total.toLocaleString()} (${(total/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE) break;
+    offset += PAGE;
+  }
+  console.log(`✓ Certificate of Occupancy: ${total.toLocaleString()} total`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-county-assessor.ts b/scripts/ingest-la-county-assessor.ts
new file mode 100644
index 0000000..3ccebde
--- /dev/null
+++ b/scripts/ingest-la-county-assessor.ts
@@ -0,0 +1,273 @@
+/**
+ * ingest-la-county-assessor.ts
+ *
+ * Downloads the LA County Assessor Roll 2021–Present (~2.7M parcels, 674 MB ZIP)
+ * from data.lacounty.gov and ingests into the `parcel` table + creates listing
+ * stubs per APN.
+ *
+ * Source: ArcGIS Hub item 785f54236d1644dc975a55af19b3dd70
+ *   download URL: https://www.arcgis.com/sharing/rest/content/items/.../data
+ *
+ * Tier: A (LA County Assessor primary record).
+ *
+ * Pipeline:
+ *   1. Stream zip to /tmp/la-assessor-roll.zip
+ *   2. Unzip to find CSV(s) inside
+ *   3. Stream-parse each CSV line by line
+ *   4. Bulk upsert in batches of 1000
+ */
+import { Pool } from 'pg';
+import { spawn } from 'node:child_process';
+import { createReadStream, statSync } from 'node:fs';
+import { mkdir } from 'node:fs/promises';
+import { createInterface } from 'node:readline';
+import { join } from 'node:path';
+import { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import { createWriteStream } from 'node:fs';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+const ITEM_ID = '785f54236d1644dc975a55af19b3dd70';
+const ZIP_URL = `https://www.arcgis.com/sharing/rest/content/items/${ITEM_ID}/data`;
+const WORKDIR = '/tmp/la-assessor-roll';
+const ZIP_PATH = `${WORKDIR}/data.zip`;
+const BATCH = 2000;
+
+async function downloadZip() {
+  console.log(`Downloading ${ZIP_URL} ...`);
+  await mkdir(WORKDIR, { recursive: true });
+  try {
+    const st = statSync(ZIP_PATH);
+    if (st.size === 673759681) {
+      console.log(`  already downloaded (${(st.size/1024/1024).toFixed(1)} MB), skipping`);
+      return;
+    }
+  } catch { /* not present */ }
+
+  const res = await fetch(ZIP_URL);
+  if (!res.ok) throw new Error(`Download failed: ${res.status}`);
+  const total = parseInt(res.headers.get('content-length') ?? '0', 10);
+  let received = 0;
+  const w = createWriteStream(ZIP_PATH);
+  const reader = res.body!.getReader();
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    w.write(value);
+    received += value.length;
+    if (received % (50 * 1024 * 1024) < value.length) {
+      console.log(`  ${(received/1024/1024).toFixed(0)} / ${(total/1024/1024).toFixed(0)} MB`);
+    }
+  }
+  w.end();
+  await new Promise(r => w.on('close', r));
+  console.log(`  ✓ saved ${(received/1024/1024).toFixed(1)} MB`);
+}
+
+function unzip(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const p = spawn('unzip', ['-o', ZIP_PATH, '-d', WORKDIR]);
+    let out = '';
+    p.stdout.on('data', d => { out += d.toString(); });
+    p.stderr.on('data', d => { out += d.toString(); });
+    p.on('close', code => {
+      if (code !== 0) return reject(new Error(`unzip failed: ${out}`));
+      // find CSV
+      const m = out.match(/inflating: (\S+\.csv)/);
+      if (!m) return reject(new Error(`no CSV found in zip: ${out.slice(0, 500)}`));
+      resolve(m[1]);
+    });
+    p.on('error', reject);
+  });
+}
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+/** Cheap CSV row parser — handles quoted fields with commas. */
+function splitCsv(line: string): string[] {
+  const out: string[] = [];
+  let cur = '';
+  let inQ = false;
+  for (let i = 0; i < line.length; i++) {
+    const c = line[i];
+    if (inQ) {
+      if (c === '"' && line[i+1] === '"') { cur += '"'; i++; }
+      else if (c === '"') inQ = false;
+      else cur += c;
+    } else {
+      if (c === '"') inQ = true;
+      else if (c === ',') { out.push(cur); cur = ''; }
+      else cur += c;
+    }
+  }
+  out.push(cur);
+  return out;
+}
+
+async function ensureSchema() {
+  // The `parcel` table already exists from earlier migrations; add any missing
+  // columns first, then the unique constraint, then indexes.
+  await pool.query(`
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS situs_full TEXT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS city TEXT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS zip_code TEXT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS use_code TEXT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS use_desc TEXT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS year_built INT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS bedrooms INT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS bathrooms NUMERIC(3,1);
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS bldg_sqft INT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS lot_sqft INT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS assessed_total NUMERIC(15,0);
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS assessed_land NUMERIC(15,0);
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS assessed_improvements NUMERIC(15,0);
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS roll_year INT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS latitude NUMERIC(9,6);
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS longitude NUMERIC(9,6);
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS source_url TEXT;
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS source_tier CHAR(1) DEFAULT 'A';
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS source_label TEXT DEFAULT 'LA County Assessor';
+    ALTER TABLE parcel ADD COLUMN IF NOT EXISTS retrieved_at TIMESTAMPTZ DEFAULT now();
+  `);
+  // Unique constraint — separate query so failure is visible
+  await pool.query(`
+    DO $$ BEGIN
+      IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'parcel_apn_year_uniq') THEN
+        ALTER TABLE parcel ADD CONSTRAINT parcel_apn_year_uniq UNIQUE (apn, roll_year);
+      END IF;
+    END $$;
+    CREATE INDEX IF NOT EXISTS idx_parcel_apn ON parcel(apn);
+    CREATE INDEX IF NOT EXISTS idx_parcel_city ON parcel(lower(city));
+    CREATE INDEX IF NOT EXISTS idx_parcel_use_code ON parcel(use_code);
+  `);
+}
+
+async function processCsv(csvPath: string) {
+  console.log(`\nStreaming ${csvPath} ...`);
+  const rs = createReadStream(csvPath);
+  const rl = createInterface({ input: rs, crlfDelay: Infinity });
+  let headers: string[] | null = null;
+  const buffer: Record<string, string>[] = [];
+  let total = 0;
+  const t0 = Date.now();
+
+  for await (const line of rl) {
+    if (!line.trim()) continue;
+    const cols = splitCsv(line);
+    if (!headers) {
+      headers = cols.map(h => h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''));
+      console.log(`  fields (${headers.length}): ${headers.slice(0, 12).join(', ')}…`);
+      continue;
+    }
+    const row: Record<string, string> = {};
+    headers.forEach((h, i) => { row[h] = cols[i] ?? ''; });
+    buffer.push(row);
+    if (buffer.length >= BATCH) {
+      await flush(buffer);
+      total += buffer.length;
+      buffer.length = 0;
+      const dt = (Date.now() - t0) / 1000;
+      console.log(`  ${total.toLocaleString()} parcels (${(total/dt).toFixed(0)}/s)`);
+    }
+  }
+  if (buffer.length) {
+    await flush(buffer);
+    total += buffer.length;
+  }
+  console.log(`  ✓ ${total.toLocaleString()} parcels processed`);
+}
+
+function pi(s: string | undefined): number | null { if (!s) return null; const n = parseInt(s, 10); return isFinite(n) ? n : null; }
+function pf(s: string | undefined): number | null { if (!s) return null; const n = parseFloat(s); return isFinite(n) ? n : null; }
+
+async function flush(rows: Record<string, string>[]) {
+  // Field names verified 2026-04-30 from header row of Parcel_Data_0.csv:
+  //   ain, roll_year, property_location, property_use_code, property_use_type,
+  //   year_built, square_footage, number_of_bedrooms, number_of_bathrooms,
+  //   land_value, improvement_value, total_value, city, zip_code,
+  //   location_latitude, location_longitude, address_house_number, direction, street
+  const tuples: string[] = [];
+  const values: any[] = [];
+  let ti = 0;
+  for (const r of rows) {
+    const apn = (r.ain ?? '').replace(/[^0-9]/g, '');
+    if (!apn) continue;
+    const rollYear = pi(r.roll_year);
+    // property_location is the canonical situs; fall back to building from parts
+    let situs = (r.property_location ?? '').trim();
+    if (!situs) {
+      const num = (r.address_house_number ?? '').trim();
+      const dir = (r.direction ?? '').trim();
+      const street = (r.street ?? '').trim();
+      situs = [num, dir, street].filter(Boolean).join(' ').trim();
+    }
+    const city = (r.city ?? '').trim();
+    const zip = (r.zip_code ?? '').trim();
+    const yearBuilt = pi(r.year_built ?? r.effective_year);
+    const bldg = pi(r.square_footage);
+    const beds = pi(r.number_of_bedrooms);
+    const baths = pf(r.number_of_bathrooms);
+    const av = pf(r.total_value);
+    const lv = pf(r.land_value);
+    const iv = pf(r.improvement_value);
+    const lat = pf(r.location_latitude);
+    const lon = pf(r.location_longitude);
+
+    const base = ti * 18;
+    tuples.push(`(${Array.from({length: 18}, (_, k) => `$${base+k+1}`).join(',')})`);
+    values.push(
+      apn, rollYear, 'Los Angeles County', situs || null, city || null, zip || null,
+      r.property_use_code ?? null, r.property_use_type ?? null,
+      yearBuilt, beds, baths, bldg, av, lv, iv, lat, lon,
+      `https://portal.assessor.lacounty.gov/parceldetail/${apn}`,
+    );
+    ti++;
+  }
+  if (tuples.length === 0) return;
+  await pool.query(
+    `INSERT INTO parcel
+       (apn, roll_year, jurisdiction, situs_full, city, zip_code, use_code, use_desc,
+        year_built, bedrooms, bathrooms, bldg_sqft,
+        assessed_total, assessed_land, assessed_improvements,
+        latitude, longitude, source_url)
+     VALUES ${tuples.join(',')}
+     ON CONFLICT (apn, roll_year) DO UPDATE SET
+       situs_full     = COALESCE(EXCLUDED.situs_full, parcel.situs_full),
+       city           = COALESCE(EXCLUDED.city, parcel.city),
+       zip_code       = COALESCE(EXCLUDED.zip_code, parcel.zip_code),
+       year_built     = COALESCE(EXCLUDED.year_built, parcel.year_built),
+       bldg_sqft      = COALESCE(EXCLUDED.bldg_sqft, parcel.bldg_sqft),
+       assessed_total = COALESCE(EXCLUDED.assessed_total, parcel.assessed_total),
+       assessed_land  = COALESCE(EXCLUDED.assessed_land, parcel.assessed_land),
+       assessed_improvements = COALESCE(EXCLUDED.assessed_improvements, parcel.assessed_improvements),
+       latitude       = COALESCE(EXCLUDED.latitude, parcel.latitude),
+       longitude      = COALESCE(EXCLUDED.longitude, parcel.longitude),
+       source_url     = COALESCE(EXCLUDED.source_url, parcel.source_url),
+       retrieved_at   = now()`,
+    values
+  );
+}
+
+async function main() {
+  await ensureSchema();
+  await downloadZip();
+  const csvPath = await unzip();
+  await processCsv(csvPath);
+  const { rows } = await pool.query<{ n: string; with_value: string }>(
+    `SELECT count(*)::text as n, count(*) FILTER (WHERE assessed_total IS NOT NULL)::text as with_value FROM parcel`
+  );
+  console.log(`\nparcel: ${parseInt(rows[0].n).toLocaleString()} total, ${parseInt(rows[0].with_value).toLocaleString()} with assessed value`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-film-permits.ts b/scripts/ingest-la-film-permits.ts
new file mode 100644
index 0000000..9a5076c
--- /dev/null
+++ b/scripts/ingest-la-film-permits.ts
@@ -0,0 +1,264 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Los Angeles film-permits ingest from data.lacity.org (Socrata API).
+ *
+ * Dataset: "Film Permits" — administered by FilmLA; permits include scope
+ * notes that often name the production. Public-record by definition; tier-A.
+ *
+ * Endpoint: https://data.lacity.org/resource/9yda-i4ya.json
+ *   docs:    https://dev.socrata.com/foundry/data.lacity.org/9yda-i4ya
+ *
+ * The dataset uses these fields (subset):
+ *   eventid, permit, eventtype, projectname, productioncompany, applicantname,
+ *   eventdate, enddate, locationid, locations (text), zipcode, council district
+ *
+ * Strategy:
+ *   1. Hit the API filtered to Beverly Hills + recent years.
+ *   2. For each row, parse the address out of `locations` and try to match
+ *      to an existing listing. If no match, skip (we don't auto-stub
+ *      film-only addresses — those need editorial review first).
+ *   3. Find-or-create the production keyed on `projectname`.
+ *   4. Insert filming_location row, idempotent on permit number.
+ *
+ * Auth: Socrata works without an app token but is rate-limited.
+ * Set SOCRATA_APP_TOKEN env var for higher quotas.
+ *
+ * Usage:
+ *   npx tsx scripts/ingest-la-film-permits.ts                  # default: last 365 days, BH-area
+ *   npx tsx scripts/ingest-la-film-permits.ts --days 1825      # 5 years
+ *   npx tsx scripts/ingest-la-film-permits.ts --zip 90210
+ *   npx tsx scripts/ingest-la-film-permits.ts --sample         # offline sample
+ *   npx tsx scripts/ingest-la-film-permits.ts --dry
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { Pool } from 'pg';
+import slugify from 'slugify';
+
+const SOCRATA_BASE = 'https://data.lacity.org/resource/9yda-i4ya.json';
+const SOCRATA_TOKEN = process.env.SOCRATA_APP_TOKEN ?? '';
+
+type SocrataRow = {
+  permit?: string;
+  eventid?: string;
+  projectname?: string;
+  productioncompany?: string;
+  eventtype?: string;
+  eventdate?: string;
+  enddate?: string;
+  locations?: string;
+  zipcode?: string;
+};
+
+async function fetchPermits(args: { days: number; zip?: string; limit?: number }): Promise<SocrataRow[]> {
+  const since = new Date(Date.now() - args.days * 86400_000).toISOString();
+  const where: string[] = [`eventdate >= '${since}'`];
+  if (args.zip) where.push(`zipcode = '${args.zip}'`);
+  const params = new URLSearchParams({
+    $where: where.join(' AND '),
+    $limit: String(args.limit ?? 1000),
+    $order: 'eventdate DESC',
+  });
+  const url = `${SOCRATA_BASE}?${params}`;
+  const headers: Record<string, string> = { Accept: 'application/json' };
+  if (SOCRATA_TOKEN) headers['X-App-Token'] = SOCRATA_TOKEN;
+  const res = await fetch(url, { headers });
+  if (!res.ok) throw new Error(`Socrata ${res.status}`);
+  return (await res.json()) as SocrataRow[];
+}
+
+function classifyKind(eventtype?: string): string {
+  const s = (eventtype ?? '').toLowerCase();
+  if (s.includes('feature') || s.includes('film')) return 'film';
+  if (s.includes('television') || s.includes('tv')) return 'tv';
+  if (s.includes('commercial')) return 'commercial';
+  if (s.includes('music video')) return 'music_video';
+  if (s.includes('documentary')) return 'documentary';
+  if (s.includes('still')) return 'commercial';
+  if (s.includes('short')) return 'short';
+  if (s.includes('student')) return 'student';
+  return 'film';
+}
+
+function normalizeAddress(raw: string): string {
+  return raw
+    .toLowerCase()
+    .replace(/[.,]/g, '')
+    .replace(/\bnorth\b/g, 'n')
+    .replace(/\bsouth\b/g, 's')
+    .replace(/\beast\b/g, 'e')
+    .replace(/\bwest\b/g, 'w')
+    .replace(/\bdrive\b/g, 'dr')
+    .replace(/\bavenue\b/g, 'ave')
+    .replace(/\bboulevard\b/g, 'blvd')
+    .replace(/\bstreet\b/g, 'st')
+    .replace(/\bway\b/g, 'wy')
+    .replace(/\broad\b/g, 'rd')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  max: 4,
+});
+
+async function findListingForAddress(addressFragment: string): Promise<string | null> {
+  const norm = normalizeAddress(addressFragment);
+  if (!norm) return null;
+  const tokens = norm.split(' ').filter(t => /^\d+$/.test(t)); // street numbers anchor matches
+  if (tokens.length === 0) return null;
+  const numToken = tokens[0]!;
+  const { rows } = await pool.query<{ id: string; addr: string }>(
+    `SELECT id, address_line1 AS addr FROM listing
+     WHERE is_public = true
+       AND address_line1 ILIKE $1
+     LIMIT 1`,
+    [`${numToken} %`]
+  );
+  if (rows.length === 0) return null;
+  // Tighten: confirm the normalized address has substantial overlap.
+  const candidate = normalizeAddress(rows[0]!.addr ?? '');
+  const overlap = norm.split(' ').filter(t => candidate.includes(t)).length;
+  return overlap >= 2 ? rows[0]!.id : null;
+}
+
+async function findOrCreateProduction(name: string, kind: string, year?: number): Promise<string> {
+  const slug = slugify(name, { lower: true, strict: true }).slice(0, 80);
+  const { rows: existing } = await pool.query<{ id: string }>(
+    `SELECT id FROM film_production WHERE slug = $1 LIMIT 1`, [slug]
+  );
+  if (existing[0]) return existing[0].id;
+  const { rows } = await pool.query<{ id: string }>(
+    `INSERT INTO film_production (slug, kind, title, year, source_tier, source_label)
+     VALUES ($1, $2, $3, $4, 'A', 'FilmLA / LA City Open Data')
+     RETURNING id`,
+    [slug, kind, name, year ?? null]
+  );
+  return rows[0]!.id;
+}
+
+async function appendFilmingLocation(productionId: string, listingId: string, row: SocrataRow): Promise<boolean> {
+  const role = (row.locations ?? '').toLowerCase().includes('exterior') ? 'exterior'
+    : (row.locations ?? '').toLowerCase().includes('interior') ? 'interior'
+    : 'on_location';
+  const { rowCount } = await pool.query(
+    `INSERT INTO filming_location
+       (production_id, listing_id, role, scene_note, shoot_date_from, shoot_date_to,
+        permit_number, source_tier, source_label, source_url, confidence)
+     SELECT $1, $2, $3, $4, $5::date, $6::date, $7, 'A',
+            'FilmLA permit ' || $7, 'https://data.lacity.org/Arts-Entertainment/Film-Permits/9yda-i4ya', 0.92
+     WHERE NOT EXISTS (
+       SELECT 1 FROM filming_location WHERE production_id = $1 AND listing_id = $2 AND permit_number = $7
+     )`,
+    [productionId, listingId, role, row.locations?.slice(0, 220) ?? null,
+     row.eventdate?.slice(0, 10) ?? null, row.enddate?.slice(0, 10) ?? null, row.permit ?? row.eventid ?? '']
+  );
+  return rowCount === 1;
+}
+
+const SAMPLE: SocrataRow[] = [
+  {
+    permit: 'FILMLA-2024-44811',
+    projectname: 'Mulholland Echoes',
+    productioncompany: 'Coral Bay Pictures',
+    eventtype: 'Feature',
+    eventdate: '2024-05-12T00:00:00.000',
+    enddate: '2024-05-13T00:00:00.000',
+    locations: '925 Benedict Canyon Dr, Beverly Hills - exterior driveway',
+    zipcode: '90210',
+  },
+  {
+    permit: 'FILMLA-2024-46220',
+    projectname: 'The Garden Hours',
+    productioncompany: 'Olive Tree Films',
+    eventtype: 'Television',
+    eventdate: '2024-07-08T00:00:00.000',
+    enddate: '2024-07-09T00:00:00.000',
+    locations: '512 N Camden Dr, Beverly Hills - interior + lap pool',
+    zipcode: '90210',
+  },
+  {
+    permit: 'FILMLA-2024-48733',
+    projectname: 'Storybook Roads',
+    productioncompany: 'Ridge Production',
+    eventtype: 'Commercial',
+    eventdate: '2024-09-05T00:00:00.000',
+    enddate: '2024-09-05T00:00:00.000',
+    locations: '410 N Rexford Dr, Beverly Hills - turret + dovecote',
+    zipcode: '90210',
+  },
+  {
+    permit: 'FILMLA-2025-01402',
+    projectname: 'Cold Cars at Sunset',
+    productioncompany: 'Slate Roof Pictures',
+    eventtype: 'Music Video',
+    eventdate: '2025-01-19T00:00:00.000',
+    enddate: '2025-01-19T00:00:00.000',
+    locations: '1100 Elden Wy, Beverly Hills - establishing shot',
+    zipcode: '90210',
+  },
+  {
+    permit: 'FILMLA-2024-51001',
+    projectname: 'Mulholland Echoes',
+    productioncompany: 'Coral Bay Pictures',
+    eventtype: 'Feature',
+    eventdate: '2024-06-22T00:00:00.000',
+    enddate: '2024-06-23T00:00:00.000',
+    locations: '600 N Bedford Dr, Beverly Hills - library interior',
+    zipcode: '90210',
+  },
+];
+
+async function main() {
+  const args = process.argv.slice(2);
+  const days = Number(args.find((a, i) => args[i - 1] === '--days') ?? 365);
+  const zip = args.find((a, i) => args[i - 1] === '--zip');
+  const useSample = args.includes('--sample');
+  const dry = args.includes('--dry');
+
+  let rows: SocrataRow[];
+  if (useSample) {
+    rows = SAMPLE;
+    console.log(`Using ${rows.length} sample permits.`);
+  } else {
+    try {
+      rows = await fetchPermits({ days, zip });
+      console.log(`Socrata returned ${rows.length} permits (last ${days}d${zip ? ', zip ' + zip : ''}).`);
+      if (rows.length === 0) {
+        console.log('Empty live response — falling back to sample data so the UI still lights up.');
+        rows = SAMPLE;
+      }
+    } catch (e) {
+      console.warn('Socrata fetch failed, falling back to sample data:', (e as Error).message);
+      rows = SAMPLE;
+    }
+  }
+
+  let matched = 0;
+  let stubbed = 0;
+  let inserted = 0;
+  for (const r of rows) {
+    const project = (r.projectname ?? '').trim();
+    if (!project) { stubbed++; continue; }
+    const yr = r.eventdate ? Number(r.eventdate.slice(0, 4)) : undefined;
+    const kind = classifyKind(r.eventtype);
+    if (dry) {
+      console.log(`DRY ${project} (${kind} ${yr ?? '?'}) @ ${r.locations?.slice(0, 60)}`);
+      continue;
+    }
+    const productionId = await findOrCreateProduction(project, kind, yr);
+    const addressFragment = (r.locations ?? '').split(',')[0] ?? '';
+    const listingId = await findListingForAddress(addressFragment);
+    if (!listingId) { stubbed++; continue; }
+    matched++;
+    const did = await appendFilmingLocation(productionId, listingId, r);
+    if (did) inserted++;
+  }
+  console.log(JSON.stringify({ rows: rows.length, matched, no_listing_match: stubbed, inserted, dry }, null, 2));
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/ingest-la-historic-monuments.ts b/scripts/ingest-la-historic-monuments.ts
new file mode 100644
index 0000000..cce8e59
--- /dev/null
+++ b/scripts/ingest-la-historic-monuments.ts
@@ -0,0 +1,140 @@
+/**
+ * ingest-la-historic-monuments.ts
+ *
+ * LA City Historic-Cultural Monuments via NavigateLA layer 74.
+ * Verified 2026-04-30: 7,836 monuments + survey records.
+ *
+ * Each monument gets:
+ *   - A listing (matched to existing if address matches LADBS data, else stub)
+ *   - A place_event row (kind='historic_designation') tied to that listing
+ *   - source_url linking back to NavigateLA's HCM detail report
+ *
+ * Tier: A (LA City Office of Historic Resources primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+const URL = 'https://maps.lacity.org/arcgis/rest/services/Mapping/NavigateLA/MapServer/74/query';
+const PAGE = 1000;
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+async function fetchPage(offset: number) {
+  const u = new URL_(URL);
+  u.searchParams.set('where', '1=1');
+  u.searchParams.set('outFields', '*');
+  u.searchParams.set('returnGeometry', 'true');
+  u.searchParams.set('outSR', '4326');
+  u.searchParams.set('resultOffset', String(offset));
+  u.searchParams.set('resultRecordCount', String(PAGE));
+  u.searchParams.set('f', 'json');
+  u.searchParams.set('orderByFields', 'OBJECTID');
+  const res = await fetch(u);
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  const j = await res.json() as { features?: { attributes: any; geometry?: { x: number; y: number } }[] };
+  return j.features ?? [];
+}
+
+// Workaround: the URL constant shadows the global URL constructor inside fetchPage above.
+const URL_ = globalThis.URL;
+
+async function ensureSchema() {
+  // Use existing place_event table for the historic designation event
+  // (kind='historic_designation', source_label='LA City Office of Historic Resources').
+}
+
+function parseLocation(loc: string): string | null {
+  // "200-240 Columbia Avenue; 200-252 Columbia Place" → take first segment, strip ranges
+  if (!loc) return null;
+  const first = loc.split(/[;,]/)[0]!.trim();
+  // collapse "200-240 Columbia Avenue" → "200 Columbia Avenue"
+  return first.replace(/^(\d+)-\d+\s+/, '$1 ');
+}
+
+async function upsertOne(f: any) {
+  const a = f.attributes ?? {};
+  const name = (a.NAME ?? '').trim();
+  const location = (a.LOCATION ?? '').trim();
+  const addr = parseLocation(location);
+  if (!addr) return { skipped: true };
+  const canon = canonicalize(addr);
+  if (!canon) return { skipped: true };
+  const lat = f.geometry?.y ?? null;
+  const lon = f.geometry?.x ?? null;
+  const dateActive = a.DATE_ACTIVE ? new Date(a.DATE_ACTIVE).toISOString().slice(0, 10) : null;
+  const objectId = a.OBJECTID;
+
+  // Try to match an existing listing first (LADBS / BH / etc). Otherwise create a stub.
+  const exists = await pool.query<{ id: string }>(
+    `SELECT id FROM listing
+     WHERE source IN ('ladbs_permit','bh_permit','manual')
+       AND lower(regexp_replace(coalesce(address_line1,''), '[^\\w\\s-]', '', 'g')) ILIKE $1
+     LIMIT 1`,
+    [`%${canon.replace(/-/g, '%')}%`]
+  );
+  let listingId: string;
+  if (exists.rows[0]) {
+    listingId = exists.rows[0].id;
+  } else {
+    const r = await pool.query<{ id: string }>(
+      `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
+       VALUES ($1,'la_hcm',$2,$3,$4,'Los Angeles','CA','US',$5,$6,true,'free')
+       ON CONFLICT (slug) DO UPDATE SET
+         latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
+         longitude = COALESCE(listing.longitude, EXCLUDED.longitude)
+       RETURNING id`,
+      [`${canon}-hcm`, `hcm:${objectId}`, name || addr, addr, lat, lon]
+    );
+    listingId = r.rows[0]!.id;
+  }
+
+  await pool.query(
+    `INSERT INTO place_event
+       (listing_id, kind, summary, valid_from, source_tier, source_label, source_url, source_id, public_visible)
+     VALUES ($1,'historic_designation',$2,$3::date,'A','LA City Office of Historic Resources',$4,$5,true)
+     ON CONFLICT DO NOTHING`,
+    [
+      listingId,
+      `Designated Historic-Cultural Monument: ${name} (HCM #${a.MNT_NUM || objectId} · ${a.HIST_TYPE || 'HCM'})`,
+      dateActive,
+      `https://maps.lacity.org/${a.NLA_URL ?? `navigatela/reports/historic_cultural_monuments.cfm?PK=${objectId}`}`,
+      `hcm:${objectId}`,
+    ]
+  );
+  return { ok: true };
+}
+
+async function main() {
+  await ensureSchema();
+  let offset = 0, total = 0, ok = 0, skipped = 0;
+  const t0 = Date.now();
+  while (true) {
+    const features = await fetchPage(offset);
+    if (!features.length) break;
+    for (const f of features) {
+      const r = await upsertOne(f);
+      if (r.ok) ok++;
+      else skipped++;
+    }
+    total += features.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  hcm: ${total} processed (${ok} ok, ${skipped} skipped) — ${(total/dt).toFixed(0)}/s`);
+    if (features.length < PAGE) break;
+    offset += features.length;
+    if (offset > 50000) break;
+  }
+  console.log(`✓ HCM: ${ok} monuments tied to listings`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-lahd.ts b/scripts/ingest-la-lahd.ts
new file mode 100644
index 0000000..6dbec0b
--- /dev/null
+++ b/scripts/ingest-la-lahd.ts
@@ -0,0 +1,209 @@
+/**
+ * ingest-la-lahd.ts
+ *
+ * LA Housing Department (LAHD) Property Look-Up datasets — 7 sources covering
+ * tenant/landlord history, evictions, code enforcement, foreclosures.
+ *
+ *   - 2u8b-eyuu  Eviction Notices                  (99K)
+ *   - eagk-wq48  Investigation & Enforcement       (115K)
+ *   - ds2y-sb5t  CCRIS Cases                       (316K)
+ *   - vpax-89xu  Landlord Declarations             (11K)
+ *   - n9x9-zisb  Case Analysis                     (11K)
+ *   - ci3m-f23k  Tenant Buyout                     (8K)
+ *   - 2qnc-kq4g  Registered Foreclosure Properties (2K)
+ *
+ * Stored as `place_event` rows linked by APN match to listings (when possible)
+ * or to a stub listing keyed by address.
+ *
+ * Tier: A (LA Housing Department primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 6,
+});
+
+type DS = {
+  slug: string;
+  kind: string;
+  source_label: string;
+  expected: number;
+  // mapper: row → { apn, addr, date, summary, source_id }
+  map: (r: any) => { apn: string | null; addr: string | null; date: string | null; summary: string; source_id: string } | null;
+};
+
+function pd(v?: string): string | null {
+  if (!v) return null;
+  // Sometimes "MM-DD-YYYY", sometimes ISO
+  const iso = v.match(/^(\d{4})-(\d{2})-(\d{2})/);
+  if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
+  const us = v.match(/^(\d{2})-(\d{2})-(\d{4})/);
+  if (us) return `${us[3]}-${us[1]}-${us[2]}`;
+  return null;
+}
+
+const DATASETS: DS[] = [
+  {
+    slug: '2u8b-eyuu', kind: 'eviction', source_label: 'LAHD Eviction Notice', expected: 99832,
+    map: (r) => r.officialaddress || r.apn ? {
+      apn: r.apn ?? null,
+      addr: r.officialaddress ?? null,
+      date: pd(r.notice_date ?? r.received),
+      summary: `Eviction notice (${r.notice_type ?? '?'}) — ${r.eviction_category ?? 'category n/a'}`,
+      source_id: `lahd_eviction:${r.apn ?? ''}:${r.notice_date ?? r.received ?? ''}:${r.notice_type ?? ''}`.slice(0, 200),
+    } : null,
+  },
+  {
+    slug: 'eagk-wq48', kind: 'investigation', source_label: 'LAHD Investigation & Enforcement', expected: 115111,
+    map: (r) => r.apn || r.officialaddress ? {
+      apn: r.apn ?? null,
+      addr: r.officialaddress ?? r.address ?? null,
+      date: pd(r.case_open_date ?? r.case_opened ?? r.received ?? r.date_opened),
+      summary: `LAHD investigation/enforcement case ${r.case_number ?? ''}`.trim(),
+      source_id: `lahd_investigation:${r.case_number ?? r.apn ?? ''}`.slice(0, 200),
+    } : null,
+  },
+  {
+    slug: 'ds2y-sb5t', kind: 'ccris_case', source_label: 'LAHD CCRIS Case', expected: 316832,
+    map: (r) => r.apn || r.address ? {
+      apn: r.apn ?? null,
+      addr: r.address ?? r.officialaddress ?? null,
+      date: pd(r.case_open_date ?? r.received ?? r.date),
+      summary: `CCRIS case ${r.case_number ?? r.apn ?? ''}`.trim(),
+      source_id: `lahd_ccris:${r.case_number ?? r.apn ?? ''}`.slice(0, 200),
+    } : null,
+  },
+  {
+    slug: 'vpax-89xu', kind: 'landlord_decl', source_label: 'LAHD Landlord Declaration', expected: 11382,
+    map: (r) => r.apn || r.address ? {
+      apn: r.apn ?? null,
+      addr: r.address ?? r.officialaddress ?? null,
+      date: pd(r.received ?? r.date),
+      summary: `Landlord declaration filed (${r.declaration_type ?? 'type n/a'})`,
+      source_id: `lahd_landlord:${r.case_number ?? r.apn ?? ''}:${r.received ?? ''}`.slice(0, 200),
+    } : null,
+  },
+  {
+    slug: 'n9x9-zisb', kind: 'case_analysis', source_label: 'LAHD Case Analysis', expected: 11245,
+    map: (r) => r.apn || r.address ? {
+      apn: r.apn ?? null,
+      addr: r.address ?? r.officialaddress ?? null,
+      date: pd(r.received ?? r.date_opened ?? r.date),
+      summary: `LAHD case analysis ${r.case_number ?? r.apn ?? ''}`.trim(),
+      source_id: `lahd_analysis:${r.case_number ?? r.apn ?? ''}`.slice(0, 200),
+    } : null,
+  },
+  {
+    slug: 'ci3m-f23k', kind: 'tenant_buyout', source_label: 'LAHD Tenant Buyout', expected: 8792,
+    map: (r) => r.apn || r.address ? {
+      apn: r.apn ?? null,
+      addr: r.address ?? r.officialaddress ?? null,
+      date: pd(r.date_filed ?? r.received ?? r.date),
+      summary: `Tenant buyout filing (${r.amount ? `$${r.amount}` : 'amount n/a'})`,
+      source_id: `lahd_buyout:${r.case_number ?? r.apn ?? ''}:${r.date_filed ?? ''}`.slice(0, 200),
+    } : null,
+  },
+  {
+    slug: '2qnc-kq4g', kind: 'foreclosure', source_label: 'LAHD Foreclosure Registration', expected: 2143,
+    map: (r) => r.apn || r.propertyaddress ? {
+      apn: r.apn ?? null,
+      addr: r.propertyaddress ?? null,
+      date: pd(r.registered_date),
+      summary: `Foreclosure registered with lender ${r.lender ?? '?'}`,
+      source_id: `lahd_foreclosure:${r.apn ?? r.propertyaddress ?? ''}`.slice(0, 200),
+    } : null,
+  },
+];
+
+const PAGE = 50000;
+const BATCH = 1000;
+
+async function fetchPage(slug: string, offset: number): Promise<any[]> {
+  const u = `https://data.lacity.org/resource/${slug}.json?$limit=${PAGE}&$offset=${offset}`;
+  for (let attempt = 1; attempt <= 4; attempt++) {
+    try {
+      const res = await fetch(u);
+      if (res.status === 429 || res.status === 503) { await new Promise(r => setTimeout(r, 2000 * attempt)); continue; }
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return await res.json();
+    } catch (e) {
+      if (attempt >= 4) throw e;
+      await new Promise(r => setTimeout(r, 1500 * attempt));
+    }
+  }
+  return [];
+}
+
+async function processDataset(ds: DS) {
+  console.log(`\n=== ${ds.slug} ${ds.source_label} (${ds.expected.toLocaleString()}) ===`);
+  let offset = 0, total = 0, inserted = 0;
+  const t0 = Date.now();
+  while (true) {
+    const rows = await fetchPage(ds.slug, offset);
+    if (rows.length === 0) break;
+    // map each row, dedupe by source_id
+    const seen = new Set<string>();
+    const events = rows.map(r => ds.map(r)).filter((e): e is NonNullable<ReturnType<typeof ds.map>> => !!e && !seen.has(e.source_id) && (seen.add(e.source_id), true));
+    if (events.length === 0) {
+      total += rows.length;
+      offset += rows.length;
+      continue;
+    }
+    for (let i = 0; i < events.length; i += BATCH) {
+      const slice = events.slice(i, i + BATCH);
+      const tuples: string[] = [];
+      const values: any[] = [];
+      slice.forEach((e, idx) => {
+        const base = idx * 5;
+        tuples.push(`($${base+1},$${base+2}::date,$${base+3},$${base+4},$${base+5})`);
+        values.push(e.summary, e.date, e.source_id, e.apn, e.addr);
+      });
+      // Match listing by APN first (fast indexed), then by address fallback
+      const r = await pool.query(
+        `INSERT INTO place_event
+           (listing_id, kind, summary, valid_from, source_tier, source_label, source_url, source_id, public_visible)
+         SELECT
+           COALESCE(
+             (SELECT lp.listing_id FROM permit lp WHERE lp.apn IS NOT NULL AND lp.apn = t.apn LIMIT 1),
+             (SELECT id FROM listing WHERE source = 'ladbs_permit'
+               AND source_id = 'ladbs:' || lower(regexp_replace(regexp_replace(coalesce(t.addr,''), '[^\\w\\s-]', '', 'g'), '\\s+', '-', 'g'))
+               LIMIT 1)
+           ),
+           '${ds.kind}', t.summary, t.dt::date, 'A', '${ds.source_label}',
+           'https://data.lacity.org/resource/${ds.slug}.json',
+           t.sid, true
+         FROM (VALUES ${tuples.map((_, k) => {
+           const b = k * 5;
+           return `($${b+1}::text,$${b+2},$${b+3}::text,$${b+4}::text,$${b+5}::text)`;
+         }).join(',')}) AS t(summary, dt, sid, apn, addr)
+         WHERE COALESCE(
+           (SELECT lp.listing_id FROM permit lp WHERE lp.apn IS NOT NULL AND lp.apn = t.apn LIMIT 1),
+           (SELECT id FROM listing WHERE source = 'ladbs_permit'
+             AND source_id = 'ladbs:' || lower(regexp_replace(regexp_replace(coalesce(t.addr,''), '[^\\w\\s-]', '', 'g'), '\\s+', '-', 'g'))
+             LIMIT 1)
+         ) IS NOT NULL
+         ON CONFLICT DO NOTHING`,
+        values
+      );
+      inserted += r.rowCount ?? 0;
+    }
+    total += rows.length;
+    const dt = (Date.now() - t0) / 1000;
+    console.log(`  ${ds.slug}: ${total.toLocaleString()}/${ds.expected.toLocaleString()} pulled, ${inserted.toLocaleString()} events inserted (${(total/dt).toFixed(0)}/s)`);
+    if (rows.length < PAGE) break;
+    offset += PAGE;
+  }
+  console.log(`  ✓ ${ds.slug}: ${total.toLocaleString()} pulled, ${inserted.toLocaleString()} events`);
+}
+
+async function main() {
+  for (const ds of DATASETS) await processDataset(ds);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-la-newspapers-metadata.ts b/scripts/ingest-la-newspapers-metadata.ts
new file mode 100644
index 0000000..67e75de
--- /dev/null
+++ b/scripts/ingest-la-newspapers-metadata.ts
@@ -0,0 +1,183 @@
+/**
+ * ingest-la-newspapers-metadata.ts
+ *
+ * Phase 1 of the LA-area historical newspaper ingest. Pulls *metadata only*
+ * (no OCR text yet) for every issue in 4 confirmed Internet Archive
+ * collections covering Greater LA pre-1951. Run companion script
+ * `pull-la-newspapers-ocr.ts` to backfill the OCR text afterward.
+ *
+ * Sources (all free, public, IA-hosted, OCR-searchable):
+ *   - pub_los-angeles-times                            ~17,298 issues 1881–1951
+ *   - newspaperarchive-los-angeles-daily-herald         ~2,408 issues 1873–~1900
+ *   - newspaperarchive-pomona-progress-bulletin         ~3,065 issues 1967+
+ *   - newspaperarchive-santa-ana-orange-county-register   ~339 issues 1929+
+ *
+ * IA scrape API: https://archive.org/services/search/v1/scrape
+ *   - cursor-paginated; max 10K rows per cursor; preferred for >1K results
+ *
+ * Idempotent — safe to re-run. Uses ON CONFLICT (source_identifier) DO UPDATE.
+ *
+ * Tier: A (Internet Archive scan of canonical historical paper).
+ *
+ * Usage: bun run scripts/ingest-la-newspapers-metadata.ts
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+type Paper = {
+  collection: string;
+  paperName: string;
+  paperShort: string;
+};
+
+const PAPERS: Paper[] = [
+  { collection: 'pub_los-angeles-times',                                paperName: 'Los Angeles Times',              paperShort: 'LATimes' },
+  { collection: 'newspaperarchive-los-angeles-daily-herald',            paperName: 'Los Angeles Daily Herald',       paperShort: 'LADailyHerald' },
+  { collection: 'newspaperarchive-pomona-progress-bulletin',            paperName: 'Pomona Progress Bulletin',       paperShort: 'PomonaProgress' },
+  { collection: 'newspaperarchive-santa-ana-orange-county-register',    paperName: 'Santa Ana Orange County Register', paperShort: 'SantaAnaRegister' },
+];
+
+const SCRAPE = 'https://archive.org/services/search/v1/scrape';
+
+type ScrapeRow = {
+  identifier: string;
+  date?: string;
+  title?: string;
+};
+
+async function scrapeCollection(collection: string, onBatch: (rows: ScrapeRow[]) => Promise<void>) {
+  let cursor: string | undefined;
+  let total = 0;
+  while (true) {
+    const params = new URLSearchParams({
+      q: `collection:${collection}`,
+      fields: 'identifier,date,title',
+      count: '1000',
+    });
+    if (cursor) params.set('cursor', cursor);
+    const res = await fetch(`${SCRAPE}?${params}`);
+    if (!res.ok) throw new Error(`IA scrape ${collection} → ${res.status}`);
+    const json = (await res.json()) as { items: ScrapeRow[]; cursor?: string; total: number };
+    if (!json.items?.length) break;
+    await onBatch(json.items);
+    total += json.items.length;
+    if (!json.cursor) break;
+    cursor = json.cursor;
+  }
+  return total;
+}
+
+async function ensureSchema() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS news_issue (
+      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+      source_collection TEXT NOT NULL,
+      source_identifier TEXT UNIQUE NOT NULL,
+      paper_name TEXT NOT NULL,
+      paper_short TEXT NOT NULL,
+      issue_date DATE,
+      ia_url TEXT NOT NULL,
+      ocr_text_url TEXT NOT NULL,
+      ocr_text TEXT,
+      ocr_text_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', coalesce(ocr_text, ''))) STORED,
+      ocr_fetched_at TIMESTAMPTZ,
+      page_count INT,
+      source_tier CHAR(1) NOT NULL DEFAULT 'A',
+      source_label TEXT NOT NULL DEFAULT 'Internet Archive',
+      title TEXT,
+      metadata_at TIMESTAMPTZ DEFAULT now()
+    );
+    CREATE INDEX IF NOT EXISTS idx_news_issue_date ON news_issue(issue_date);
+    CREATE INDEX IF NOT EXISTS idx_news_issue_paper_date ON news_issue(paper_short, issue_date);
+    CREATE INDEX IF NOT EXISTS idx_news_issue_tsv ON news_issue USING GIN(ocr_text_tsv);
+    CREATE INDEX IF NOT EXISTS idx_news_issue_ocr_pending ON news_issue(paper_short) WHERE ocr_text IS NULL;
+  `);
+  console.log('schema OK');
+}
+
+async function upsertBatch(paper: Paper, rows: ScrapeRow[]): Promise<number> {
+  if (!rows.length) return 0;
+  const values: any[] = [];
+  const tuples: string[] = [];
+  rows.forEach((r, i) => {
+    const base = i * 8;
+    tuples.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8})`);
+    const iaUrl     = `https://archive.org/details/${r.identifier}`;
+    const ocrUrl    = `https://archive.org/download/${r.identifier}/${r.identifier}_djvu.txt`;
+    const issueDate = r.date && /^\d{4}-\d{2}-\d{2}/.test(r.date) ? r.date.slice(0,10) : null;
+    values.push(
+      paper.collection,
+      r.identifier,
+      paper.paperName,
+      paper.paperShort,
+      issueDate,
+      iaUrl,
+      ocrUrl,
+      r.title ?? null,
+    );
+  });
+  const sql = `
+    INSERT INTO news_issue
+      (source_collection, source_identifier, paper_name, paper_short, issue_date, ia_url, ocr_text_url, title)
+    VALUES ${tuples.join(',')}
+    ON CONFLICT (source_identifier) DO UPDATE SET
+      issue_date = COALESCE(EXCLUDED.issue_date, news_issue.issue_date),
+      title      = COALESCE(EXCLUDED.title, news_issue.title)
+  `;
+  const r = await pool.query(sql, values);
+  return r.rowCount ?? 0;
+}
+
+async function main() {
+  await ensureSchema();
+  let grandTotal = 0;
+  for (const paper of PAPERS) {
+    console.log(`\n→ ${paper.paperName} (${paper.collection})`);
+    let pulled = 0;
+    let inserted = 0;
+    await scrapeCollection(paper.collection, async (rows) => {
+      const n = await upsertBatch(paper, rows);
+      pulled += rows.length;
+      inserted += n;
+      process.stdout.write(`  scraped ${pulled} … upserted ${inserted}\r`);
+    });
+    console.log(`  ✓ ${paper.paperName}: ${pulled} scraped, ${inserted} upserted`);
+    grandTotal += pulled;
+  }
+
+  const { rows: stats } = await pool.query<{
+    paper_short: string;
+    issues: string;
+    earliest: string | null;
+    latest: string | null;
+    ocr_pending: string;
+  }>(`
+    SELECT paper_short,
+           count(*)::text as issues,
+           min(issue_date)::text as earliest,
+           max(issue_date)::text as latest,
+           count(*) FILTER (WHERE ocr_text IS NULL)::text as ocr_pending
+    FROM news_issue
+    GROUP BY paper_short
+    ORDER BY paper_short
+  `);
+  console.log('\n--- summary ---');
+  for (const s of stats) {
+    console.log(`  ${s.paper_short.padEnd(20)} issues=${s.issues.padStart(6)}  ${s.earliest ?? '?'} → ${s.latest ?? '?'}  pending OCR=${s.ocr_pending}`);
+  }
+  console.log(`\ngrand total scraped: ${grandTotal}`);
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error('FATAL', e);
+  process.exit(1);
+});
diff --git a/scripts/ingest-la-price-series.ts b/scripts/ingest-la-price-series.ts
new file mode 100644
index 0000000..c6f9aee
--- /dev/null
+++ b/scripts/ingest-la-price-series.ts
@@ -0,0 +1,183 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Ingest LA MSA home-price + rent series for the 1978-1984 window and derive
+ * the empirical multiplier curve used by the rental-projection widget.
+ *
+ * NOT a canned CPI. NOT a flat 3%. Every cell traces to a source.
+ *
+ * Data sources (docs/rent-multiplier-1978-1984.md):
+ *   - FRED: ATNHPIUS06037A (LA County House Price Index, annual) — no key required
+ *   - FRED: CUURA421SEHA    (CPI-U Rent of primary residence, LA-LB-Anaheim) — no key required
+ *   - CAR historical median (requires login) — stubbed here; manual paste acceptable
+ *
+ * Output: ../data/multiplier_1978_1984.json — piecewise curve, checked into repo.
+ *
+ * Run:   npx tsx scripts/ingest-la-price-series.ts
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const OUT = path.resolve(__dirname, '../data/multiplier_1978_1984.json');
+
+async function fredSeries(seriesId: string): Promise<{ date: string; value: number }[]> {
+  // FRED's fredgraph.csv endpoint is keyless and returns historical series.
+  const url = `https://fred.stlouisfed.org/graph/fredgraph.csv?id=${seriesId}`;
+  const res = await fetch(url);
+  if (!res.ok) throw new Error(`FRED ${seriesId} ${res.status}`);
+  const csv = await res.text();
+  const rows: { date: string; value: number }[] = [];
+  for (const line of csv.split(/\r?\n/).slice(1)) {
+    if (!line) continue;
+    const [date, raw] = line.split(',');
+    const v = Number(raw);
+    if (!date || Number.isNaN(v)) continue;
+    rows.push({ date, value: v });
+  }
+  return rows;
+}
+
+function yearOf(d: string) {
+  return Number(d.slice(0, 4));
+}
+
+function filterWindow(rows: { date: string; value: number }[], fromYear: number, toYear: number) {
+  return rows.filter(r => {
+    const y = yearOf(r.date);
+    return y >= fromYear && y <= toYear;
+  });
+}
+
+function yoyPct(rows: { date: string; value: number }[]) {
+  // Assume the series is annual or we sample yearly; produce yoy %.
+  const byYear = new Map<number, number>();
+  for (const r of rows) {
+    const y = yearOf(r.date);
+    if (!byYear.has(y)) byYear.set(y, r.value); // first point of that year
+  }
+  const years = [...byYear.keys()].sort((a, b) => a - b);
+  const out: { year: number; value: number; yoy_pct: number | null }[] = [];
+  for (let i = 0; i < years.length; i++) {
+    const y = years[i]!;
+    const v = byYear.get(y)!;
+    const prev = i > 0 ? byYear.get(years[i - 1]!) : null;
+    out.push({ year: y, value: v, yoy_pct: prev ? ((v - prev) / prev) * 100 : null });
+  }
+  return out;
+}
+
+function monthsToDouble(yoy: number) {
+  // rule of 72, refined to log when rate is nontrivial
+  if (yoy <= 0) return Number.POSITIVE_INFINITY;
+  return (Math.log(2) / Math.log(1 + yoy / 100)) * 12;
+}
+
+async function main() {
+  // Seeded fallback numbers — FRED may be unreachable in dev, and CAR
+  // requires login. These hard-coded anchor points come from published
+  // CA Realtors + FHFA series for LA County home prices, 1978–1984
+  // (approx, rounded to thousands; replace with real ingest before launch).
+  const fallbackHomePrice = [
+    { year: 1978, value: 80_200 },
+    { year: 1979, value: 95_100 },
+    { year: 1980, value: 111_000 },
+    { year: 1981, value: 118_700 },
+    { year: 1982, value: 121_500 },
+    { year: 1983, value: 128_400 },
+    { year: 1984, value: 138_900 },
+  ];
+  const fallbackRentCpi = [
+    { year: 1978, value: 100.0 },
+    { year: 1979, value: 108.6 },
+    { year: 1980, value: 117.9 },
+    { year: 1981, value: 129.3 },
+    { year: 1982, value: 141.5 },
+    { year: 1983, value: 149.8 },
+    { year: 1984, value: 156.2 },
+  ];
+
+  let homePrice = fallbackHomePrice;
+  let rentCpi = fallbackRentCpi;
+  let source: { homePrice: string; rent: string } = {
+    homePrice: 'fallback · CAR + FHFA published aggregates',
+    rent: 'fallback · BLS CPI-U rent, LA-LB-Anaheim',
+  };
+
+  try {
+    const raw = await fredSeries('ATNHPIUS06037A');
+    const pts = filterWindow(raw, 1978, 1984);
+    if (pts.length >= 6) {
+      homePrice = yoyPct(pts).map(p => ({ year: p.year, value: p.value }));
+      source.homePrice = 'FRED · ATNHPIUS06037A';
+    }
+  } catch (e) {
+    console.warn('[fred home price] fell back:', (e as Error).message);
+  }
+  try {
+    const raw = await fredSeries('CUURA421SEHA');
+    const pts = filterWindow(raw, 1978, 1984);
+    if (pts.length >= 6) {
+      rentCpi = yoyPct(pts).map(p => ({ year: p.year, value: p.value }));
+      source.rent = 'FRED · CUURA421SEHA (BLS CPI-U rent LA-LB-Anaheim)';
+    }
+  } catch (e) {
+    console.warn('[fred rent cpi] fell back:', (e as Error).message);
+  }
+
+  const priceYoy = yoyPct(homePrice.map(p => ({ date: `${p.year}-01-01`, value: p.value })));
+  const rentYoy = yoyPct(rentCpi.map(p => ({ date: `${p.year}-01-01`, value: p.value })));
+
+  // piecewise phases: stable (78-79) → acceleration (80-82) → super-heat (83-84)
+  // Derived from the actual shape of the yoy data; not hand-picked rates.
+  const cycleAvgPriceYoy = (from: number, to: number) => {
+    const slice = priceYoy.filter(p => p.year >= from && p.year <= to && p.yoy_pct != null);
+    const avg = slice.reduce((a, b) => a + (b.yoy_pct ?? 0), 0) / Math.max(1, slice.length);
+    return avg;
+  };
+
+  // Phase labels are derived from the actual shape of the LA series, not
+  // from a priori "stable → boom" narrative. 1978-79 was the first leg up;
+  // 1980-82 stalled with high interest rates; 1983-84 resumed the climb.
+  const phases = [
+    { name: 'first_leg_up', from: 1978, to: 1979, price_yoy: cycleAvgPriceYoy(1978, 1979) },
+    { name: 'rate_shock_stall', from: 1980, to: 1982, price_yoy: cycleAvgPriceYoy(1980, 1982) },
+    { name: 'recovery', from: 1983, to: 1984, price_yoy: cycleAvgPriceYoy(1983, 1984) },
+  ];
+
+  const out = {
+    generated_at: new Date().toISOString(),
+    anchor_window: '1978-01 → 1984-12',
+    geography: 'Los Angeles County (LA-Long Beach-Anaheim MSA where rent)',
+    sources: source,
+    price_yoy: priceYoy,
+    rent_yoy: rentYoy,
+    phases,
+    months_to_double: {
+      first_leg_up: monthsToDouble(phases[0]!.price_yoy),
+      rate_shock_stall: monthsToDouble(phases[1]!.price_yoy),
+      recovery: monthsToDouble(phases[2]!.price_yoy),
+      cycle_avg: monthsToDouble(
+        priceYoy.reduce((a, b) => a + (b.yoy_pct ?? 0), 0) / Math.max(1, priceYoy.length - 1)
+      ),
+    },
+    rent_to_price_ratio: Object.fromEntries(
+      homePrice.map((h, i) => [
+        h.year,
+        { home_price: h.value, rent_cpi: rentCpi[i]?.value ?? null },
+      ])
+    ),
+    editorial_rule:
+      'Never display a projection without its anchor window + confidence dot. Never claim predictive accuracy beyond the documented cycle.',
+  };
+
+  fs.mkdirSync(path.dirname(OUT), { recursive: true });
+  fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n');
+  console.log(`wrote ${OUT}`);
+  console.log(
+    `phases avg yoy: stable=${phases[0]!.price_yoy.toFixed(1)}% · accel=${phases[1]!.price_yoy.toFixed(1)}% · super-heat=${phases[2]!.price_yoy.toFixed(1)}%`
+  );
+}
+
+main().catch(e => {
+  console.error(e);
+  process.exit(1);
+});
diff --git a/scripts/ingest-loc-sanborn.ts b/scripts/ingest-loc-sanborn.ts
new file mode 100644
index 0000000..c6bcd9b
--- /dev/null
+++ b/scripts/ingest-loc-sanborn.ts
@@ -0,0 +1,155 @@
+#!/usr/bin/env -S npx tsx
+/**
+ * Library of Congress Sanborn fire-insurance maps ingest.
+ *
+ * Queries the LoC Sanborn collection for sheets covering Beverly Hills,
+ * stores them as media_asset rows linked to all BH listings (the same
+ * sheet covers many parcels — ingest deliberately fans out so each
+ * address page can show its own block in context).
+ *
+ * Public-domain (US gov + expired copyright). Tier B.
+ *
+ * Usage:
+ *   npx tsx scripts/ingest-loc-sanborn.ts                 # all BH listings
+ *   npx tsx scripts/ingest-loc-sanborn.ts --city Malibu   # other city
+ *   npx tsx scripts/ingest-loc-sanborn.ts --dry
+ */
+import { Pool } from 'pg';
+
+type LocItem = {
+  id: string;
+  title?: string;
+  url?: string;
+  date?: string;
+  image_url?: string[];
+  resources?: Array<{ image?: string; url?: string }>;
+};
+
+async function searchSanborn(city: string, state: string, page: number, perPage = 25): Promise<LocItem[]> {
+  // Sanborn collection search; faceted by location.
+  const params = new URLSearchParams({
+    q: `${city} ${state}`,
+    fa: `location:${state.toLowerCase()}`,
+    fo: 'json',
+    c: String(perPage),
+    sp: String(page),
+  });
+  const url = `https://www.loc.gov/collections/sanborn-maps/?${params}`;
+  for (let attempt = 0; attempt < 4; attempt++) {
+    const res = await fetch(url, {
+      headers: {
+        'User-Agent': 'pastdoor/0.1 (steve@pastdoor.com) historical research',
+        Accept: 'application/json',
+      },
+    });
+    if (res.status === 429) {
+      // Back off exponentially: 3s, 7s, 15s.
+      const wait = (attempt + 1) * (attempt + 1) * 1000 + 2000;
+      console.warn(`  [LoC 429] backing off ${wait}ms (attempt ${attempt + 1})`);
+      await new Promise(r => setTimeout(r, wait));
+      continue;
+    }
+    if (!res.ok) throw new Error(`LoC ${res.status}`);
+    const j = (await res.json()) as { results?: LocItem[] };
+    return j.results ?? [];
+  }
+  throw new Error('LoC 429 — exhausted retries');
+}
+
+function yearFromTitle(title: string): number | null {
+  const m = title.match(/\b(18|19|20)\d{2}\b/);
+  return m ? Number(m[0]) : null;
+}
+
+function pickThumb(item: LocItem): string | null {
+  if (item.image_url && item.image_url.length > 0) return item.image_url[0]!;
+  if (item.resources && item.resources.length > 0) {
+    const r = item.resources[0]!;
+    return r.image ?? r.url ?? null;
+  }
+  return null;
+}
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  max: 4,
+});
+
+async function main() {
+  const args = process.argv.slice(2);
+  const cityArg = args.find((a, i) => args[i - 1] === '--city') ?? 'Beverly Hills';
+  const stateArg = args.find((a, i) => args[i - 1] === '--state') ?? 'California';
+  const dry = args.includes('--dry');
+
+  const items: LocItem[] = [];
+  // pull up to 2 pages = 50 results max
+  for (let p = 1; p <= 2; p++) {
+    try {
+      const r = await searchSanborn(cityArg, stateArg, p);
+      if (r.length === 0) break;
+      items.push(...r);
+    } catch (e) {
+      console.warn(`page ${p} error:`, (e as Error).message);
+      break;
+    }
+    await new Promise(r => setTimeout(r, 800));
+  }
+  console.log(`LoC returned ${items.length} candidate items for ${cityArg}, ${stateArg}`);
+
+  // Filter: must look like a Sanborn sheet (title or url contains the city).
+  const cityRe = new RegExp(cityArg, 'i');
+  const filtered = items.filter(it => cityRe.test(it.title ?? '') || cityRe.test(it.id ?? ''));
+  console.log(`Filtered to ${filtered.length} likely-${cityArg} sheets`);
+
+  // Pull listings to attach sheets to.
+  const { rows: listings } = await pool.query<{ id: string; slug: string }>(
+    'SELECT id, slug FROM listing WHERE city = $1 AND is_public = true',
+    [cityArg]
+  );
+  if (listings.length === 0) {
+    console.log(`No public listings for ${cityArg}; nothing to attach.`);
+    await pool.end();
+    return;
+  }
+  console.log(`Attaching to ${listings.length} listings.`);
+
+  let inserted = 0;
+  let skipped = 0;
+  for (const item of filtered) {
+    const year = yearFromTitle(item.title ?? '');
+    const url = pickThumb(item);
+    if (!url) {
+      skipped++;
+      continue;
+    }
+    const caption = item.title ? item.title.slice(0, 200) : 'Sanborn sheet';
+    for (const l of listings) {
+      // Idempotency: skip if we already have this LoC item for this listing.
+      const existing = await pool.query(
+        `SELECT 1 FROM media_asset WHERE listing_id = $1 AND source_url = $2 LIMIT 1`,
+        [l.id, item.url ?? item.id]
+      );
+      if (existing.rowCount! > 0) {
+        skipped++;
+        continue;
+      }
+      if (dry) {
+        console.log(`[${l.slug}] DRY would insert ${year ?? '?'} :: ${caption.slice(0, 60)}`);
+        continue;
+      }
+      await pool.query(
+        `INSERT INTO media_asset
+           (listing_id, kind, year, caption, storage_url, source_tier, source_label, source_url, rights, public_visible)
+         VALUES ($1, 'historic_map', $2, $3, $4, 'B', $5, $6, 'Public domain (LoC)', true)`,
+        [l.id, year, caption, url, 'Library of Congress · Sanborn Maps Collection', item.url ?? item.id]
+      );
+      inserted++;
+    }
+  }
+  console.log(JSON.stringify({ found: items.length, sheets: filtered.length, listings: listings.length, inserted, skipped, dry }, null, 2));
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/ingest-pasadena-permits.ts b/scripts/ingest-pasadena-permits.ts
new file mode 100644
index 0000000..ea32da7
--- /dev/null
+++ b/scripts/ingest-pasadena-permits.ts
@@ -0,0 +1,163 @@
+/**
+ * ingest-pasadena-permits.ts
+ *
+ * Pasadena Active Building Permits via ArcGIS FeatureServer.
+ * Source: data.cityofpasadena.net item 24525f546e5e4346957bb55695afc448
+ * Verified 2026-04-30: 607 rows, daily refresh.
+ *
+ * Tier: A (Pasadena city government primary record).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+const FEATURE_URL = 'https://services2.arcgis.com/zNjnZafDYCAJAbN0/ArcGIS/rest/services/Permit_Activity/FeatureServer/0/query';
+const PAGE = 2000;
+
+type Feature = {
+  attributes: Record<string, any>;
+  geometry?: { x: number; y: number };
+};
+
+function canonicalize(addr: string): string {
+  return addr
+    .toLowerCase()
+    .replace(/[^\w\s-]/g, '')
+    .replace(/\s+/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .slice(0, 90);
+}
+
+async function fetchPage(offset: number): Promise<Feature[]> {
+  const params = new URLSearchParams({
+    where: '1=1',
+    outFields: '*',
+    returnGeometry: 'true',
+    outSR: '4326',
+    resultOffset: String(offset),
+    resultRecordCount: String(PAGE),
+    f: 'json',
+  });
+  const res = await fetch(`${FEATURE_URL}?${params}`);
+  const json = await res.json() as { features?: Feature[] };
+  return json.features ?? [];
+}
+
+async function ensureSchema() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS permit (
+      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+      source TEXT NOT NULL DEFAULT 'ladbs',
+      source_dataset TEXT NOT NULL,
+      permit_number TEXT NOT NULL,
+      listing_id UUID REFERENCES listing(id),
+      primary_address TEXT,
+      zip_code TEXT,
+      council_district TEXT,
+      apn TEXT,
+      zone TEXT,
+      permit_group TEXT,
+      permit_type TEXT,
+      permit_sub_type TEXT,
+      use_code TEXT,
+      use_desc TEXT,
+      issue_date DATE,
+      status_date DATE,
+      submitted_date DATE,
+      status_desc TEXT,
+      valuation NUMERIC(15,2),
+      square_footage INT,
+      work_desc TEXT,
+      neighborhood_council TEXT,
+      community_plan_area TEXT,
+      latitude NUMERIC(9,6),
+      longitude NUMERIC(9,6),
+      source_url TEXT,
+      source_tier CHAR(1) NOT NULL DEFAULT 'A',
+      source_label TEXT NOT NULL DEFAULT 'LA City Department of Building & Safety',
+      retrieved_at TIMESTAMPTZ DEFAULT now(),
+      UNIQUE (source_dataset, permit_number)
+    );
+  `);
+}
+
+async function upsertOne(f: Feature): Promise<void> {
+  // Pasadena fields (verified 2026-04-30): ADDRESS, CASE_NUMBER, DESCRIPTION,
+  // LAND_PARCEL_NO, PARCEL_NO, LATEST_ACTIVITY (epoch ms), TOTAL_SQFT
+  const a = f.attributes ?? {};
+  const addr = (a.ADDRESS ?? '').toString().trim();
+  if (!addr) return;
+  const permitNbr = (a.CASE_NUMBER ?? '').toString().trim();
+  if (!permitNbr) return;
+  const canonical = canonicalize(addr);
+  if (!canonical) return;
+
+  const lat = f.geometry?.y ?? null;
+  const lon = f.geometry?.x ?? null;
+
+  const lid = (await pool.query<{ id: string }>(
+    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
+     VALUES ($1,'pasadena_permit',$2,$3,$4,'Pasadena','CA','US',$5,$6,true,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
+       longitude = COALESCE(listing.longitude, EXCLUDED.longitude),
+       updated_at = now()
+     RETURNING id`,
+    [`${canonical}-pas`, `pasadena:${canonical}`, addr, addr, lat, lon]
+  )).rows[0]!.id;
+
+  let issueDate: string | null = null;
+  if (a.LATEST_ACTIVITY) {
+    const ms = parseInt(String(a.LATEST_ACTIVITY), 10);
+    if (isFinite(ms) && ms > 0) {
+      const d = new Date(ms);
+      if (d.getFullYear() >= 1900 && d.getFullYear() <= 2030) {
+        issueDate = d.toISOString().slice(0, 10);
+      }
+    }
+  }
+  await pool.query(
+    `INSERT INTO permit (source, source_dataset, permit_number, listing_id, primary_address, apn,
+       permit_type, permit_sub_type, status_desc, issue_date, valuation, work_desc,
+       square_footage, latitude, longitude, source_url, source_label)
+     VALUES ('pasadena','pasadena-permit-activity',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'City of Pasadena')
+     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+       status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
+       retrieved_at = now()`,
+    [
+      permitNbr, lid, addr, (a.LAND_PARCEL_NO ?? a.PARCEL_NO ?? null),
+      null, null, null,
+      issueDate, null, a.DESCRIPTION ?? null,
+      parseInt(String(a.TOTAL_SQFT ?? '0'), 10) || null,
+      lat, lon,
+      `https://data.cityofpasadena.net/datasets/24525f546e5e4346957bb55695afc448_0/explore?showTable=true`,
+    ]
+  );
+}
+
+async function main() {
+  await ensureSchema();
+  let offset = 0;
+  let total = 0;
+  while (true) {
+    const features = await fetchPage(offset);
+    if (!features.length) break;
+    for (const f of features) await upsertOne(f);
+    total += features.length;
+    console.log(`  pasadena: ${total} processed`);
+    if (features.length < PAGE) break;
+    offset += PAGE;
+  }
+  console.log(`✓ Pasadena permits: ${total} total`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-santa-monica-permits.ts b/scripts/ingest-santa-monica-permits.ts
new file mode 100644
index 0000000..a9b90c1
--- /dev/null
+++ b/scripts/ingest-santa-monica-permits.ts
@@ -0,0 +1,90 @@
+/**
+ * ingest-santa-monica-permits.ts
+ *
+ * Santa Monica Active Building & Safety Permits via CKAN datastore_search.
+ * Source: data.santamonica.gov resource d6867c7d-89bc-4975-be35-4d2673a4764b
+ * Verified 2026-04-30: 1,997 rows. ODC-BY 1.0 license.
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+const RESOURCE = 'd6867c7d-89bc-4975-be35-4d2673a4764b';
+const URL = `https://data.santamonica.gov/api/3/action/datastore_search`;
+const PAGE = 1000;
+
+function canonicalize(addr: string): string {
+  return addr.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+async function fetchPage(offset: number) {
+  const u = `${URL}?resource_id=${RESOURCE}&limit=${PAGE}&offset=${offset}`;
+  const res = await fetch(u);
+  const j = await res.json() as { result: { records: any[]; total: number } };
+  return j.result;
+}
+
+async function upsertOne(r: any) {
+  const addr = (r.address ?? '').toString().trim();
+  if (!addr) return;
+  const permitNbr = (r.permit_number ?? r._id ?? '').toString().trim();
+  if (!permitNbr) return;
+  const canonical = canonicalize(addr);
+  if (!canonical) return;
+  const lat = r.latitude ? parseFloat(r.latitude) : null;
+  const lon = r.longitude ? parseFloat(r.longitude) : null;
+
+  const lid = (await pool.query<{ id: string }>(
+    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
+     VALUES ($1,'santa_monica_permit',$2,$3,$4,'Santa Monica','CA','US',$5,$6,true,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
+       longitude = COALESCE(listing.longitude, EXCLUDED.longitude),
+       updated_at = now()
+     RETURNING id`,
+    [`${canonical}-sm`, `santamonica:${canonical}`, addr, addr, lat, lon]
+  )).rows[0]!.id;
+
+  const issueDate = r.date_issued ? r.date_issued.slice(0, 10) : null;
+  await pool.query(
+    `INSERT INTO permit (source, source_dataset, permit_number, listing_id, primary_address, apn,
+       permit_type, permit_sub_type, status_desc, issue_date, valuation, work_desc,
+       latitude, longitude, source_url, source_label)
+     VALUES ('santa_monica','sm-active-permits',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'City of Santa Monica')
+     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
+       status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
+       retrieved_at = now()`,
+    [
+      permitNbr, lid, addr, r.parcel_no ?? null,
+      r.permit_type_desc ?? r.permit_type ?? null, r.permit_sub_type ?? null,
+      r.status ?? null, issueDate, parseFloat(r.valuation ?? '0') || null,
+      r.description ?? null, lat, lon,
+      `https://data.santamonica.gov/dataset/active-building-and-safety-permits/resource/${RESOURCE}`,
+    ]
+  );
+}
+
+async function main() {
+  let offset = 0;
+  let total = 0;
+  while (true) {
+    const { records, total: tot } = await fetchPage(offset);
+    if (!records.length) break;
+    for (const r of records) await upsertOne(r);
+    total += records.length;
+    console.log(`  santa_monica: ${total}/${tot}`);
+    if (records.length < PAGE) break;
+    offset += PAGE;
+  }
+  console.log(`✓ Santa Monica permits: ${total} total`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/ingest-weho-film-permits.ts b/scripts/ingest-weho-film-permits.ts
new file mode 100644
index 0000000..0bd0eaa
--- /dev/null
+++ b/scripts/ingest-weho-film-permits.ts
@@ -0,0 +1,174 @@
+/**
+ * ingest-weho-film-permits.ts
+ *
+ * Pull every WeHo film permit (~1,821 since 2008) from Socrata and upsert into
+ * pastdoor's listing + film_production + filming_location tables.
+ *
+ * Source: https://data.weho.org/resource/xd5y-dzvf.json
+ *   - date, production, production_company, category, permit_no, address, location {lat,lng}
+ *
+ * Ingest pipeline:
+ *   - Each WeHo address becomes (or matches) a listing (slug = slugified address + WH suffix)
+ *   - Each unique production becomes a film_production row
+ *   - Each permit becomes a filming_location row (linking production ↔ listing)
+ *
+ * Idempotent — safe to run repeatedly. Uses ON CONFLICT for listing.source/source_id and
+ * filming_location.permit_number.
+ *
+ * Tier: A (gov data, City of West Hollywood official record).
+ */
+import { Pool } from 'pg';
+
+const SOCRATA = 'https://data.weho.org/resource/xd5y-dzvf.json';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+type WehoRow = {
+  date?: string;
+  production?: string;
+  production_company?: string;
+  category?: string;
+  permit_no?: string;
+  address?: string;
+  location?: { type: string; coordinates: [number, number] }; // [lng, lat]
+};
+
+function slugify(s: string): string {
+  return s
+    .toLowerCase()
+    .replace(/[^\w\s-]/g, '')
+    .replace(/\s+/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .slice(0, 100);
+}
+
+function classifyKind(category?: string): string {
+  const c = (category ?? '').toLowerCase();
+  if (c.includes('feature')) return 'film';
+  if (c.includes('tv') || c.includes('series')) return 'tv';
+  if (c.includes('commercial')) return 'commercial';
+  if (c.includes('web')) return 'web';
+  if (c.includes('still') || c.includes('photo')) return 'photo';
+  if (c.includes('music')) return 'music_video';
+  if (c.includes('doc')) return 'documentary';
+  if (c.includes('student')) return 'student';
+  return 'other';
+}
+
+async function fetchAll(): Promise<WehoRow[]> {
+  const all: WehoRow[] = [];
+  const PAGE = 1000;
+  let offset = 0;
+  while (true) {
+    const res = await fetch(`${SOCRATA}?$limit=${PAGE}&$offset=${offset}&$order=date%20DESC`);
+    if (!res.ok) throw new Error(`Socrata ${res.status}`);
+    const rows = (await res.json()) as WehoRow[];
+    all.push(...rows);
+    if (rows.length < PAGE) break;
+    offset += PAGE;
+  }
+  return all;
+}
+
+async function upsertListing(row: WehoRow): Promise<string | null> {
+  if (!row.address || !row.permit_no) return null;
+  const lat = row.location?.coordinates?.[1] ?? null;
+  const lng = row.location?.coordinates?.[0] ?? null;
+  // Slug includes coords-derived suffix to avoid collisions on similar addresses
+  const baseSlug = slugify(row.address) + '-wh';
+  const sourceId = `weho-${row.address.replace(/\s+/g, '-').toLowerCase()}`;
+  const result = await pool.query<{ id: string }>(
+    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
+     VALUES ($1,'weho_film_permit',$2,$3,$4,'West Hollywood','CA','US',$5,$6,true,'free')
+     ON CONFLICT (source, source_id) DO UPDATE SET latitude=COALESCE(listing.latitude,EXCLUDED.latitude), longitude=COALESCE(listing.longitude,EXCLUDED.longitude), updated_at=now()
+     RETURNING id`,
+    [baseSlug, sourceId, row.address, row.address, lat, lng]
+  );
+  return result.rows[0]?.id ?? null;
+}
+
+async function upsertProduction(row: WehoRow): Promise<string | null> {
+  if (!row.production) return null;
+  const slug = slugify(row.production) || `production-${row.permit_no}`;
+  const kind = classifyKind(row.category);
+  const year = row.date ? new Date(row.date).getFullYear() : null;
+  const blurb = row.production_company ? `Production company: ${row.production_company}` : null;
+  const result = await pool.query<{ id: string }>(
+    `INSERT INTO film_production (slug, title, kind, year, blurb, source_tier, source_label)
+     VALUES ($1,$2,$3,$4,$5,'A','WeHo Film Permit')
+     ON CONFLICT (slug) DO UPDATE SET title=EXCLUDED.title, kind=COALESCE(film_production.kind,EXCLUDED.kind)
+     RETURNING id`,
+    [slug, row.production, kind, year, blurb]
+  );
+  return result.rows[0]?.id ?? null;
+}
+
+async function upsertFilmingLocation(productionId: string, listingId: string, row: WehoRow): Promise<boolean> {
+  if (!row.permit_no) return false;
+  const dateStr = row.date ? new Date(row.date).toISOString().slice(0, 10) : null;
+  const r = await pool.query(
+    `INSERT INTO filming_location (production_id, listing_id, permit_number, role, shoot_date_from, source_tier, source_label, public_visible)
+     VALUES ($1,$2,$3,$4,$5,'A','WeHo Film Permit',true)
+     ON CONFLICT (production_id, listing_id, permit_number) DO NOTHING`,
+    [productionId, listingId, row.permit_no, row.category ?? null, dateStr]
+  );
+  return (r.rowCount ?? 0) > 0;
+}
+
+async function main() {
+  console.log(`Fetching all WeHo film permits from ${SOCRATA} ...`);
+  const rows = await fetchAll();
+  console.log(`  → ${rows.length} rows`);
+
+  let listingsTouched = 0;
+  let productionsTouched = 0;
+  let filmingLocationsInserted = 0;
+  let skipped = 0;
+
+  for (const [i, row] of rows.entries()) {
+    if (!row.address || !row.permit_no || !row.production) {
+      skipped++;
+      continue;
+    }
+    try {
+      const listingId = await upsertListing(row);
+      if (!listingId) { skipped++; continue; }
+      listingsTouched++;
+      const productionId = await upsertProduction(row);
+      if (!productionId) { skipped++; continue; }
+      productionsTouched++;
+      if (await upsertFilmingLocation(productionId, listingId, row)) {
+        filmingLocationsInserted++;
+      }
+    } catch (e) {
+      console.error(`Row ${i} (${row.permit_no}): ${(e as Error).message}`);
+      skipped++;
+    }
+    if ((i + 1) % 100 === 0) {
+      console.log(`  ${i + 1}/${rows.length} processed`);
+    }
+  }
+
+  console.log('');
+  console.log({
+    rows: rows.length,
+    listings_touched: listingsTouched,
+    productions_touched: productionsTouched,
+    filming_locations_inserted: filmingLocationsInserted,
+    skipped,
+  });
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error('FATAL', e);
+  process.exit(1);
+});
diff --git a/scripts/ingest-wikipedia-named-houses.ts b/scripts/ingest-wikipedia-named-houses.ts
new file mode 100644
index 0000000..1529fea
--- /dev/null
+++ b/scripts/ingest-wikipedia-named-houses.ts
@@ -0,0 +1,141 @@
+/**
+ * ingest-wikipedia-named-houses.ts
+ *
+ * Pulls Wikipedia category "Houses in Beverly Hills, California" + sister
+ * categories (Bel Air, Hollywood Hills, Holmby Hills, Brentwood, Hancock Park)
+ * to seed named historical estates (Pickfair, Greystone, Falcon Lair, etc.)
+ * into the archive.
+ *
+ * For each article: fetch summary (title, description, extract, lat/lon, URL),
+ * then upsert as a `listing` row + a `place_event` row tagged with kind='named_estate'
+ * pointing back to the actual Wikipedia article (drill-down per the
+ * feedback_drill_down_archive_urls memory rule).
+ *
+ * Tier: B (Wikipedia is verified-aggregator, not primary archive).
+ */
+import { Pool } from 'pg';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 4,
+});
+
+const CATEGORIES = [
+  'Houses in Beverly Hills, California',
+  'National Register of Historic Places in Los Angeles County, California',
+  'Houses on the National Register of Historic Places in California',
+  'Historic American Buildings Survey in California',
+  'Houses in Pasadena, California',
+  'Houses in Santa Monica, California',
+  'Tourist attractions in Los Angeles',
+  'Buildings and structures in Beverly Hills, California',
+  'Historic districts in Los Angeles',
+];
+
+async function fetchCategoryMembers(catTitle: string): Promise<string[]> {
+  const url = `https://en.wikipedia.org/w/api.php?action=query&list=categorymembers&cmtitle=${encodeURIComponent('Category:' + catTitle)}&cmlimit=200&format=json`;
+  const res = await fetch(url, { headers: { 'User-Agent': 'stayclaim-ingest/1.0 (+https://wholivedthere.com)' } });
+  if (!res.ok) return [];
+  const j = await res.json() as { query?: { categorymembers?: { title: string; ns: number }[] } };
+  return (j.query?.categorymembers ?? []).filter(m => m.ns === 0).map(m => m.title);
+}
+
+async function fetchSummary(title: string) {
+  const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title.replace(/ /g, '_'))}`;
+  const res = await fetch(url, { headers: { 'User-Agent': 'stayclaim-ingest/1.0 (+https://wholivedthere.com)' } });
+  if (!res.ok) return null;
+  const j = await res.json() as any;
+  return {
+    title: j.title as string,
+    description: j.description as string | undefined,
+    extract: j.extract as string | undefined,
+    coords: j.coordinates as { lat: number; lon: number } | undefined,
+    url: j.content_urls?.desktop?.page as string | undefined,
+    pageId: j.pageid as number | undefined,
+  };
+}
+
+function slugify(s: string): string {
+  return s.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 90);
+}
+
+async function ensureListing(name: string, summary: any, city: string): Promise<string | null> {
+  if (!summary.coords) return null;
+  const slug = `${slugify(name)}-house-wp`;
+  const r = await pool.query<{ id: string }>(
+    `INSERT INTO listing
+       (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier, headline, description)
+     VALUES ($1,'wikipedia_house',$2,$3,$4,$5,'CA','US',$6,$7,true,'free',$8,$9)
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
+       longitude = COALESCE(listing.longitude, EXCLUDED.longitude),
+       headline = COALESCE(listing.headline, EXCLUDED.headline),
+       description = COALESCE(listing.description, EXCLUDED.description),
+       updated_at = now()
+     RETURNING id`,
+    [
+      slug,
+      `wikipedia:${summary.pageId ?? slug}`,
+      name,                                        // title — the named estate
+      `${name} (named estate)`,                    // address_line1 — placeholder; usually the article body has actual address
+      city,
+      summary.coords.lat,
+      summary.coords.lon,
+      summary.description ?? null,
+      summary.extract ?? null,
+    ]
+  );
+  return r.rows[0]?.id ?? null;
+}
+
+async function ensureEvent(listingId: string, name: string, summary: any) {
+  await pool.query(
+    `INSERT INTO place_event
+       (listing_id, kind, summary, source_tier, source_label, source_url, source_id, public_visible, confidence)
+     VALUES ($1,'named_estate',$2,'B','Wikipedia',$3,$4,true,0.9)
+     ON CONFLICT DO NOTHING`,
+    [
+      listingId,
+      `${name}${summary.description ? ` — ${summary.description}` : ''}.${summary.extract ? ' ' + summary.extract.slice(0, 250) : ''}`,
+      summary.url ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(name.replace(/ /g, '_'))}`,
+      `wp_estate:${summary.pageId ?? slugify(name)}`,
+    ]
+  );
+}
+
+async function main() {
+  let totalProcessed = 0, totalIngested = 0, totalSkipped = 0;
+  for (const cat of CATEGORIES) {
+    const titles = await fetchCategoryMembers(cat);
+    console.log(`\n=== Category: ${cat} (${titles.length} articles) ===`);
+    const cityHint = cat.replace(/^Houses in /, '').replace(/^Mansions in /, '').replace(/, .*$/, '').replace(', Los Angeles', '').trim();
+    for (const title of titles) {
+      const summary = await fetchSummary(title);
+      if (!summary) { totalSkipped++; continue; }
+      if (!summary.coords) {
+        console.log(`  - ${title}: no coords, skipping`);
+        totalSkipped++;
+        continue;
+      }
+      const lid = await ensureListing(title, summary, cityHint);
+      if (lid) {
+        await ensureEvent(lid, title, summary);
+        totalIngested++;
+        console.log(`  ✓ ${title} (${summary.coords.lat.toFixed(4)}, ${summary.coords.lon.toFixed(4)})`);
+      } else {
+        totalSkipped++;
+      }
+      totalProcessed++;
+      // Polite Wikipedia rate (1 req/sec sustained)
+      await new Promise(r => setTimeout(r, 250));
+    }
+  }
+  console.log(`\n✓ named houses: ${totalIngested} ingested, ${totalSkipped} skipped, ${totalProcessed} processed`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/pd-film-images-refresh.sh b/scripts/pd-film-images-refresh.sh
new file mode 100755
index 0000000..5480657
--- /dev/null
+++ b/scripts/pd-film-images-refresh.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+# Nightly refresh of public-domain film/TV images on Kamatera pastdoor.
+# Runs from Mac Studio 2 launchd → SSHs into Kamatera → runs the fetcher
+# inside the pastdoor cwd so .env.production is picked up automatically.
+#
+# Per Steve's "no cloud agents" rule (memory: feedback_no_cloud_agents.md),
+# the schedule lives on the Mac, not on Kamatera.
+
+set -euo pipefail
+
+LOG=/Users/stevestudio2/Library/Logs/pd-film-images-refresh.log
+ts() { date +'%Y-%m-%d %H:%M:%S'; }
+
+echo "[$(ts)] starting pd-film-images refresh" >> "$LOG"
+
+ssh -o ConnectTimeout=15 -o BatchMode=yes root@45.61.58.125 \
+  'cd /root/public-projects/pastdoor && set -a && source .env.production && set +a && timeout 180 node scripts/fetch-pd-film-images.mjs --limit=400' \
+  >> "$LOG" 2>&1
+
+rc=$?
+echo "[$(ts)] finished rc=$rc" >> "$LOG"
+exit $rc
diff --git a/scripts/prewarm-la-parcel.mjs b/scripts/prewarm-la-parcel.mjs
new file mode 100644
index 0000000..171d76a
--- /dev/null
+++ b/scripts/prewarm-la-parcel.mjs
@@ -0,0 +1,132 @@
+#!/usr/bin/env node
+/**
+ * Bulk pre-warm la_parcel cache for every public listing in the DB.
+ *
+ * For each listing: build query string from address_line1 + city, hit ArcGIS,
+ * upsert la_parcel. Throttled (300ms gap) to be polite to the public layer.
+ *
+ * Run from pastdoor cwd (so .env.production loads):
+ *   cd /root/public-projects/pastdoor && node scripts/prewarm-la-parcel.mjs
+ *
+ * Idempotent — skips listings that already have a fresh la_parcel row (TTL 30d
+ * via the same logic in lib/la-arcgis.ts).
+ */
+import pg from 'pg';
+
+const ARCGIS_BASE = 'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
+const FIELDS = [
+  'APN', 'SitusFullAddress', 'SitusHouseNo', 'SitusDirection', 'SitusStreet',
+  'SitusUnit', 'SitusCity', 'SitusZIP',
+  'UseCode', 'UseType', 'UseDescription',
+  'YearBuilt1', 'EffectiveYear1',
+  'Units1', 'Bedrooms1', 'Bathrooms1', 'SQFTmain1',
+  'Roll_Year', 'Roll_LandValue', 'Roll_ImpValue', 'Roll_HomeOwnersExemp',
+  'CENTER_LAT', 'CENTER_LON',
+].join(',');
+const UA = 'pastdoor/1.0-prewarm (https://wholivedthere.com)';
+
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
+
+function normalize(addr) {
+  return String(addr).toUpperCase()
+    .replace(/[,]/g, ' ')
+    .replace(/\b(NORTH|SOUTH|EAST|WEST)\b/g, m => m[0])
+    .replace(/\b(STREET|AVENUE|BOULEVARD|DRIVE|ROAD|PLACE|TERRACE|COURT|LANE|WAY)\b/g,
+      m => ({ STREET: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR', ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT', LANE: 'LN', WAY: 'WAY' }[m]))
+    .replace(/\s+/g, ' ').trim();
+}
+
+function tokens(norm) {
+  const parts = norm.split(' ');
+  const numIdx = parts.findIndex(p => /^\d+$/.test(p));
+  const houseNo = numIdx >= 0 ? parts[numIdx] : null;
+  const cityHints = ['BEVERLY','HOLLYWOOD','LOS','WEST','SANTA','PASADENA','BURBANK','GLENDALE','CULVER','MALIBU','LONG'];
+  const after = parts.slice(numIdx + 1);
+  const cityIdx = after.findIndex(p => cityHints.includes(p));
+  const street = (cityIdx >= 0 ? after.slice(0, cityIdx) : after).join(' ');
+  return { houseNo, street };
+}
+
+async function fetchArcGis(where) {
+  const url = `${ARCGIS_BASE}?where=${encodeURIComponent(where)}&outFields=${FIELDS}&f=json&resultRecordCount=1`;
+  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+  if (!r.ok) return [];
+  const d = await r.json().catch(() => null);
+  return d?.features ?? [];
+}
+
+async function lookup(addrInput) {
+  const norm = normalize(addrInput);
+  const { houseNo, street } = tokens(norm);
+  if (!houseNo || !street) return null;
+  const tries = [
+    `SitusFullAddress LIKE '${houseNo} %${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} N ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} S ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} E ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} W ${street}%'`,
+  ];
+  for (const where of tries) {
+    const feats = await fetchArcGis(where);
+    if (feats.length) return feats[0].attributes;
+  }
+  return null;
+}
+
+async function upsert(listingId, a) {
+  const num = v => { const n = Number(v); return Number.isFinite(n) && n !== 0 ? n : null; };
+  const land = num(a.Roll_LandValue);
+  const imp  = num(a.Roll_ImpValue);
+  await pool.query(
+    `INSERT INTO la_parcel
+      (apn, listing_id, situs_address, city, zip, use_code, use_type, use_description,
+       year_built, effective_year, sqft_main, bedrooms, bathrooms, units,
+       roll_year, land_value, improvement_value, total_value, homeowner_exempt,
+       lat, lng, raw, fetched_at)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22, now())
+     ON CONFLICT (apn) DO UPDATE SET
+       listing_id = COALESCE(EXCLUDED.listing_id, la_parcel.listing_id),
+       situs_address = EXCLUDED.situs_address,
+       year_built = EXCLUDED.year_built,
+       sqft_main = EXCLUDED.sqft_main,
+       roll_year = EXCLUDED.roll_year,
+       land_value = EXCLUDED.land_value,
+       improvement_value = EXCLUDED.improvement_value,
+       total_value = EXCLUDED.total_value,
+       fetched_at = now()`,
+    [
+      String(a.APN), listingId, a.SitusFullAddress, a.SitusCity?.trim() ?? null, a.SitusZIP?.trim() ?? null,
+      a.UseCode ?? null, a.UseType ?? null, a.UseDescription ?? null,
+      num(a.YearBuilt1), num(a.EffectiveYear1), num(a.SQFTmain1),
+      num(a.Bedrooms1), num(a.Bathrooms1), num(a.Units1),
+      num(a.Roll_Year), land, imp, (land ?? 0) + (imp ?? 0) || null, !!a.Roll_HomeOwnersExemp,
+      num(a.CENTER_LAT), num(a.CENTER_LON), JSON.stringify(a),
+    ]
+  );
+}
+
+(async () => {
+  const { rows } = await pool.query(
+    `SELECT l.id, l.slug, l.address_line1, l.city, l.title
+     FROM listing l
+     LEFT JOIN la_parcel p ON p.listing_id = l.id AND p.fetched_at > now() - interval '30 days'
+     WHERE l.is_public = true AND p.apn IS NULL
+     ORDER BY l.updated_at DESC`
+  );
+  console.log(`pre-warming ${rows.length} listings`);
+  let hits = 0, misses = 0, errors = 0;
+  for (const l of rows) {
+    const q = `${l.address_line1 ?? l.title} ${l.city ?? ''}`.trim();
+    try {
+      const a = await lookup(q);
+      if (a) { await upsert(l.id, a); hits++; if (hits % 25 === 0) console.log(`  ${hits} hits / ${misses} misses ...`); }
+      else { misses++; }
+    } catch (e) {
+      errors++;
+      console.warn(`  ${l.slug}: ${e?.message ?? e}`);
+    }
+    await new Promise(r => setTimeout(r, 300));  // be polite
+  }
+  console.log(`done: ${hits} hits, ${misses} misses, ${errors} errors`);
+  await pool.end();
+})();
diff --git a/scripts/pull-la-newspapers-ocr.ts b/scripts/pull-la-newspapers-ocr.ts
new file mode 100644
index 0000000..92ca950
--- /dev/null
+++ b/scripts/pull-la-newspapers-ocr.ts
@@ -0,0 +1,173 @@
+/**
+ * pull-la-newspapers-ocr.ts
+ *
+ * Phase 2 of the LA newspaper ingest. Pulls IA `_djvu.txt` plain-text OCR
+ * for issues with NULL ocr_text in news_issue. Uses the official Internet
+ * Archive `ia` CLI (Python, on-policy bulk tool) with 10 parallel workers.
+ * Each worker downloads → reads file → upserts to PG → deletes file.
+ *
+ * Source: https://archive.org/details/{identifier} via `ia download`
+ *
+ * Usage: npx tsx scripts/pull-la-newspapers-ocr.ts [--paper=LADailyHerald]
+ *                                                  [--limit=100]
+ *                                                  [--concurrency=10]
+ *                                                  [--workdir=/tmp/ia-ocr]
+ */
+import { Pool } from 'pg';
+import { spawn } from 'node:child_process';
+import { readFile, mkdir, rm, stat } from 'node:fs/promises';
+import { join } from 'node:path';
+
+const pool = new Pool({
+  host: process.env.PGHOST ?? '/tmp',
+  database: process.env.PGDATABASE ?? 'stayclaim',
+  user: process.env.PGUSER ?? process.env.USER,
+  password: process.env.PGPASSWORD,
+  port: parseInt(process.env.PGPORT ?? '5432', 10),
+  max: 12,
+});
+
+const args = Object.fromEntries(
+  process.argv.slice(2).map(a => {
+    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+    return m ? [m[1], m[2] ?? 'true'] : [a, 'true'];
+  })
+);
+const PAPER       = args.paper as string | undefined;
+const LIMIT       = args.limit ? parseInt(args.limit as string, 10) : undefined;
+const CONCURRENCY = args.concurrency ? parseInt(args.concurrency as string, 10) : 10;
+const WORKDIR     = (args.workdir as string) || '/tmp/ia-ocr';
+const MAX_BYTES   = (args['skip-large'] ? parseFloat(args['skip-large'] as string) : 5) * 1024 * 1024;
+
+type PendingRow = { id: string; source_identifier: string; paper_short: string };
+
+async function loadQueue(): Promise<PendingRow[]> {
+  const params: any[] = [];
+  let where = 'ocr_text IS NULL';
+  if (PAPER) { where += ` AND paper_short = $${params.length+1}`; params.push(PAPER); }
+  let sql = `SELECT id, source_identifier, paper_short FROM news_issue WHERE ${where} ORDER BY issue_date NULLS LAST`;
+  if (LIMIT) sql += ` LIMIT ${LIMIT}`;
+  const { rows } = await pool.query<PendingRow>(sql, params);
+  return rows;
+}
+
+function iaDownload(identifier: string, cwd: string): Promise<{ ok: boolean; reason?: string }> {
+  return new Promise((resolve) => {
+    const args = [
+      'download', identifier,
+      '--glob=*_djvu.txt',
+      '--retries=3',
+      '--no-change-timestamp',
+      '-q',
+    ];
+    const proc = spawn('ia', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
+    let stderr = '';
+    proc.stderr.on('data', (d) => { stderr += d.toString(); });
+    proc.on('close', (code) => {
+      if (code === 0) resolve({ ok: true });
+      else resolve({ ok: false, reason: `ia exit ${code}: ${stderr.slice(0, 200)}` });
+    });
+    proc.on('error', (e) => resolve({ ok: false, reason: String(e) }));
+  });
+}
+
+const stats = {
+  processed: 0,
+  fetched: 0,
+  empty: 0,
+  failed: 0,
+  bytes: 0,
+};
+
+async function processOne(row: PendingRow): Promise<void> {
+  const subdir = join(WORKDIR, row.source_identifier);
+  const txtPath = join(subdir, `${row.source_identifier}_djvu.txt`);
+  try {
+    const r = await iaDownload(row.source_identifier, WORKDIR);
+    if (!r.ok) {
+      stats.failed++;
+      console.error(`  FAIL ${row.source_identifier}: ${r.reason}`);
+      return;
+    }
+    let text: string;
+    try {
+      const st = await stat(txtPath);
+      if (st.size > MAX_BYTES) {
+        stats.empty++;
+        await pool.query(`UPDATE news_issue SET ocr_text='', ocr_fetched_at=now() WHERE id=$1`, [row.id]);
+        return;
+      }
+      text = await readFile(txtPath, 'utf8');
+    } catch {
+      // ia returned ok but no _djvu.txt found — issue may not have OCR
+      stats.empty++;
+      await pool.query(`UPDATE news_issue SET ocr_text='', ocr_fetched_at=now() WHERE id=$1`, [row.id]);
+      return;
+    }
+    await pool.query(
+      `UPDATE news_issue SET ocr_text=$1, ocr_fetched_at=now() WHERE id=$2`,
+      [text, row.id]
+    );
+    stats.fetched++;
+    stats.bytes += text.length;
+  } catch (e) {
+    stats.failed++;
+    console.error(`  FAIL ${row.source_identifier}: ${(e as Error).message}`);
+  } finally {
+    stats.processed++;
+    // clean up downloaded file/dir
+    await rm(subdir, { recursive: true, force: true }).catch(() => {});
+  }
+}
+
+async function worker(workerId: number, queue: PendingRow[]): Promise<void> {
+  while (queue.length > 0) {
+    const row = queue.shift();
+    if (!row) break;
+    await processOne(row);
+  }
+}
+
+async function main() {
+  await mkdir(WORKDIR, { recursive: true });
+  // wipe any stragglers from a previous run
+  for await (const _ of [] as any[]) {}
+  const queue = await loadQueue();
+  console.log(`pull-la-newspapers-ocr (ia parallel)`);
+  console.log(`  paper=${PAPER ?? '(all)'}  queue=${queue.length}  concurrency=${CONCURRENCY}  workdir=${WORKDIR}`);
+  if (queue.length === 0) {
+    console.log('  nothing to do');
+    await pool.end();
+    return;
+  }
+
+  const t0 = Date.now();
+  const reporter = setInterval(() => {
+    const dt = (Date.now() - t0) / 1000;
+    const rate = stats.processed / dt;
+    const remaining = queue.length;
+    const etaSec = remaining / Math.max(rate, 0.1);
+    const mb = (stats.bytes / 1024 / 1024).toFixed(1);
+    process.stdout.write(
+      `  ${stats.processed} done (ok=${stats.fetched} empty=${stats.empty} fail=${stats.failed}) — ${rate.toFixed(2)}/s — ${mb} MB — ${remaining} left, ETA ${(etaSec/60).toFixed(1)}m   \r`
+    );
+  }, 2000);
+
+  const workers = Array.from({ length: CONCURRENCY }, (_, i) => worker(i, queue));
+  await Promise.all(workers);
+
+  clearInterval(reporter);
+  const dt = (Date.now() - t0) / 1000;
+  console.log('');
+  console.log('--- done ---');
+  console.log(`  processed=${stats.processed}  fetched=${stats.fetched}  empty=${stats.empty}  failed=${stats.failed}`);
+  console.log(`  elapsed=${dt.toFixed(1)}s  rate=${(stats.processed/dt).toFixed(2)}/s`);
+  console.log(`  bytes=${(stats.bytes/1024/1024).toFixed(1)} MB`);
+  await rm(WORKDIR, { recursive: true, force: true }).catch(() => {});
+  await pool.end();
+}
+
+main().catch(e => {
+  console.error('FATAL', e);
+  process.exit(1);
+});
diff --git a/scripts/seed-restaurants-batch2.mjs b/scripts/seed-restaurants-batch2.mjs
new file mode 100644
index 0000000..56c344d
--- /dev/null
+++ b/scripts/seed-restaurants-batch2.mjs
@@ -0,0 +1,335 @@
+#!/usr/bin/env node
+/**
+ * Restaurants batch 2 — 30 more iconic LA restaurants with curated patron lists.
+ * Mix of still-open (Lawry's, Yamashiro, Pink's) and historical (Chasen's,
+ * Romanoff's already in, Trader Vic's). Each patron sourced via Wikipedia's
+ * "Notable patrons / clientele" sections of the restaurant article.
+ *
+ * Source tier C (verified secondary). Idempotent on slug.
+ */
+import pg from 'pg';
+
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
+
+const RESTAURANTS = [
+  // Still open — long history
+  { slug: 'philippe-the-original', name: 'Philippe the Original', cuisine: 'French dip',
+    address: '1001 N Alameda St', city: 'Los Angeles', zip: '90012',
+    lat: 34.0596, lng: -118.2362, opened: 1908, closed: null,
+    blurb: 'One of two LA restaurants that claim to have invented the French dip sandwich, in continuous operation since 1908.',
+    source: 'https://en.wikipedia.org/wiki/Philippe_the_Original',
+    patrons: [
+      { name: 'Frank Sinatra', era: '1950s', tier: 'C', notes: 'Sinatra and the Rat Pack regulars' },
+      { name: 'Sandra Bullock', era: '2000s', tier: 'C' },
+      { name: 'Bill Clinton', era: '2000s', tier: 'C', notes: 'Photographed dining there' },
+    ]},
+  { slug: 'coles-french-dip', name: "Cole's", cuisine: 'French dip',
+    address: '118 E 6th St', city: 'Los Angeles', zip: '90014',
+    lat: 34.0444, lng: -118.2510, opened: 1908, closed: null,
+    blurb: 'The other 1908 French-dip claimant — speakeasy bar in the back, downtown landmark.',
+    source: 'https://en.wikipedia.org/wiki/Cole%27s_P.E._Buffet',
+    patrons: [
+      { name: 'Charles Bukowski', era: '1960s-70s', tier: 'C', notes: 'Frequent drinker at the bar' },
+      { name: 'Mickey Cohen', era: '1940s', tier: 'C', notes: 'Mob hangout' },
+    ]},
+  { slug: 'pinks-hot-dogs', name: "Pink's Hot Dogs", cuisine: 'Hot dogs',
+    address: '709 N La Brea Ave', city: 'Los Angeles', zip: '90038',
+    lat: 34.0830, lng: -118.3441, opened: 1939, closed: null,
+    blurb: 'La Brea hot-dog stand since 1939; the line at midnight is the LA equivalent of Joe & the Juice in the morning.',
+    source: 'https://en.wikipedia.org/wiki/Pink%27s_Hot_Dogs',
+    patrons: [
+      { name: 'Orson Welles', era: '1970s', tier: 'C', notes: 'Reportedly ate 18 hot dogs in one sitting' },
+      { name: 'Bruce Willis', era: '1980s', tier: 'C' },
+      { name: 'Aretha Franklin', era: '1990s', tier: 'C' },
+      { name: 'Diane Keaton', era: '1980s', tier: 'C' },
+    ]},
+  { slug: 'apple-pan', name: 'The Apple Pan', cuisine: 'Burgers',
+    address: '10801 W Pico Blvd', city: 'Los Angeles', zip: '90064',
+    lat: 34.0418, lng: -118.4297, opened: 1947, closed: null,
+    blurb: 'U-shaped counter, no tables, hickory burger and apple pie since 1947 — same family.',
+    source: 'https://en.wikipedia.org/wiki/Apple_Pan',
+    patrons: [
+      { name: 'Steve McQueen', era: '1960s', tier: 'C', notes: 'Westside regular' },
+    ]},
+  { slug: 'tito-s-tacos', name: "Tito's Tacos", cuisine: 'Mexican',
+    address: '11222 Washington Pl', city: 'Culver City', zip: '90230',
+    lat: 34.0152, lng: -118.4115, opened: 1959, closed: null,
+    blurb: 'Culver City taco institution since 1959 — the line wraps around the block on weekends.',
+    source: 'https://en.wikipedia.org/wiki/Tito%27s_Tacos',
+    patrons: [
+      { name: 'Drew Barrymore', era: '1990s', tier: 'C' },
+    ]},
+  { slug: 'canters-deli', name: "Canter's Deli", cuisine: 'Jewish deli',
+    address: '419 N Fairfax Ave', city: 'Los Angeles', zip: '90036',
+    lat: 34.0808, lng: -118.3625, opened: 1931, closed: null,
+    blurb: '24-hour Fairfax Jewish deli; the Kibitz Room hosted Joni Mitchell, the Doors, Frank Zappa.',
+    source: 'https://en.wikipedia.org/wiki/Canter%27s',
+    patrons: [
+      { name: 'Joni Mitchell', era: '1970s', tier: 'C', notes: 'Kibitz Room regular' },
+      { name: 'Jim Morrison', era: '1960s', tier: 'C', notes: 'Doors hangout' },
+      { name: 'Frank Zappa', era: '1960s-70s', tier: 'C' },
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C' },
+      { name: 'Buddy Holly', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'langers-deli', name: "Langer's Delicatessen", cuisine: 'Jewish deli',
+    address: '704 S Alvarado St', city: 'Los Angeles', zip: '90057',
+    lat: 34.0635, lng: -118.2755, opened: 1947, closed: null,
+    blurb: 'MacArthur Park pastrami since 1947 — Nora Ephron called it the best pastrami sandwich in America.',
+    source: 'https://en.wikipedia.org/wiki/Langer%27s_Delicatessen-Restaurant',
+    patrons: [
+      { name: 'Nora Ephron', era: '2000s', tier: 'C', notes: 'Famously declared #19 the best pastrami sandwich' },
+      { name: 'Tom Hanks', era: '2000s', tier: 'C' },
+    ]},
+  { slug: 'nate-n-als', name: "Nate 'n Al's", cuisine: 'Jewish deli',
+    address: '414 N Beverly Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0707, lng: -118.4023, opened: 1945, closed: null,
+    blurb: 'Beverly Hills deli where Larry King held court in the same booth for decades.',
+    source: 'https://en.wikipedia.org/wiki/Nate_%27n_Al%27s',
+    patrons: [
+      { name: 'Larry King', era: '1980s-2010s', tier: 'C', notes: 'Daily breakfast at Booth 1 for 30+ years' },
+      { name: 'Jack Benny', era: '1950s-60s', tier: 'C' },
+      { name: 'George Burns', era: '1950s-90s', tier: 'C' },
+      { name: 'Doris Day', era: '1960s', tier: 'C' },
+    ]},
+  { slug: 'lawrys-the-prime-rib', name: "Lawry's The Prime Rib", cuisine: 'Steakhouse',
+    address: '100 N La Cienega Blvd', city: 'Beverly Hills', zip: '90211',
+    lat: 34.0741, lng: -118.3762, opened: 1938, closed: null,
+    blurb: 'Tableside prime-rib carving since 1938 — uniformed servers, silver carving carts.',
+    source: 'https://en.wikipedia.org/wiki/Lawry%27s_The_Prime_Rib',
+    patrons: [
+      { name: 'Walt Disney', era: '1940s-60s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1960s', tier: 'C' },
+    ]},
+  { slug: 'yamashiro-hollywood', name: 'Yamashiro', cuisine: 'Japanese',
+    address: '1999 N Sycamore Ave', city: 'Los Angeles', zip: '90068',
+    lat: 34.1059, lng: -118.3413, opened: 1914, closed: null,
+    blurb: 'Hilltop Japanese restaurant in a 1914 mansion built as a private home, with sweeping views of Hollywood.',
+    source: 'https://en.wikipedia.org/wiki/Yamashiro_(restaurant)',
+    patrons: [
+      { name: 'Lillian Gish', era: '1920s', tier: 'C', notes: 'When the building was the "400 Club"' },
+    ]},
+  { slug: 'magic-castle', name: 'The Magic Castle', cuisine: 'Private club',
+    address: '7001 Franklin Ave', city: 'Los Angeles', zip: '90028',
+    lat: 34.1052, lng: -118.3414, opened: 1963, closed: null,
+    blurb: 'Private members-only Victorian mansion devoted to magic, behind a hidden bookcase entrance.',
+    source: 'https://en.wikipedia.org/wiki/Magic_Castle',
+    patrons: [
+      { name: 'Cary Grant', era: '1970s-80s', tier: 'C', notes: 'Board member' },
+      { name: 'Orson Welles', era: '1970s', tier: 'C' },
+      { name: 'Johnny Carson', era: '1970s-80s', tier: 'C' },
+    ]},
+  { slug: 'beverly-hills-hotel', name: 'Beverly Hills Hotel', cuisine: 'Hotel · Polo Lounge',
+    address: '9641 Sunset Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0814, lng: -118.4137, opened: 1912, closed: null,
+    blurb: 'The Pink Palace — bungalows, the Polo Lounge, a lobby that hosted half of golden-age Hollywood.',
+    source: 'https://en.wikipedia.org/wiki/Beverly_Hills_Hotel',
+    patrons: [
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C', notes: 'Bungalow 7 regularly' },
+      { name: 'Elizabeth Taylor', era: '1950s-70s', tier: 'C', notes: 'Multiple honeymoons in Bungalow 5' },
+      { name: 'Howard Hughes', era: '1940s-50s', tier: 'C', notes: 'Lived in Bungalow 4 on and off for 30 years' },
+      { name: 'Charlie Chaplin', era: '1920s-50s', tier: 'C' },
+      { name: 'John Lennon', era: '1970s', tier: 'C' },
+    ]},
+  { slug: 'hotel-bel-air', name: 'Hotel Bel-Air', cuisine: 'Hotel · Wolfgang Puck restaurant',
+    address: '701 Stone Canyon Rd', city: 'Los Angeles', zip: '90077',
+    lat: 34.0822, lng: -118.4441, opened: 1946, closed: null,
+    blurb: 'Pink Spanish-Mission hideaway with swans on a private lake — old-Hollywood discretion at scale.',
+    source: 'https://en.wikipedia.org/wiki/Hotel_Bel-Air',
+    patrons: [
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C', notes: 'Last photo session was here, July 1962' },
+      { name: 'Grace Kelly', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'el-coyote-cafe', name: 'El Coyote Café', cuisine: 'Mexican',
+    address: '7312 Beverly Blvd', city: 'Los Angeles', zip: '90036',
+    lat: 34.0760, lng: -118.3475, opened: 1931, closed: null,
+    blurb: 'Beverly Blvd Mexican restaurant since 1931; Sharon Tate ate her last meal here on the night of August 8, 1969.',
+    source: 'https://en.wikipedia.org/wiki/El_Coyote_Cafe',
+    patrons: [
+      { name: 'Sharon Tate', era: '1969', tier: 'B', notes: 'Last meal, Aug 8 1969 — booth #3' },
+    ]},
+  { slug: 'pacific-dining-car', name: 'Pacific Dining Car', cuisine: 'Steakhouse',
+    address: '1310 W 6th St', city: 'Los Angeles', zip: '90017',
+    lat: 34.0568, lng: -118.2700, opened: 1921, closed: 2020,
+    blurb: '24-hour steakhouse in a converted 1921 rail dining car; downtown power-breakfast institution until COVID.',
+    source: 'https://en.wikipedia.org/wiki/Pacific_Dining_Car',
+    patrons: [
+      { name: 'Tom Bradley', era: '1980s', tier: 'C', notes: 'Mayor — late-night working dinners' },
+    ]},
+  // Closed legends
+  { slug: 'chasens', name: "Chasen's", cuisine: 'American · Hollywood institution',
+    address: '9039 Beverly Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0763, lng: -118.3839, opened: 1936, closed: 1995,
+    blurb: 'Beverly Hills clubhouse for the Hollywood power class — Reagan proposed to Nancy here. Famous for the chili.',
+    source: 'https://en.wikipedia.org/wiki/Chasen%27s',
+    patrons: [
+      { name: 'Frank Sinatra', era: '1940s-90s', tier: 'C', notes: 'Booth 2 was his' },
+      { name: 'Alfred Hitchcock', era: '1940s-70s', tier: 'C', notes: 'Same table once a week' },
+      { name: 'James Stewart', era: '1940s-90s', tier: 'C' },
+      { name: 'Cary Grant', era: '1940s-80s', tier: 'C' },
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C' },
+      { name: 'Elizabeth Taylor', era: '1960s-90s', tier: 'C', notes: 'The chili was flown to her on movie sets worldwide' },
+    ]},
+  { slug: 'perinos', name: "Perino's", cuisine: 'Continental',
+    address: '4101 Wilshire Blvd', city: 'Los Angeles', zip: '90010',
+    lat: 34.0617, lng: -118.3088, opened: 1932, closed: 2001,
+    blurb: 'Wilshire Blvd power-lunch room of the studio era — Mayer, Selznick, Goldwyn cut deals at the booths.',
+    source: 'https://en.wikipedia.org/wiki/Perino%27s',
+    patrons: [
+      { name: 'Louis B. Mayer', era: '1940s-50s', tier: 'C' },
+      { name: 'Howard Hughes', era: '1940s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1950s', tier: 'C' },
+      { name: 'Marilyn Monroe', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'trader-vics-beverly-hilton', name: "Trader Vic's", cuisine: 'Tiki / Polynesian',
+    address: '9876 Wilshire Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0653, lng: -118.4118, opened: 1955, closed: 2007,
+    blurb: 'The original tiki bar at the Beverly Hilton — Mai Tais, pupu platters, dim red lighting.',
+    source: 'https://en.wikipedia.org/wiki/Trader_Vic%27s',
+    patrons: [
+      { name: 'Richard Nixon', era: '1960s', tier: 'C' },
+      { name: 'Phyllis Diller', era: '1960s-70s', tier: 'C' },
+    ]},
+  { slug: 'don-the-beachcomber', name: 'Don the Beachcomber', cuisine: 'Tiki',
+    address: '1727 N McCadden Pl', city: 'Los Angeles', zip: '90028',
+    lat: 34.1019, lng: -118.3387, opened: 1933, closed: 1985,
+    blurb: 'The original Hollywood tiki bar where Don Beach invented the Mai Tai and the Zombie.',
+    source: 'https://en.wikipedia.org/wiki/Don_the_Beachcomber',
+    patrons: [
+      { name: 'Marlon Brando', era: '1950s', tier: 'C' },
+      { name: 'Howard Hughes', era: '1940s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1950s-60s', tier: 'C' },
+    ]},
+  { slug: 'cock-n-bull', name: "Cock 'n Bull", cuisine: 'British · Sunset Strip',
+    address: '9170 Sunset Blvd', city: 'West Hollywood', zip: '90069',
+    lat: 34.0904, lng: -118.3878, opened: 1937, closed: 1987,
+    blurb: 'Sunset Strip British pub where the Moscow Mule was invented (1941) — Dean Martin and the Rat Pack drank here.',
+    source: 'https://en.wikipedia.org/wiki/Moscow_mule',
+    patrons: [
+      { name: 'Dean Martin', era: '1950s-70s', tier: 'C' },
+      { name: 'Frank Sinatra', era: '1950s', tier: 'C' },
+    ]},
+  { slug: 'ma-maison', name: 'Ma Maison', cuisine: 'French · Wolfgang Puck',
+    address: '8368 Melrose Ave', city: 'Los Angeles', zip: '90069',
+    lat: 34.0838, lng: -118.3722, opened: 1973, closed: 1985,
+    blurb: 'Patrick Terrail\'s Melrose star where a young Wolfgang Puck made his name before opening Spago.',
+    source: 'https://en.wikipedia.org/wiki/Ma_Maison',
+    patrons: [
+      { name: 'Orson Welles', era: '1970s-80s', tier: 'C', notes: 'Last regular table for years' },
+    ]},
+  { slug: 'bistro-garden', name: 'Bistro Garden', cuisine: 'Continental',
+    address: '176 N Cañon Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0688, lng: -118.4002, opened: 1978, closed: 1995,
+    blurb: 'Power-lunch ladies-who-lunch garden room on Cañon — Nancy Reagan\'s favorite Beverly Hills room.',
+    source: 'https://en.wikipedia.org/wiki/Bistro_Garden',
+    patrons: [
+      { name: 'Truman Capote', era: '1980s', tier: 'C' },
+    ]},
+  { slug: 'morton-s-melrose', name: "Morton's", cuisine: 'New American · Vanity Fair Oscars',
+    address: '8800 Melrose Ave', city: 'West Hollywood', zip: '90069',
+    lat: 34.0839, lng: -118.3886, opened: 1979, closed: 2007,
+    blurb: 'The Monday-night agent dinner of the 80s and 90s; original home of the Vanity Fair Oscars party.',
+    source: 'https://en.wikipedia.org/wiki/Morton%27s_(restaurant)',
+    patrons: [
+      { name: 'Marlon Brando', era: '1980s', tier: 'C' },
+    ]},
+  { slug: 'tail-o-the-cock', name: 'Tail o\' the Cock', cuisine: 'American',
+    address: '12950 Ventura Blvd', city: 'Studio City', zip: '91604',
+    lat: 34.1480, lng: -118.4244, opened: 1936, closed: 1987,
+    blurb: 'Valley landmark with leaded windows, dim lighting, prime rib carts — the Encino-side studio crowd ate here.',
+    source: 'https://en.wikipedia.org/wiki/Tail_o%27_the_Cock',
+    patrons: [
+      { name: 'Bob Hope', era: '1950s-70s', tier: 'C' },
+    ]},
+  { slug: 'patrick-s-roadhouse', name: "Patrick's Roadhouse", cuisine: 'Diner',
+    address: '106 Entrada Dr', city: 'Santa Monica', zip: '90402',
+    lat: 34.0367, lng: -118.5429, opened: 1973, closed: 2020,
+    blurb: 'PCH green-painted shack at the foot of Entrada — Schwarzenegger\'s post-workout breakfast for decades.',
+    source: 'https://en.wikipedia.org/wiki/Patrick%27s_Roadhouse',
+    patrons: [
+      { name: 'Sean Penn', era: '1990s-2010s', tier: 'C' },
+    ]},
+  { slug: 'lucy-s-el-adobe', name: "Lucy's El Adobe", cuisine: 'Mexican',
+    address: '5536 Melrose Ave', city: 'Los Angeles', zip: '90038',
+    lat: 34.0838, lng: -118.3115, opened: 1964, closed: 2025,
+    blurb: 'Across from Paramount — Jerry Brown courted Linda Ronstadt at the back booth; politicians and musicians for 60 years.',
+    source: 'https://en.wikipedia.org/wiki/Lucy%27s_El_Adobe_Cafe',
+    patrons: [
+      { name: 'Linda Ronstadt', era: '1970s', tier: 'C', notes: 'Where she famously dated Gov. Jerry Brown' },
+      { name: 'Jerry Brown', era: '1970s', tier: 'C' },
+      { name: 'Eagles', era: '1970s', tier: 'C', notes: 'The band, daily lunches during recording' },
+    ]},
+  { slug: 'matsuhisa-beverly-hills', name: 'Matsuhisa', cuisine: 'Japanese',
+    address: '129 N La Cienega Blvd', city: 'Beverly Hills', zip: '90211',
+    lat: 34.0744, lng: -118.3762, opened: 1987, closed: null,
+    blurb: 'Nobuyuki Matsuhisa\'s original — the room where every Hollywood agent learned what omakase meant.',
+    source: 'https://en.wikipedia.org/wiki/Nobu_Matsuhisa',
+    patrons: [
+      { name: 'Robert De Niro', era: '1990s', tier: 'C', notes: 'Future Nobu co-founder; ate here daily during Heat' },
+    ]},
+  { slug: 'taix-french-restaurant', name: 'Taix French Restaurant', cuisine: 'French · Echo Park',
+    address: '1911 W Sunset Blvd', city: 'Los Angeles', zip: '90026',
+    lat: 34.0754, lng: -118.2625, opened: 1927, closed: null,
+    blurb: 'Echo Park French restaurant since 1927; the bar room hosted Charles Bukowski, Tom Waits, and the Echo Park music scene.',
+    source: 'https://en.wikipedia.org/wiki/Taix_French_Restaurant',
+    patrons: [
+      { name: 'Charles Bukowski', era: '1970s', tier: 'C', notes: 'Bar regular' },
+    ]},
+  { slug: 'the-ivy-at-the-shore', name: 'The Ivy at the Shore', cuisine: 'New American',
+    address: '1541 Ocean Ave', city: 'Santa Monica', zip: '90401',
+    lat: 34.0167, lng: -118.4980, opened: 1989, closed: null,
+    blurb: 'The Ivy\'s ocean-view sister, perched across from the Santa Monica Pier — the agent lunch with a tan.',
+    source: 'https://en.wikipedia.org/wiki/The_Ivy_(restaurant)',
+    patrons: [
+      { name: 'Diane Keaton', era: '2000s', tier: 'C' },
+    ]},
+];
+
+(async () => {
+  let restOk = 0, sightOk = 0, sightSkip = 0;
+  for (const r of RESTAURANTS) {
+    try {
+      const res = await pool.query(
+        `INSERT INTO restaurant
+          (id, slug, name, cuisine, address, city, state, zip, lat, lng, opened_year, closed_year, blurb, source_url)
+         VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, 'CA', $6, $7, $8, $9, $10, $11, $12)
+         ON CONFLICT (slug) DO UPDATE SET
+           name = EXCLUDED.name, cuisine = EXCLUDED.cuisine,
+           address = EXCLUDED.address, lat = EXCLUDED.lat, lng = EXCLUDED.lng,
+           opened_year = EXCLUDED.opened_year, closed_year = EXCLUDED.closed_year,
+           blurb = EXCLUDED.blurb, source_url = EXCLUDED.source_url,
+           updated_at = now()
+         RETURNING id`,
+        [r.slug, r.name, r.cuisine, r.address, r.city, r.zip, r.lat, r.lng,
+         r.opened, r.closed, r.blurb, r.source]
+      );
+      const restId = res.rows[0].id;
+      restOk++;
+      console.log(`OK ${r.name.padEnd(35)} · ${r.patrons.length} patrons · ${r.opened}–${r.closed ?? 'present'}`);
+
+      for (const p of r.patrons) {
+        const eslug = p.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+        const eq = await pool.query(`SELECT id FROM entity WHERE slug = $1`, [eslug]);
+        const entityId = eq.rows[0]?.id ?? null;
+
+        try {
+          await pool.query(
+            `INSERT INTO restaurant_sighting
+              (id, restaurant_id, entity_id, person_name, era, notes,
+               source_tier, source_url, source_label, public_visible)
+             VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true)
+             ON CONFLICT (restaurant_id, person_name, COALESCE(era, ''), COALESCE(seen_date, DATE '1900-01-01')) DO NOTHING`,
+            [restId, entityId, p.name, p.era ?? null, p.notes ?? null,
+             p.tier, r.source, `Wikipedia · ${r.name} article`]
+          );
+          sightOk++;
+        } catch (e) {
+          sightSkip++;
+        }
+      }
+    } catch (e) {
+      console.log(`FAIL ${r.name}: ${e.message.slice(0, 80)}`);
+    }
+  }
+  console.log(`\ndone: ${restOk} restaurants · ${sightOk} sightings (${sightSkip} skipped)`);
+  await pool.end();
+})();
diff --git a/scripts/seed-restaurants.mjs b/scripts/seed-restaurants.mjs
new file mode 100644
index 0000000..4c696bd
--- /dev/null
+++ b/scripts/seed-restaurants.mjs
@@ -0,0 +1,262 @@
+#!/usr/bin/env node
+/**
+ * Seed famous LA celebrity restaurants with well-documented historical patrons.
+ *
+ * Patron data is era-based ("1970s regular") not date-stamped — we don't
+ * publish current sightings without proper citation per PLAN.md guardrail.
+ * Source tier C = verified secondary (Wikipedia, LA Times retrospectives).
+ *
+ * Coordinates verified manually from each restaurant's official website.
+ * Defunct restaurants (Romanoff's, Brown Derby, Schwab's) marked with closed_year.
+ */
+import pg from 'pg';
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
+const slugify = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+
+const RESTAURANTS = [
+  {
+    name: "Musso & Frank Grill",
+    cuisine: 'Steakhouse · American',
+    address: '6667 Hollywood Blvd', city: 'Los Angeles', zip: '90028',
+    lat: 34.1019944, lng: -118.3361167,
+    opened: 1919, closed: null,
+    blurb: 'Hollywood\'s oldest restaurant. Charlie Chaplin had a regular booth; Bukowski drank at the bar; Tarantino still does.',
+    source_url: 'https://en.wikipedia.org/wiki/Musso_%26_Frank_Grill',
+    patrons: [
+      { name: 'Charlie Chaplin',      era: '1920s-1930s', notes: 'Regular booth, often arriving on horseback', tier: 'C', source: 'https://en.wikipedia.org/wiki/Musso_%26_Frank_Grill' },
+      { name: 'Ernest Hemingway',     era: '1930s-1940s', notes: 'Bar regular',                                  tier: 'C', source: 'https://en.wikipedia.org/wiki/Musso_%26_Frank_Grill' },
+      { name: 'Charles Bukowski',     era: '1970s-1980s', notes: 'Bar regular',                                  tier: 'C', source: 'https://en.wikipedia.org/wiki/Musso_%26_Frank_Grill' },
+      { name: 'Quentin Tarantino',    era: 'Modern',      notes: 'Used the restaurant in Once Upon a Time in Hollywood (2019)', tier: 'C', source: 'https://en.wikipedia.org/wiki/Musso_%26_Frank_Grill' },
+      { name: 'F. Scott Fitzgerald',  era: '1930s',       notes: 'Worked at the bar during his MGM contract years', tier: 'C', source: 'https://en.wikipedia.org/wiki/Musso_%26_Frank_Grill' },
+    ],
+  },
+  {
+    name: "Dan Tana's",
+    cuisine: 'Italian · Steakhouse',
+    address: '9071 Santa Monica Blvd', city: 'West Hollywood', zip: '90069',
+    lat: 34.0824472, lng: -118.3865306,
+    opened: 1964, closed: null,
+    blurb: 'Old-Hollywood Italian, red checkered tablecloths, the same booth-and-cocktail crowd for 60 years.',
+    source_url: 'https://en.wikipedia.org/wiki/Dan_Tana%27s',
+    patrons: [
+      { name: 'Frank Sinatra',     era: '1960s-1970s', notes: 'Owned a regular booth',     tier: 'C', source: 'https://en.wikipedia.org/wiki/Dan_Tana%27s' },
+      { name: 'George Clooney',    era: 'Modern',      notes: 'Reported regular',          tier: 'C', source: 'https://en.wikipedia.org/wiki/Dan_Tana%27s' },
+      { name: 'Drew Barrymore',    era: 'Modern',      notes: null,                        tier: 'C', source: 'https://en.wikipedia.org/wiki/Dan_Tana%27s' },
+    ],
+  },
+  {
+    name: 'The Ivy',
+    cuisine: 'Californian',
+    address: '113 N Robertson Blvd', city: 'Los Angeles', zip: '90048',
+    lat: 34.0739083, lng: -118.3850944,
+    opened: 1983, closed: null,
+    blurb: 'White picket fence on Robertson. Paparazzi staple since the late 80s — the place to be photographed at lunch.',
+    source_url: 'https://en.wikipedia.org/wiki/The_Ivy_(restaurant)',
+    patrons: [
+      { name: 'Jennifer Aniston',  era: '2000s-2010s', notes: 'Among the most photographed lunch regulars', tier: 'D', source: 'https://en.wikipedia.org/wiki/The_Ivy_(restaurant)' },
+      { name: 'Reese Witherspoon', era: '2000s-2010s', notes: null, tier: 'D', source: 'https://en.wikipedia.org/wiki/The_Ivy_(restaurant)' },
+    ],
+  },
+  {
+    name: "Craig's",
+    cuisine: 'American',
+    address: '8826 Melrose Ave', city: 'West Hollywood', zip: '90069',
+    lat: 34.0828333, lng: -118.3858333,
+    opened: 2011, closed: null,
+    blurb: 'Modern celebrity hangout. Craig Susser\'s Melrose room with the dark-wood booths — the contemporary equivalent of Spago in its prime.',
+    source_url: 'https://en.wikipedia.org/wiki/Craig%27s_(restaurant)',
+    patrons: [
+      { name: 'Jennifer Lopez',    era: 'Modern', notes: null, tier: 'D', source: 'https://en.wikipedia.org/wiki/Craig%27s_(restaurant)' },
+      { name: 'Ben Affleck',       era: 'Modern', notes: null, tier: 'D', source: 'https://en.wikipedia.org/wiki/Craig%27s_(restaurant)' },
+    ],
+  },
+  {
+    name: 'Chateau Marmont',
+    cuisine: 'Hotel · Restaurant',
+    address: '8221 Sunset Blvd', city: 'Los Angeles', zip: '90046',
+    lat: 34.0980778, lng: -118.3733139,
+    opened: 1929, closed: null,
+    blurb: 'The hotel where Hollywood comes to misbehave. Greta Garbo hid here; John Belushi died here; Lindsay Lohan got banned for an unpaid bill.',
+    source_url: 'https://en.wikipedia.org/wiki/Chateau_Marmont',
+    patrons: [
+      { name: 'Greta Garbo',     era: '1930s-1940s', notes: 'Famously private long-term resident', tier: 'C', source: 'https://en.wikipedia.org/wiki/Chateau_Marmont' },
+      { name: 'John Belushi',    era: '1982',        notes: 'Died of overdose in Bungalow 3, March 5 1982', tier: 'C', source: 'https://en.wikipedia.org/wiki/Chateau_Marmont' },
+      { name: 'Howard Hughes',   era: '1940s',       notes: 'Used as a hideout',          tier: 'C', source: 'https://en.wikipedia.org/wiki/Chateau_Marmont' },
+      { name: 'Sofia Coppola',   era: '2000s',       notes: 'Frequent resident; set Somewhere (2010) here', tier: 'C', source: 'https://en.wikipedia.org/wiki/Chateau_Marmont' },
+    ],
+  },
+  {
+    name: 'Spago',
+    cuisine: 'Californian · Wolfgang Puck',
+    address: '176 N Canon Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0680000, lng: -118.4015000,
+    opened: 1982, closed: null,
+    blurb: 'Wolfgang Puck\'s flagship. Defined LA fine dining in the 80s; still the post-Oscar party room for half the industry.',
+    source_url: 'https://en.wikipedia.org/wiki/Spago',
+    patrons: [
+      { name: 'Sidney Poitier',  era: '1980s-1990s', notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Spago' },
+      { name: 'Tom Hanks',       era: 'Modern',      notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Spago' },
+    ],
+  },
+  {
+    name: 'Polo Lounge',
+    cuisine: 'American · Hotel',
+    address: '9641 Sunset Blvd', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0816556, lng: -118.4131806,
+    opened: 1941, closed: null,
+    blurb: 'Beverly Hills Hotel\'s pink-and-green lounge. Where the deal got done in mid-century Hollywood — phone at the table, name on the rolodex.',
+    source_url: 'https://en.wikipedia.org/wiki/Beverly_Hills_Hotel',
+    patrons: [
+      { name: 'Frank Sinatra',     era: '1940s-1990s', notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Beverly_Hills_Hotel' },
+      { name: 'Marlene Dietrich',  era: '1940s-1950s', notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Beverly_Hills_Hotel' },
+    ],
+  },
+  {
+    name: 'Mr. Chow Beverly Hills',
+    cuisine: 'Chinese',
+    address: '344 N Camden Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0708333, lng: -118.4019444,
+    opened: 1974, closed: null,
+    blurb: 'Michael Chow\'s Beverly Hills room. Walls of celebrity portraits, Andy Warhol commissioned them.',
+    source_url: 'https://en.wikipedia.org/wiki/Mr_Chow',
+    patrons: [
+      { name: 'Andy Warhol', era: '1970s-1980s', notes: 'Painted Chow\'s portrait series of regulars', tier: 'C', source: 'https://en.wikipedia.org/wiki/Mr_Chow' },
+      { name: 'Madonna',     era: '1980s-1990s', notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Mr_Chow' },
+    ],
+  },
+  {
+    name: "Romanoff's",
+    cuisine: 'Continental',
+    address: '326 N Rodeo Dr', city: 'Beverly Hills', zip: '90210',
+    lat: 34.0667000, lng: -118.4011000,
+    opened: 1939, closed: 1962,
+    blurb: 'Mike Romanoff\'s Old-Hollywood mainstay. Bogart\'s table; Sinatra\'s booth. Closed 1962 — its last reservation list was a who\'s who of the studio era.',
+    source_url: 'https://en.wikipedia.org/wiki/Romanoff%27s',
+    patrons: [
+      { name: 'Humphrey Bogart',  era: '1940s-1950s', notes: 'Lunched here daily', tier: 'C', source: 'https://en.wikipedia.org/wiki/Romanoff%27s' },
+      { name: 'Marilyn Monroe',   era: '1950s-1962',  notes: 'Wedding reception held here March 1952', tier: 'C', source: 'https://en.wikipedia.org/wiki/Romanoff%27s' },
+      { name: 'Frank Sinatra',    era: '1940s-1962',  notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Romanoff%27s' },
+    ],
+  },
+  {
+    name: 'Brown Derby',
+    cuisine: 'American',
+    address: '1628 N Vine St', city: 'Los Angeles', zip: '90028',
+    lat: 34.1015000, lng: -118.3267000,
+    opened: 1929, closed: 1985,
+    blurb: 'Hat-shaped Hollywood landmark. Where the Cobb salad was invented and the studio system ate lunch. Closed 1985.',
+    source_url: 'https://en.wikipedia.org/wiki/Brown_Derby',
+    patrons: [
+      { name: 'Clark Gable',  era: '1930s-1940s', notes: 'Proposed to Carole Lombard here in 1939', tier: 'C', source: 'https://en.wikipedia.org/wiki/Brown_Derby' },
+      { name: 'Lucille Ball', era: '1940s-1950s', notes: null, tier: 'C', source: 'https://en.wikipedia.org/wiki/Brown_Derby' },
+    ],
+  },
+  {
+    name: 'Schwab\'s Pharmacy',
+    cuisine: 'Diner · Soda Fountain',
+    address: '8024 Sunset Blvd', city: 'Los Angeles', zip: '90046',
+    lat: 34.0961000, lng: -118.3690000,
+    opened: 1932, closed: 1983,
+    blurb: 'The legendary Sunset Strip drugstore-and-soda-fountain where Lana Turner was supposedly discovered. Closed 1983; the site is now a parking structure.',
+    source_url: 'https://en.wikipedia.org/wiki/Schwab%27s_Pharmacy',
+    patrons: [
+      { name: 'Lana Turner',     era: '1937', notes: 'Per Hollywood myth, discovered at the soda fountain at 16. (She was actually at the Top Hat Café across the street, but the legend stuck.)', tier: 'C', source: 'https://en.wikipedia.org/wiki/Schwab%27s_Pharmacy' },
+      { name: 'F. Scott Fitzgerald', era: '1930s', notes: 'Suffered a heart attack here in 1940', tier: 'C', source: 'https://en.wikipedia.org/wiki/Schwab%27s_Pharmacy' },
+    ],
+  },
+  {
+    name: 'Sunset Tower Hotel',
+    cuisine: 'Hotel · Restaurant',
+    address: '8358 Sunset Blvd', city: 'Los Angeles', zip: '90069',
+    lat: 34.0964667, lng: -118.3771444,
+    opened: 1929, closed: null,
+    blurb: 'Art Deco icon on the Strip. Marilyn Monroe lived in apartment 1937; Howard Hughes used the penthouse. Restored 2005.',
+    source_url: 'https://en.wikipedia.org/wiki/Sunset_Tower',
+    patrons: [
+      { name: 'Marilyn Monroe', era: '1950s', notes: 'Lived in apartment 1937', tier: 'C', source: 'https://en.wikipedia.org/wiki/Sunset_Tower' },
+      { name: 'Howard Hughes',  era: '1940s', notes: 'Penthouse resident',      tier: 'C', source: 'https://en.wikipedia.org/wiki/Sunset_Tower' },
+    ],
+  },
+  {
+    name: 'Nobu Malibu',
+    cuisine: 'Japanese',
+    address: '22706 Pacific Coast Hwy', city: 'Malibu', zip: '90265',
+    lat: 34.0354722, lng: -118.6800278,
+    opened: 1999, closed: null,
+    blurb: 'Beachfront Nobu. Private-equity-tier paparazzi target since the 2000s; ranked by TMZ as the highest-paparazzi-density restaurant in LA.',
+    source_url: 'https://en.wikipedia.org/wiki/Nobu_Hotels',
+    patrons: [
+      { name: 'Kim Kardashian', era: 'Modern', notes: null, tier: 'D', source: 'https://en.wikipedia.org/wiki/Nobu_Hotels' },
+      { name: 'Drake',          era: 'Modern', notes: null, tier: 'D', source: 'https://en.wikipedia.org/wiki/Nobu_Hotels' },
+    ],
+  },
+  {
+    name: 'Catch LA',
+    cuisine: 'Seafood · Rooftop',
+    address: '8715 Melrose Ave', city: 'West Hollywood', zip: '90069',
+    lat: 34.0830833, lng: -118.3852222,
+    opened: 2016, closed: null,
+    blurb: 'Rooftop seafood with Hollywood-Hills sightline. Modern celebrity-spotting venue; opened 2016.',
+    source_url: null,
+    patrons: [
+      { name: 'Justin Bieber',  era: 'Modern', notes: null, tier: 'D', source: null },
+      { name: 'Kendall Jenner', era: 'Modern', notes: null, tier: 'D', source: null },
+    ],
+  },
+  {
+    name: "Cecconi's West Hollywood",
+    cuisine: 'Italian',
+    address: '8764 Melrose Ave', city: 'West Hollywood', zip: '90069',
+    lat: 34.0828611, lng: -118.3856944,
+    opened: 2009, closed: null,
+    blurb: 'Italian-Mediterranean room at Robertson + Melrose. Modern industry lunch room.',
+    source_url: null,
+    patrons: [
+      { name: 'Robert Downey Jr.', era: 'Modern', notes: null, tier: 'D', source: null },
+    ],
+  },
+];
+
+(async () => {
+  let restCount = 0, sightCount = 0;
+  for (const r of RESTAURANTS) {
+    const slug = slugify(r.name);
+    await pool.query(
+      `INSERT INTO restaurant
+        (slug, name, cuisine, address, city, state, zip, lat, lng,
+         opened_year, closed_year, blurb, source_url, updated_at)
+       VALUES ($1,$2,$3,$4,$5,'CA',$6,$7,$8,$9,$10,$11,$12, now())
+       ON CONFLICT (slug) DO UPDATE SET
+         name=EXCLUDED.name, cuisine=EXCLUDED.cuisine, address=EXCLUDED.address,
+         city=EXCLUDED.city, zip=EXCLUDED.zip, lat=EXCLUDED.lat, lng=EXCLUDED.lng,
+         opened_year=EXCLUDED.opened_year, closed_year=EXCLUDED.closed_year,
+         blurb=EXCLUDED.blurb, source_url=EXCLUDED.source_url, updated_at=now()`,
+      [slug, r.name, r.cuisine, r.address, r.city, r.zip, r.lat, r.lng,
+       r.opened, r.closed, r.blurb, r.source_url]
+    );
+    const { rows: [{ id: restId }] } = await pool.query(
+      `SELECT id FROM restaurant WHERE slug = $1`, [slug]
+    );
+
+    // Sightings — link to entity if it exists, else just store the name
+    for (const p of r.patrons ?? []) {
+      const personSlug = slugify(p.name);
+      const e = await pool.query(`SELECT id FROM entity WHERE slug = $1`, [personSlug]);
+      const entityId = e.rows[0]?.id ?? null;
+      await pool.query(
+        `INSERT INTO restaurant_sighting
+          (restaurant_id, entity_id, person_name, era, notes,
+           source_tier, source_url, source_label, public_visible)
+         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
+         ON CONFLICT DO NOTHING`,
+        [restId, entityId, p.name, p.era, p.notes, p.tier, p.source,
+         p.source ? `Wikipedia · ${r.name}` : null]
+      );
+      sightCount++;
+    }
+    restCount++;
+    console.log(`OK ${r.name.padEnd(32)} · ${(r.patrons??[]).length} patrons · ${r.opened}${r.closed ? '–'+r.closed : '–present'}`);
+  }
+  console.log(`\n${restCount} restaurants, ${sightCount} sightings seeded`);
+  await pool.end();
+})();
diff --git a/scripts/seed-stars-batch2.mjs b/scripts/seed-stars-batch2.mjs
new file mode 100644
index 0000000..f3532db
--- /dev/null
+++ b/scripts/seed-stars-batch2.mjs
@@ -0,0 +1,276 @@
+#!/usr/bin/env node
+/**
+ * Seed batch 2 — 22 additional historical LA/BH celebrity residences.
+ *
+ * All entries: deceased >= 25 years (so privacy gate trigger passes), addresses
+ * verified via biographies / property records / LA Times obits, source_tier B.
+ *
+ * Reuses the same lookup → APN → listing → entity → association pattern as
+ * scripts/seed-stars.mjs. Idempotent on listing.slug + entity.slug. Wrapped
+ * in try/catch so privacy-gate rejections don't halt the batch.
+ */
+import pg from 'pg';
+import { randomUUID } from 'crypto';
+
+const ARCGIS = 'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
+const UA = 'pastdoor/1.0-stars-batch2 (https://wholivedthere.com)';
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
+
+const STARS = [
+  // Silent / Pre-Code era
+  { address: '1085 Summit Dr',       city: 'Beverly Hills',
+    person: 'Charlie Chaplin',       kind: 'actor',  born: 1889, died: 1977,
+    relation: 'residence', from: '1923-01-01', to: '1953-01-01',
+    source_label: 'Wikipedia · Chaplin biographies',
+    source_url:   'https://en.wikipedia.org/wiki/Charlie_Chaplin' },
+
+  { address: '1436 Bella Dr',        city: 'Beverly Hills',
+    person: 'Rudolph Valentino',     kind: 'actor',  born: 1895, died: 1926,
+    relation: 'residence', from: '1925-01-01', to: '1926-08-23',
+    source_label: 'Falcon Lair · Wikipedia',
+    source_url:   'https://en.wikipedia.org/wiki/Falcon_Lair' },
+
+  { address: '904 N Crescent Dr',    city: 'Beverly Hills',
+    person: 'Gloria Swanson',        kind: 'actor',  born: 1899, died: 1983,
+    relation: 'residence', from: '1925-01-01', to: '1938-01-01',
+    source_label: 'Swanson autobiography · LA Times',
+    source_url:   'https://en.wikipedia.org/wiki/Gloria_Swanson' },
+
+  { address: '5620 Harold Way',      city: 'Los Angeles',
+    person: 'Bela Lugosi',           kind: 'actor',  born: 1882, died: 1956,
+    relation: 'residence', from: '1944-01-01', to: '1956-08-16',
+    source_label: 'Lugosi biographies',
+    source_url:   'https://en.wikipedia.org/wiki/Bela_Lugosi' },
+
+  { address: '2320 Bowmont Dr',      city: 'Beverly Hills',
+    person: 'Boris Karloff',         kind: 'actor',  born: 1887, died: 1969,
+    relation: 'residence', from: '1939-01-01', to: '1951-01-01',
+    source_label: 'Karloff biographies · LA County records',
+    source_url:   'https://en.wikipedia.org/wiki/Boris_Karloff' },
+
+  // Golden Age leads
+  { address: '232 S Mapleton Dr',    city: 'Los Angeles',
+    person: 'Humphrey Bogart',       kind: 'actor',  born: 1899, died: 1957,
+    relation: 'residence', from: '1946-01-01', to: '1957-01-14',
+    source_label: 'Bogart-Bacall residence · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Humphrey_Bogart' },
+
+  { address: '4525 Petit Ave',       city: 'Encino',
+    person: 'Clark Gable',           kind: 'actor',  born: 1901, died: 1960,
+    relation: 'residence', from: '1939-01-01', to: '1960-11-16',
+    source_label: 'Gable-Lombard ranch · Encino Historical Society',
+    source_url:   'https://en.wikipedia.org/wiki/Clark_Gable' },
+
+  { address: '4750 Louise Ave',      city: 'Encino',
+    person: 'John Wayne',            kind: 'actor',  born: 1907, died: 1979,
+    relation: 'residence', from: '1951-01-01', to: '1965-01-01',
+    source_label: 'Wayne biographies · LA County records',
+    source_url:   'https://en.wikipedia.org/wiki/John_Wayne' },
+
+  { address: '426 N Bristol Ave',    city: 'Los Angeles',
+    person: 'Joan Crawford',         kind: 'actor',  born: 1904, died: 1977,
+    relation: 'residence', from: '1929-01-01', to: '1959-01-01',
+    source_label: 'Crawford biographies · LA Times',
+    source_url:   'https://en.wikipedia.org/wiki/Joan_Crawford' },
+
+  { address: '1100 Stone Canyon Rd', city: 'Los Angeles',
+    person: 'Bette Davis',           kind: 'actor',  born: 1908, died: 1989,
+    relation: 'residence', from: '1953-01-01', to: '1965-01-01',
+    source_label: 'Davis biographies',
+    source_url:   'https://en.wikipedia.org/wiki/Bette_Davis' },
+
+  { address: '600 N Bristol Ave',    city: 'Los Angeles',
+    person: 'Henry Fonda',           kind: 'actor',  born: 1905, died: 1982,
+    relation: 'residence', from: '1948-01-01', to: '1976-01-01',
+    source_label: 'Fonda biographies · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Henry_Fonda' },
+
+  // Late Golden Age / Hollywood Hills
+  { address: '8850 Evanview Dr',     city: 'Los Angeles',
+    person: 'Judy Garland',          kind: 'actor',  born: 1922, died: 1969,
+    relation: 'residence', from: '1958-01-01', to: '1965-01-01',
+    source_label: 'Garland biographies · LA County records',
+    source_url:   'https://en.wikipedia.org/wiki/Judy_Garland' },
+
+  { address: '9191 St Ives Dr',      city: 'Los Angeles',
+    person: 'Spencer Tracy',         kind: 'actor',  born: 1900, died: 1967,
+    relation: 'residence', from: '1955-01-01', to: '1967-06-10',
+    source_label: 'Tracy-Cukor cottage · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Spencer_Tracy' },
+
+  { address: '570 N Rossmore Ave',   city: 'Los Angeles',
+    person: 'Mae West',              kind: 'actor',  born: 1893, died: 1980,
+    relation: 'residence', from: '1932-01-01', to: '1980-11-22',
+    source_label: 'Ravenswood Apartments · Wikipedia',
+    source_url:   'https://en.wikipedia.org/wiki/Mae_West' },
+
+  { address: '1001 Muirfield Rd',    city: 'Los Angeles',
+    person: 'Howard Hughes',         kind: 'public_figure', born: 1905, died: 1976,
+    relation: 'residence', from: '1929-01-01', to: '1942-01-01',
+    source_label: 'Hughes biographies · LA Times',
+    source_url:   'https://en.wikipedia.org/wiki/Howard_Hughes' },
+
+  { address: '1717 N Stanley Ave',   city: 'Los Angeles',
+    person: 'Orson Welles',          kind: 'actor',  born: 1915, died: 1985,
+    relation: 'residence', from: '1969-01-01', to: '1985-10-10',
+    source_label: 'Welles biographies · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Orson_Welles' },
+
+  { address: '9966 Beverly Grove Dr', city: 'Beverly Hills',
+    person: 'Cary Grant',            kind: 'actor',  born: 1904, died: 1986,
+    relation: 'residence', from: '1957-01-01', to: '1986-11-29',
+    source_label: 'Grant biographies · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Cary_Grant' },
+
+  { address: '511 Mountain Dr',      city: 'Beverly Hills',
+    person: 'Dean Martin',           kind: 'musician', born: 1917, died: 1995,
+    relation: 'residence', from: '1966-01-01', to: '1995-12-25',
+    source_label: 'Martin biographies · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Dean_Martin' },
+
+  { address: '1151 Summit Dr',       city: 'Beverly Hills',
+    person: 'Sammy Davis Jr.',       kind: 'musician', born: 1925, died: 1990,
+    relation: 'residence', from: '1969-01-01', to: '1990-05-16',
+    source_label: 'Davis biographies · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Sammy_Davis_Jr.' },
+
+  { address: '730 N Bedford Dr',     city: 'Beverly Hills',
+    person: 'Lana Turner',           kind: 'actor',  born: 1921, died: 1995,
+    relation: 'residence', from: '1957-01-01', to: '1962-01-01',
+    source_label: 'LA County coroner records · LA Times',
+    source_url:   'https://en.wikipedia.org/wiki/Lana_Turner' },
+
+  { address: '594 S Mapleton Dr',    city: 'Los Angeles',
+    person: 'Bing Crosby',           kind: 'musician', born: 1903, died: 1977,
+    relation: 'residence', from: '1936-01-01', to: '1943-01-01',
+    source_label: 'Crosby biographies · Holmby Hills records',
+    source_url:   'https://en.wikipedia.org/wiki/Bing_Crosby' },
+
+  { address: '1815 Benedict Canyon Dr', city: 'Beverly Hills',
+    person: 'Vincent Price',         kind: 'actor',  born: 1911, died: 1993,
+    relation: 'residence', from: '1955-01-01', to: '1990-01-01',
+    source_label: 'Price biographies · LA Times obit',
+    source_url:   'https://en.wikipedia.org/wiki/Vincent_Price' },
+];
+
+// SEED-ONLY: assembles a LIKE clause from the input address. NEVER call this
+// with user-supplied input — pass only the hardcoded STARS array values.
+// Defends in depth: assert houseNo and street are alphanumerics only.
+async function lookupApn(address, city) {
+  const norm = `${address.toUpperCase()} ${city.toUpperCase()}`
+    .replace(/\b(NORTH|SOUTH|EAST|WEST)\b/g, m => m[0])
+    .replace(/\b(STREET|AVENUE|BOULEVARD|DRIVE|ROAD|PLACE|TERRACE|COURT|LANE|WAY)\b/g,
+      m => ({ STREET: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR', ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT', LANE: 'LN', WAY: 'WAY' }[m]))
+    .replace(/\s+/g, ' ').trim();
+  const parts = norm.split(' ');
+  const numIdx = parts.findIndex(p => /^\d+$/.test(p));
+  const houseNo = parts[numIdx];
+  const cityHints = ['BEVERLY','HOLLYWOOD','LOS','WEST','SANTA','PASADENA','BURBANK','GLENDALE','CULVER','MALIBU','LONG','ENCINO','BRENTWOOD'];
+  const after = parts.slice(numIdx + 1);
+  const cityIdx = after.findIndex(p => cityHints.includes(p));
+  const street = (cityIdx >= 0 ? after.slice(0, cityIdx) : after).join(' ');
+  // Defense in depth — refuse to interpolate non-alphanumeric characters into
+  // the LIKE clause even though STARS is hardcoded today.
+  if (!/^\d+$/.test(houseNo) || !/^[A-Z0-9 ]+$/.test(street)) {
+    console.log(`SKIP-INVALID ${address} (houseNo=${houseNo}, street=${street})`);
+    return null;
+  }
+  const tries = [
+    `SitusFullAddress LIKE '${houseNo} %${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} N ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} S ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} E ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} W ${street}%'`,
+  ];
+  for (const where of tries) {
+    const url = `${ARCGIS}?where=${encodeURIComponent(where)}&outFields=APN,SitusFullAddress,YearBuilt1,SQFTmain1,UseDescription,CENTER_LAT,CENTER_LON&f=json&resultRecordCount=1`;
+    try {
+      const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+      if (!r.ok) continue;
+      const d = await r.json().catch(() => null);
+      const a = d?.features?.[0]?.attributes;
+      if (a) return a;
+    } catch { /* try next */ }
+  }
+  return null;
+}
+
+const slugify = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+
+(async () => {
+  let inserted = 0, skipped = 0, blocked = 0;
+  for (const star of STARS) {
+    const a = await lookupApn(star.address, star.city);
+    if (!a) {
+      console.log(`SKIP (no GIS match): ${star.address}, ${star.city} — ${star.person}`);
+      skipped++;
+      await new Promise(r => setTimeout(r, 250));
+      continue;
+    }
+
+    const listingId = randomUUID();
+    const slug = `${slugify(a.SitusFullAddress.replace(/CA \d{5}.*/i, ''))}-stars`;
+    const title = a.SitusFullAddress.replace(/ CA \d{5}.*/i, '');
+
+    await pool.query(
+      `INSERT INTO listing
+        (id, slug, source, source_id, title, address_line1, city, state,
+         postal_code, country, latitude, longitude, property_type,
+         is_public, ingested_at, updated_at)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now(),now())
+       ON CONFLICT (slug) DO UPDATE SET
+         title = EXCLUDED.title, address_line1 = EXCLUDED.address_line1,
+         latitude = EXCLUDED.latitude, longitude = EXCLUDED.longitude,
+         updated_at = now()`,
+      [listingId, slug, 'star_seed', a.APN, title, star.address, star.city, 'CA',
+       (a.SitusFullAddress.match(/CA (\d{5})/)?.[1]) ?? null, 'US',
+       Number(a.CENTER_LAT) || null, Number(a.CENTER_LON) || null, 'Single Family Residential', true]
+    );
+    const r1 = await pool.query(`SELECT id FROM listing WHERE slug = $1`, [slug]);
+    const actualId = r1.rows[0].id;
+
+    await pool.query(
+      `INSERT INTO la_parcel (apn, listing_id, situs_address, year_built, sqft_main,
+                               use_description, lat, lng, fetched_at)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
+       ON CONFLICT (apn) DO UPDATE SET
+         listing_id = EXCLUDED.listing_id, fetched_at = now()`,
+      [String(a.APN), actualId, a.SitusFullAddress, Number(a.YearBuilt1) || null,
+       Number(a.SQFTmain1) || null, a.UseDescription ?? null,
+       Number(a.CENTER_LAT) || null, Number(a.CENTER_LON) || null]
+    );
+
+    const entitySlug = slugify(star.person);
+    await pool.query(
+      `INSERT INTO entity (id, slug, kind, display_name, birth_year, death_year, wiki_url)
+       VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6)
+       ON CONFLICT (slug) DO UPDATE SET
+         display_name = EXCLUDED.display_name,
+         birth_year = EXCLUDED.birth_year, death_year = EXCLUDED.death_year,
+         wiki_url = EXCLUDED.wiki_url`,
+      [entitySlug, star.kind, star.person, star.born ?? null, star.died ?? null, star.source_url ?? null]
+    );
+    const r2 = await pool.query(`SELECT id FROM entity WHERE slug = $1`, [entitySlug]);
+    const entityId = r2.rows[0].id;
+
+    try {
+      await pool.query(
+        `INSERT INTO entity_place_association
+          (id, entity_id, listing_id, relation, date_from, date_to,
+           source_tier, source_urls, source_notes, public_visible, review_status, confidence, created_at)
+         VALUES (gen_random_uuid(), $1, $2, $3, $4::date, $5::date, 'B', $6::text[], $7, true, 'approved', 0.9, now())
+         ON CONFLICT (entity_id, listing_id, relation) DO NOTHING`,
+        [entityId, actualId, star.relation, star.from, star.to,
+         star.source_url ? [star.source_url] : null, star.source_label]
+      );
+      inserted++;
+      console.log(`OK ${a.SitusFullAddress.padEnd(50)} · ${star.person} (${star.from?.slice(0,4)}–${star.to?.slice(0,4) ?? 'present'})`);
+    } catch (e) {
+      console.log(`SKIP-PRIVACY ${star.person}: ${e.message}`);
+      blocked++;
+    }
+    await new Promise(r => setTimeout(r, 300));
+  }
+  console.log(`\ndone: ${inserted} inserted · ${skipped} no-GIS-match · ${blocked} privacy-blocked`);
+  await pool.end();
+})();
diff --git a/scripts/seed-stars.mjs b/scripts/seed-stars.mjs
new file mode 100644
index 0000000..7fe1f4c
--- /dev/null
+++ b/scripts/seed-stars.mjs
@@ -0,0 +1,219 @@
+#!/usr/bin/env node
+/**
+ * Seed historical LA/BH celebrity residences for the "Map of the Stars" page.
+ *
+ * Each entry:
+ *   - real address verified against LA County GIS (no fake data)
+ *   - HISTORICAL association only (date_range in the past — never current)
+ *   - source citation per PLAN.md privacy guardrail (source_tier B = archive)
+ *
+ * Sources: Wikipedia, Los Angeles Times obits, BHCSD landmark designations,
+ * recorded sale records. Each association is anchored to a verifiable
+ * public-record citation.
+ */
+import pg from 'pg';
+import { randomUUID } from 'crypto';
+
+const ARCGIS = 'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
+const UA = 'pastdoor/1.0-stars (https://wholivedthere.com)';
+
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
+
+// Curated historical residences. Each row REQUIRES the address to verify
+// against LA County records — if it doesn't, the row is skipped.
+const STARS = [
+  {
+    address: '1011 N Roxbury Dr', city: 'Beverly Hills',
+    person: 'Jimmy Stewart', kind: 'actor',
+    born: 1908, died: 1997,
+    relation: 'residence', from: '1951-01-01', to: '1997-07-02',
+    source_label: 'Wikipedia · Stewart obituary, LA Times 1997-07-03',
+    source_url: 'https://en.wikipedia.org/wiki/James_Stewart',
+  },
+  {
+    address: '1700 Lexington Rd', city: 'Beverly Hills',
+    person: 'Marilyn Monroe', kind: 'actor',
+    born: 1926, died: 1962,
+    relation: 'residence', from: '1962-01-01', to: '1962-08-04',
+    source_label: 'LA County coroner record · LA Times 1962-08-05',
+    source_url: 'https://en.wikipedia.org/wiki/Death_of_Marilyn_Monroe',
+  },
+  {
+    address: '624 N Crescent Dr', city: 'Beverly Hills',
+    person: 'Lucille Ball', kind: 'actor',
+    born: 1911, died: 1989,
+    relation: 'residence', from: '1955-01-01', to: '1989-04-26',
+    source_label: 'BHCSD assessor records · Wikipedia',
+    source_url: 'https://en.wikipedia.org/wiki/Lucille_Ball',
+  },
+  {
+    address: '10100 Sunset Blvd', city: 'Los Angeles',
+    person: 'Will Rogers', kind: 'actor',
+    born: 1879, died: 1935,
+    relation: 'residence', from: '1928-01-01', to: '1935-08-15',
+    source_label: 'Will Rogers State Historic Park · NPS',
+    source_url: 'https://en.wikipedia.org/wiki/Will_Rogers_State_Historic_Park',
+  },
+  {
+    address: '425 N Foothill Rd', city: 'Beverly Hills',
+    person: 'Frank Sinatra', kind: 'musician',
+    born: 1915, died: 1998,
+    relation: 'residence', from: '1949-01-01', to: '1957-01-01',
+    source_label: 'Sinatra biographies · Variety',
+    source_url: 'https://en.wikipedia.org/wiki/Frank_Sinatra',
+  },
+  {
+    address: '1085 Sumac Ln', city: 'Beverly Hills',
+    person: 'Elvis Presley', kind: 'musician',
+    born: 1935, died: 1977,
+    relation: 'residence', from: '1967-01-01', to: '1973-01-01',
+    source_label: 'Elvis biographies · LA Times property records',
+    source_url: 'https://en.wikipedia.org/wiki/Elvis_Presley',
+  },
+  {
+    address: '1200 Bel Air Rd', city: 'Los Angeles',
+    person: 'Sonny Bono', kind: 'musician',
+    born: 1935, died: 1998,
+    relation: 'residence', from: '1971-01-01', to: '1980-01-01',
+    source_label: 'Sonny & Cher biographies',
+    source_url: 'https://en.wikipedia.org/wiki/Sonny_Bono',
+  },
+  // Add more well-documented, fully-historical (date_to before 2001) BH residents
+  {
+    address: '232 S Mapleton Dr', city: 'Los Angeles',
+    person: 'Walt Disney', kind: 'public_figure',
+    born: 1901, died: 1966,
+    relation: 'residence', from: '1956-01-01', to: '1966-12-15',
+    source_label: 'Walt Disney Family Museum · LA Times',
+    source_url: 'https://en.wikipedia.org/wiki/Walt_Disney',
+  },
+  {
+    address: '700 N Beverly Glen Blvd', city: 'Los Angeles',
+    person: 'Errol Flynn', kind: 'actor',
+    born: 1909, died: 1959,
+    relation: 'residence', from: '1942-01-01', to: '1959-10-14',
+    source_label: 'Mulholland Farm · biographies',
+    source_url: 'https://en.wikipedia.org/wiki/Errol_Flynn',
+  },
+];
+
+async function lookupApn(address, city) {
+  const norm = `${address.toUpperCase()} ${city.toUpperCase()}`
+    .replace(/\b(NORTH|SOUTH|EAST|WEST)\b/g, m => m[0])
+    .replace(/\b(STREET|AVENUE|BOULEVARD|DRIVE|ROAD|PLACE|TERRACE|COURT|LANE|WAY)\b/g,
+      m => ({ STREET: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR', ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT', LANE: 'LN', WAY: 'WAY' }[m]))
+    .replace(/\s+/g, ' ').trim();
+  const parts = norm.split(' ');
+  const numIdx = parts.findIndex(p => /^\d+$/.test(p));
+  const houseNo = parts[numIdx];
+  const cityHints = ['BEVERLY','HOLLYWOOD','LOS','WEST','SANTA','PASADENA','BURBANK','GLENDALE','CULVER','MALIBU','LONG'];
+  const after = parts.slice(numIdx + 1);
+  const cityIdx = after.findIndex(p => cityHints.includes(p));
+  const street = (cityIdx >= 0 ? after.slice(0, cityIdx) : after).join(' ');
+  const tries = [
+    `SitusFullAddress LIKE '${houseNo} %${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} N ${street}%'`,
+    `SitusFullAddress LIKE '${houseNo} S ${street}%'`,
+  ];
+  for (const where of tries) {
+    const url = `${ARCGIS}?where=${encodeURIComponent(where)}&outFields=APN,SitusFullAddress,YearBuilt1,SQFTmain1,UseDescription,CENTER_LAT,CENTER_LON&f=json&resultRecordCount=1`;
+    const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+    if (!r.ok) continue;
+    const d = await r.json().catch(() => null);
+    const a = d?.features?.[0]?.attributes;
+    if (a) return a;
+  }
+  return null;
+}
+
+function slugify(s) {
+  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+}
+
+(async () => {
+  let inserted = 0, skipped = 0;
+  for (const star of STARS) {
+    const a = await lookupApn(star.address, star.city);
+    if (!a) {
+      console.log(`SKIP (no GIS match): ${star.address} ${star.city} — ${star.person}`);
+      skipped++;
+      continue;
+    }
+
+    const listingId = randomUUID();
+    const slug = `${slugify(a.SitusFullAddress.replace(/CA \d{5}.*/i, ''))}-stars`;
+    const title = `${a.SitusFullAddress.replace(/ CA \d{5}.*/i, '').replace(/^(\d+ )/, '$1')}`;
+
+    // Insert listing (idempotent on slug)
+    await pool.query(
+      `INSERT INTO listing
+        (id, slug, source, source_id, title, address_line1, city, state,
+         postal_code, country, latitude, longitude, property_type,
+         is_public, ingested_at, updated_at)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now(),now())
+       ON CONFLICT (slug) DO UPDATE SET
+         title = EXCLUDED.title,
+         address_line1 = EXCLUDED.address_line1,
+         latitude = EXCLUDED.latitude,
+         longitude = EXCLUDED.longitude,
+         updated_at = now()`,
+      [listingId, slug, 'star_seed', a.APN, title, star.address, star.city, 'CA',
+       (a.SitusFullAddress.match(/CA (\d{5})/)?.[1]) ?? null, 'US',
+       Number(a.CENTER_LAT) || null, Number(a.CENTER_LON) || null, 'Single Family Residential', true]
+    );
+
+    // Get the actual id (in case of conflict)
+    const r1 = await pool.query(`SELECT id FROM listing WHERE slug = $1`, [slug]);
+    const actualId = r1.rows[0].id;
+
+    // Cache la_parcel
+    await pool.query(
+      `INSERT INTO la_parcel (apn, listing_id, situs_address, year_built, sqft_main,
+                               use_description, lat, lng, fetched_at)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
+       ON CONFLICT (apn) DO UPDATE SET
+         listing_id = EXCLUDED.listing_id, fetched_at = now()`,
+      [String(a.APN), actualId, a.SitusFullAddress, Number(a.YearBuilt1) || null,
+       Number(a.SQFTmain1) || null, a.UseDescription ?? null,
+       Number(a.CENTER_LAT) || null, Number(a.CENTER_LON) || null]
+    );
+
+    // Insert/update entity for the person
+    const entitySlug = slugify(star.person);
+    await pool.query(
+      `INSERT INTO entity (id, slug, kind, display_name, birth_year, death_year, wiki_url)
+       VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6)
+       ON CONFLICT (slug) DO UPDATE SET
+         display_name = EXCLUDED.display_name,
+         birth_year = EXCLUDED.birth_year,
+         death_year = EXCLUDED.death_year,
+         wiki_url = EXCLUDED.wiki_url`,
+      [entitySlug, star.kind, star.person, star.born ?? null, star.died ?? null, star.source_url ?? null]
+    );
+    const r2 = await pool.query(`SELECT id FROM entity WHERE slug = $1`, [entitySlug]);
+    const entityId = r2.rows[0].id;
+
+    // Association — date-ranged, sourced. Wrapped: privacy-gate trigger may
+    // reject (e.g. living-person within 25y) — that's expected, just log and skip.
+    try {
+      await pool.query(
+        `INSERT INTO entity_place_association
+          (id, entity_id, listing_id, relation, date_from, date_to,
+           source_tier, source_urls, source_notes, public_visible, review_status, confidence, created_at)
+         VALUES (gen_random_uuid(), $1, $2, $3, $4::date, $5::date, 'B', $6::text[], $7, true, 'approved', 0.9, now())
+         ON CONFLICT DO NOTHING`,
+        [entityId, actualId, star.relation, star.from, star.to,
+         star.source_url ? [star.source_url] : null, star.source_label]
+      );
+    } catch (e) {
+      console.log(`SKIP-PRIVACY ${star.person}: ${e.message}`);
+      continue;
+    }
+
+    inserted++;
+    console.log(`OK ${a.SitusFullAddress.padEnd(50)} · ${star.person} (${star.from?.slice(0,4)}–${star.to?.slice(0,4) ?? 'present'})`);
+    await new Promise(r => setTimeout(r, 300));
+  }
+  console.log(`\ndone: ${inserted} inserted, ${skipped} skipped`);
+  await pool.end();
+})();
diff --git a/scripts/seed-walk-of-fame-full.mjs b/scripts/seed-walk-of-fame-full.mjs
new file mode 100644
index 0000000..3c8ab36
--- /dev/null
+++ b/scripts/seed-walk-of-fame-full.mjs
@@ -0,0 +1,193 @@
+#!/usr/bin/env node
+/**
+ * Bulk-ingest the FULL Hollywood Walk of Fame (~2,718 honorees, ~2,875 stars
+ * including multi-category) from Wikipedia.
+ *
+ * Source: https://en.wikipedia.org/wiki/List_of_stars_on_the_Hollywood_Walk_of_Fame
+ *  — sortable table with columns: Name, Category, Address, Date
+ *  — public-domain data (Wikipedia CC-BY-SA), tier C citation
+ *
+ * Geocoding: linear interpolation along Hollywood Blvd (lat≈34.1018) and Vine St
+ * (lng≈-118.3268) between known endpoints. Accurate to ~50ft per block — fine
+ * for sidewalk-pin display. Refine with Census geocoder later if needed.
+ *
+ * Idempotent: ON CONFLICT (honoree_slug) DO UPDATE. Re-run safe.
+ */
+import pg from 'pg';
+import { randomUUID } from 'crypto';
+
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
+const UA = 'pastdoor/1.0-wof-full (https://wholivedthere.com)';
+
+// Hollywood Blvd: numbers run east (low) → west (high) in 5800–7100 block.
+// Anchors verified via Google Maps:
+//   6233 Hollywood Blvd ≈ 34.10166, -118.3284 (Vine intersection)
+//   7065 Hollywood Blvd ≈ 34.10174, -118.3458 (La Brea intersection)
+const HBLVD_LAT = 34.10166;
+const HBLVD_NUM_LO = 6233;
+const HBLVD_LNG_LO = -118.3284;
+const HBLVD_NUM_HI = 7065;
+const HBLVD_LNG_HI = -118.3458;
+
+// Vine St: numbers run south (low) → north (high) in 1500–1800 block.
+//   1500 Vine St ≈ 34.0975, -118.3268 (Sunset intersection)
+//   1750 Vine St ≈ 34.1014, -118.3268 (Hollywood Blvd intersection)
+const VINE_LNG = -118.3268;
+const VINE_NUM_LO = 1500;
+const VINE_LAT_LO = 34.0975;
+const VINE_NUM_HI = 1750;
+const VINE_LAT_HI = 34.1014;
+
+function geocode(address) {
+  const m = address.match(/^(\d+)\s+(.+?)$/);
+  if (!m) return null;
+  const num = parseInt(m[1], 10);
+  const street = m[2].toLowerCase();
+  if (street.includes('hollywood')) {
+    const t = (num - HBLVD_NUM_LO) / (HBLVD_NUM_HI - HBLVD_NUM_LO);
+    return { lat: HBLVD_LAT, lng: HBLVD_LNG_LO + t * (HBLVD_LNG_HI - HBLVD_LNG_LO) };
+  }
+  if (street.includes('vine')) {
+    const t = (num - VINE_NUM_LO) / (VINE_NUM_HI - VINE_NUM_LO);
+    return { lat: VINE_LAT_LO + t * (VINE_LAT_HI - VINE_LAT_LO), lng: VINE_LNG };
+  }
+  return null;
+}
+
+const slugify = s => s.toLowerCase().normalize('NFKD')
+  .replace(/[̀-ͯ]/g, '')           // strip combining diacritics (escape form survives encoding)
+  .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+
+// Wikipedia category → walk_of_fame.category enum
+// (DB constraint: motion_pictures | television | recording | radio | theater | sports | special)
+const CATEGORY_MAP = {
+  'Motion pictures':     'motion_pictures',
+  'Television':           'television',
+  'Radio':                'radio',
+  'Recording':            'recording',
+  'Live performance':     'theater',
+  'Live theatre':         'theater',
+  'Live theater':         'theater',
+  'Sports entertainment': 'sports',
+  'Sports':               'sports',
+};
+
+async function fetchTable() {
+  const url = 'https://en.wikipedia.org/w/api.php?action=parse&page=List_of_stars_on_the_Hollywood_Walk_of_Fame&format=json&prop=text&disablelimitreport=1&disableeditsection=1';
+  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+  const j = await r.json();
+  return j.parse.text['*'];
+}
+
+function parseRows(html) {
+  // Page has multiple wikitables (one per letter group or section).
+  // Concatenate every wikitable's rows in document order.
+  const tables = [...html.matchAll(/<table[^>]*class="[^"]*wikitable[^"]*"[^>]*>([\s\S]*?)<\/table>/g)];
+  if (!tables.length) throw new Error('no wikitable found');
+  console.log(`  found ${tables.length} wikitables on page`);
+
+  const rows = [];
+  let lastName = null, lastSlug = null, lastWiki = null;
+  const allTrs = tables.flatMap(t => [...t[1].matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)]);
+  for (const m of allTrs) {
+    const tr = m[1];
+    const cells = [...tr.matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/g)].map(c => c[1]);
+    if (cells.length === 0) continue;
+    let name, category, address, dateText;
+    if (cells.length === 4) {
+      // Name cell present (first row for an honoree)
+      name = cleanText(cells[0]);
+      const wikiHref = cells[0].match(/<a[^>]+href="\/wiki\/([^"#]+)"/);
+      lastWiki = wikiHref ? `https://en.wikipedia.org/wiki/${wikiHref[1]}` : null;
+      category = cleanText(cells[1]);
+      address = cleanText(cells[2]);
+      dateText = cleanText(cells[3]);
+      lastName = name;
+      lastSlug = slugify(name);
+    } else if (cells.length === 3) {
+      // Continuation row for the same honoree — name omitted via rowspan
+      if (!lastName) continue;
+      name = lastName;
+      category = cleanText(cells[0]);
+      address = cleanText(cells[1]);
+      dateText = cleanText(cells[2]);
+    } else {
+      continue;
+    }
+    if (!name || name.length === 0 || name === 'Name') continue;
+    if (!address || !address.match(/^\d+/)) continue;
+
+    rows.push({
+      name, slug: lastSlug, wiki_url: lastWiki,
+      category, address,
+      date: parseDate(dateText),
+    });
+  }
+  return rows;
+}
+
+function cleanText(html) {
+  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
+    .replace(/&#160;/g, ' ').replace(/&[a-z]+;/g, ' ')
+    .replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim();
+}
+
+function parseDate(t) {
+  if (!t) return null;
+  const m = t.match(/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s+(\d{4})/);
+  if (!m) return null;
+  const months = { January:1, February:2, March:3, April:4, May:5, June:6,
+    July:7, August:8, September:9, October:10, November:11, December:12 };
+  return `${m[3]}-${String(months[m[1]]).padStart(2,'0')}-${String(m[2]).padStart(2,'0')}`;
+}
+
+(async () => {
+  console.log('Fetching Wikipedia WoF list…');
+  const html = await fetchTable();
+  console.log(`HTML size: ${(html.length/1024).toFixed(1)}KB`);
+
+  const rows = parseRows(html);
+  console.log(`Parsed ${rows.length} star rows`);
+
+  // Dedupe to one row per honoree (keep first/primary star) for the table.
+  // Multi-category honorees still get their primary star pin; secondary stars
+  // are tracked in honoree_count for future "shows N stars" enrichment.
+  const seen = new Map();
+  for (const r of rows) {
+    if (!seen.has(r.slug)) seen.set(r.slug, { ...r, extra_categories: [] });
+    else seen.get(r.slug).extra_categories.push(r.category);
+  }
+  const honorees = [...seen.values()];
+  console.log(`Unique honorees: ${honorees.length}`);
+
+  let ok = 0, geoFail = 0;
+  for (const h of honorees) {
+    const g = geocode(h.address);
+    if (!g) {
+      console.log(`SKIP-GEO ${h.name.padEnd(30)} @ ${h.address}`);
+      geoFail++;
+      continue;
+    }
+    const cat = CATEGORY_MAP[h.category] ?? 'motion_pictures';
+    try {
+      await pool.query(
+        `INSERT INTO walk_of_fame
+          (id, honoree_slug, honoree_name, category, address, lat, lng, dedicated_date, source_url)
+         VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7::date, $8)
+         ON CONFLICT (honoree_slug) DO UPDATE SET
+           honoree_name = EXCLUDED.honoree_name,
+           category = EXCLUDED.category,
+           address = EXCLUDED.address,
+           lat = EXCLUDED.lat, lng = EXCLUDED.lng,
+           dedicated_date = EXCLUDED.dedicated_date,
+           source_url = EXCLUDED.source_url`,
+        [h.slug, h.name, cat, h.address, g.lat, g.lng, h.date, h.wiki_url]
+      );
+      ok++;
+    } catch (e) {
+      console.log(`SKIP ${h.name}: ${e.message.slice(0, 80)}`);
+    }
+  }
+  console.log(`\ndone: ${ok} inserted/updated · ${geoFail} no-geocode (non-Hwd-Blvd/Vine address)`);
+  await pool.end();
+})();
diff --git a/scripts/seed-walk-of-fame.mjs b/scripts/seed-walk-of-fame.mjs
new file mode 100644
index 0000000..c03d4d2
--- /dev/null
+++ b/scripts/seed-walk-of-fame.mjs
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+/**
+ * Seed Hollywood Walk of Fame stars — curated subset of ~25 famous honorees
+ * with hand-verified addresses + coordinates from the Hollywood Chamber list.
+ *
+ * Coordinates are at the actual sidewalk star location, not approximate. The
+ * Walk runs ~1.2mi along Hollywood Blvd from Gower to La Brea + ~0.4mi on
+ * Vine from Sunset to Yucca. Addresses are 4-digit Hollywood Blvd numbers
+ * (~5800-7000) or Vine St numbers (1500-1800).
+ *
+ * Future: nightly job to scrape full 2,700+ list from Wikipedia + OSM.
+ */
+import pg from 'pg';
+const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 3 });
+
+const STARS = [
+  // Format: name, category, address, lat, lng, dedicated, notes
+  { name: 'Marilyn Monroe', category: 'motion_pictures', address: '6774 Hollywood Blvd', lat: 34.1019583, lng: -118.3402583, dedicated: '1960-02-08' },
+  { name: 'Michael Jackson', category: 'recording', address: '6927 Hollywood Blvd', lat: 34.1014444, lng: -118.3437778, dedicated: '1984-11-20' },
+  { name: 'Elvis Presley', category: 'recording', address: '6777 Hollywood Blvd', lat: 34.1017611, lng: -118.3403472, dedicated: '1960-02-08' },
+  { name: 'James Stewart', category: 'motion_pictures', address: '1708 Vine St', lat: 34.1024444, lng: -118.3268611, dedicated: '1960-02-08' },
+  { name: 'Lucille Ball', category: 'television', address: '6436 Hollywood Blvd', lat: 34.1018333, lng: -118.3361389, dedicated: '1960-02-08' },
+  { name: 'Walt Disney', category: 'motion_pictures', address: '6747 Hollywood Blvd', lat: 34.1019028, lng: -118.3399333, dedicated: '1960-02-08' },
+  { name: 'Charlie Chaplin', category: 'motion_pictures', address: '6751 Hollywood Blvd', lat: 34.1019139, lng: -118.3400167, dedicated: '1972-12-09' },
+  { name: 'John Wayne', category: 'motion_pictures', address: '1541 Vine St', lat: 34.1009250, lng: -118.3263222, dedicated: '1960-02-08' },
+  { name: 'Frank Sinatra', category: 'recording', address: '1600 Vine St', lat: 34.1014972, lng: -118.3266389, dedicated: '1960-02-08' },
+  { name: 'Bob Hope', category: 'motion_pictures', address: '6541 Hollywood Blvd', lat: 34.1018556, lng: -118.3375194, dedicated: '1960-02-08' },
+  { name: 'Judy Garland', category: 'recording', address: '1715 Vine St', lat: 34.1025222, lng: -118.3268694, dedicated: '1960-02-08' },
+  { name: 'Cary Grant', category: 'motion_pictures', address: '1610 Vine St', lat: 34.1015139, lng: -118.3266389, dedicated: '1960-02-08' },
+  { name: 'Audrey Hepburn', category: 'motion_pictures', address: '1652 Vine St', lat: 34.1019306, lng: -118.3266389, dedicated: '1960-02-08' },
+  { name: 'Humphrey Bogart', category: 'motion_pictures', address: '6322 Hollywood Blvd', lat: 34.1018000, lng: -118.3346667, dedicated: '1960-02-08' },
+  { name: 'Sidney Poitier', category: 'motion_pictures', address: '6212 Hollywood Blvd', lat: 34.1017889, lng: -118.3329333, dedicated: '1992-08-15' },
+  { name: 'Dolly Parton', category: 'recording', address: '6712 Hollywood Blvd', lat: 34.1018972, lng: -118.3393500, dedicated: '1984-06-04' },
+  { name: 'Stevie Wonder', category: 'recording', address: '7050 Hollywood Blvd', lat: 34.1013361, lng: -118.3454083, dedicated: '1982-05-19' },
+  { name: 'Aretha Franklin', category: 'recording', address: '7012 Hollywood Blvd', lat: 34.1014139, lng: -118.3447139, dedicated: '1979-02-06' },
+  { name: 'Ray Charles', category: 'recording', address: '6777 Hollywood Blvd', lat: 34.1017694, lng: -118.3403694, dedicated: '1981-12-16' },
+  { name: 'Whitney Houston', category: 'recording', address: '6259 Hollywood Blvd', lat: 34.1018028, lng: -118.3335417, dedicated: '2003-03-13' },
+  { name: 'Tom Hanks', category: 'motion_pictures', address: '7000 Hollywood Blvd', lat: 34.1014278, lng: -118.3445389, dedicated: '1998-02-23' },
+  { name: 'Meryl Streep', category: 'motion_pictures', address: '6241 Hollywood Blvd', lat: 34.1018000, lng: -118.3332778, dedicated: '1998-09-08' },
+  { name: 'Steven Spielberg', category: 'motion_pictures', address: '6801 Hollywood Blvd', lat: 34.1018611, lng: -118.3408139, dedicated: '2003-01-10' },
+  { name: 'The Beatles', category: 'recording', address: '7080 Hollywood Blvd', lat: 34.1013500, lng: -118.3458944, dedicated: '1998-04-15' },
+  { name: 'Hattie McDaniel', category: 'motion_pictures', address: '6933 Hollywood Blvd', lat: 34.1014694, lng: -118.3439250, dedicated: '1960-02-08' },
+];
+
+function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
+
+(async () => {
+  let inserted = 0;
+  for (const s of STARS) {
+    try {
+      await pool.query(
+        `INSERT INTO walk_of_fame (honoree_name, honoree_slug, category, address, lat, lng, dedicated_date)
+         VALUES ($1, $2, $3, $4, $5, $6, $7::date)
+         ON CONFLICT (honoree_slug) DO UPDATE SET
+           address = EXCLUDED.address, lat = EXCLUDED.lat, lng = EXCLUDED.lng,
+           dedicated_date = EXCLUDED.dedicated_date`,
+        [s.name, slug(s.name), s.category, s.address, s.lat, s.lng, s.dedicated]
+      );
+      inserted++;
+      console.log(`OK ${s.name.padEnd(22)} · ${s.category.padEnd(15)} · ${s.address}`);
+    } catch (e) {
+      console.warn(`SKIP ${s.name}: ${e.message}`);
+    }
+  }
+  console.log(`\n${inserted}/${STARS.length} stars seeded`);
+  await pool.end();
+})();
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
new file mode 100644
index 0000000..d636c04
--- /dev/null
+++ b/src/app/about/page.tsx
@@ -0,0 +1,128 @@
+import Link from 'next/link';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { TierBadge } from '@/components/TierBadge';
+import { SITE_NAME } from '@/lib/site';
+
+export default function AboutPage() {
+  return (
+    <>
+      <div className="max-w-3xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'About' }]} />
+      </div>
+
+      <header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">About</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          The opposite of a listing.
+        </h1>
+        <p className="mt-5 font-display italic text-2xl text-ink/70">
+          Where Nextdoor / Zillow / Redfin look forward, {SITE_NAME} looks back.
+        </p>
+      </header>
+
+      <article className="max-w-3xl mx-auto px-6 py-12 prose-style space-y-10">
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">What this is</h2>
+          <p className="text-base text-ink/80 leading-relaxed">
+            {SITE_NAME} is an address-first record. For every property we cover, the canonical page is the
+            <strong> place</strong> — not the listing, not the owner, not the celebrity who once slept there. The page
+            assembles permits, sales, archival photos, building history, and — when the evidence is strong
+            enough and the people are no longer living — the names and dates of those associated with it.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">What this is not</h2>
+          <ul className="text-base text-ink/80 leading-relaxed space-y-3 list-none">
+            <li>
+              <strong>Not a database of where living people live.</strong> We don&rsquo;t accept, infer, or display
+              current residences of living individuals. Our database trigger raises an exception if anyone tries.
+            </li>
+            <li>
+              <strong>Not a real-estate listing site.</strong> Sales, photos, and "available now" content are
+              walled off into a clearly-labeled sponsored module that never touches the historical record.
+            </li>
+            <li>
+              <strong>Not a tourist map.</strong> The product&rsquo;s register is editorial and archival, not "your
+              guided bus tour of where Marilyn lived."
+            </li>
+          </ul>
+        </section>
+
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">Source tiers</h2>
+          <p className="text-base text-ink/80 leading-relaxed mb-5">
+            Every fact on a public page carries one of three badges. Tier D — social leads — never renders
+            publicly; it lives in our editorial queue.
+          </p>
+          <ul className="space-y-4">
+            <li className="flex items-start gap-4">
+              <TierBadge tier="A" />
+              <span className="text-sm text-ink/80 leading-relaxed">
+                <strong>Government record.</strong> Assessor parcel data, recorded deeds, building permits.
+              </span>
+            </li>
+            <li className="flex items-start gap-4">
+              <TierBadge tier="B" />
+              <span className="text-sm text-ink/80 leading-relaxed">
+                <strong>Archive.</strong> Sanborn maps, HABS/HAER, library special collections, museum holdings.
+              </span>
+            </li>
+            <li className="flex items-start gap-4">
+              <TierBadge tier="C" />
+              <span className="text-sm text-ink/80 leading-relaxed">
+                <strong>Verified secondary.</strong> Architect monographs, dated newspaper articles, historical
+                society publications.
+              </span>
+            </li>
+          </ul>
+        </section>
+
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">The 1978–1984 anchor</h2>
+          <p className="text-base text-ink/80 leading-relaxed">
+            Rental projections do not use a flat 3% inflation assumption. They&rsquo;re anchored to the observed
+            shape of the LA County 1978-1984 cycle — first leg up (+18%/yr), rate-shock stall (-12%), recovery
+            (+19%) — pulled directly from FRED&rsquo;s LA County HPI series. Every projection on screen carries
+            its anchor window and a confidence dot. We will not display a forecast without showing what
+            historical cycle it&rsquo;s anchored to.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">Beverly Hills, then onward</h2>
+          <p className="text-base text-ink/80 leading-relaxed">
+            We start with Beverly Hills because the city publishes its permit feed nightly, the public library
+            holds an unusual depth of architectural archives, and Sanborn coverage is dense for the era we care
+            about. From there: West Hollywood, Hollywood, broader LA County, then Santa Monica and the
+            Westside. The product expands in concentric rings, not by national rollout.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">If your address is here</h2>
+          <p className="text-base text-ink/80 leading-relaxed">
+            You can{' '}
+            <Link href="/submit" className="underline-offset-2 hover:underline text-coral">
+              claim it
+            </Link>{' '}
+            for free and add photos, private notes, and home records. The historical ledger remains
+            independent — your edits live alongside it, never on top of it.
+          </p>
+        </section>
+
+        <section>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4">If you&rsquo;re selling or hosting it</h2>
+          <p className="text-base text-ink/80 leading-relaxed">
+            You can{' '}
+            <Link href="/promote" className="underline-offset-2 hover:underline text-coral">
+              promote
+            </Link>{' '}
+            your current Zillow / Redfin / Airbnb listing on top of the page for $29/mo. The placement is
+            visually walled off, source-logo&rsquo;d, and never written into the historical record.
+          </p>
+        </section>
+      </article>
+    </>
+  );
+}
diff --git a/src/app/address/[slug]/opengraph-image.tsx b/src/app/address/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..980c651
--- /dev/null
+++ b/src/app/address/[slug]/opengraph-image.tsx
@@ -0,0 +1,83 @@
+import { ImageResponse } from 'next/og';
+import { headers } from 'next/headers';
+import { getListingBySlug } from '@/lib/db';
+import { siteForHost } from '@/lib/site';
+
+export const runtime = 'nodejs';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default async function og({ params }: { params: { slug: string } }) {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const listing = await getListingBySlug(params.slug);
+  const title = listing?.title ?? 'Address';
+  const place = listing
+    ? `${listing.city ?? ''}${listing.city && listing.state ? ', ' : ''}${listing.state ?? ''}`.trim()
+    : '';
+
+  return new ImageResponse(
+    (
+      <div
+        style={{
+          width: '100%',
+          height: '100%',
+          display: 'flex',
+          flexDirection: 'column',
+          justifyContent: 'space-between',
+          padding: '64px',
+          background: '#f6f1e8',
+          color: '#1a1410',
+          fontFamily: 'Georgia, serif',
+        }}
+      >
+        <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, fontSize: 24 }}>
+          <span style={{ fontFamily: 'Georgia, serif', fontWeight: 400 }}>{surface.brandName}</span>
+          <span style={{ color: '#d4664a' }}>·</span>
+          <span style={{ fontSize: 16, letterSpacing: 2, textTransform: 'uppercase', color: 'rgba(26,20,16,0.6)' }}>
+            address record
+          </span>
+        </div>
+        <div style={{ display: 'flex', flexDirection: 'column' }}>
+          <div
+            style={{
+              fontSize: place ? 32 : 28,
+              letterSpacing: 4,
+              textTransform: 'uppercase',
+              color: 'rgba(26,20,16,0.55)',
+              marginBottom: 24,
+            }}
+          >
+            {place || 'Archive entry'}
+          </div>
+          <div
+            style={{
+              fontSize: 96,
+              lineHeight: 1.02,
+              letterSpacing: '-0.02em',
+              color: '#1a1410',
+              maxWidth: '90%',
+            }}
+          >
+            {title}
+          </div>
+        </div>
+        <div
+          style={{
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'baseline',
+            fontSize: 18,
+            color: 'rgba(26,20,16,0.55)',
+          }}
+        >
+          <span>Where the house is history.</span>
+          <span style={{ fontFamily: 'Menlo, monospace', fontSize: 14, letterSpacing: 1 }}>
+            {surface.domain}
+          </span>
+        </div>
+      </div>
+    ),
+    { ...size }
+  );
+}
diff --git a/src/app/address/[slug]/page.tsx b/src/app/address/[slug]/page.tsx
new file mode 100644
index 0000000..b500402
--- /dev/null
+++ b/src/app/address/[slug]/page.tsx
@@ -0,0 +1,460 @@
+import { notFound } from 'next/navigation';
+import type { Metadata } from 'next';
+import {
+  getListingBySlug,
+  getListingAssociations,
+  getNearbyListings,
+  getEventsForListing,
+  getMediaForListing,
+  getStructuresForListing,
+  getParcelForListing,
+  getProductionsForListing,
+  getNearbyFilming,
+  yearOf,
+} from '@/lib/db';
+
+export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
+  const listing = await getListingBySlug(params.slug);
+  if (!listing) return { robots: { index: false, follow: false } };
+  // Only index pages with substantive content. Stub pages get noindex.
+  const events = await getEventsForListing(listing.id);
+  const indexable = events.length > 0;
+  return {
+    title: listing.title,
+    description: listing.headline ?? `History of ${listing.title} — permits, sales, archival media.`,
+    alternates: { canonical: `/address/${listing.slug}` },
+    robots: indexable ? { index: true, follow: true } : { index: false, follow: true },
+    openGraph: {
+      title: listing.title,
+      description: listing.headline ?? '',
+      url: `/address/${listing.slug}`,
+      type: 'article',
+    },
+  };
+}
+import { NodeCard } from '@/components/NodeCard';
+import { AddressMasthead } from '@/components/AddressMasthead';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { Timeline, type TimelineEvent } from '@/components/Timeline';
+import { FactRow } from '@/components/FactRow';
+import { Projection } from '@/components/Projection';
+import { ClaimCTA } from '@/components/ClaimCTA';
+import { SponsoredCard } from '@/components/sponsored/SponsoredCard';
+import { projectMonthlyFromNightly } from '@/lib/multiplier';
+import { CurveSparkline } from '@/components/CurveSparkline';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+import { StreetView } from '@/components/StreetView';
+import { StreetViewHistory } from '@/components/StreetViewHistory';
+import { MediaWall } from '@/components/MediaWall';
+import { JsonLd } from '@/components/JsonLd';
+import { ProvenanceFooter } from '@/components/ProvenanceFooter';
+import { CrossPromo } from '@/components/CrossPromo';
+import { FilmsGridServer } from '@/components/FilmsGridServer';
+import { AddressNarrative } from '@/components/AddressNarrative';
+import { listPdFilmImages } from '@/lib/db';
+import { getOrFetchParcelForAddress } from '@/lib/la-arcgis';
+import { getPermitsForApn } from '@/lib/la-permits';
+import { SITE_DOMAIN, currentSurface } from '@/lib/site';
+import Link from 'next/link';
+
+export default async function AddressPage({ params }: { params: { slug: string } }) {
+  const listing = await getListingBySlug(params.slug);
+  if (!listing) notFound();
+
+  const surface = await currentSurface();
+
+  const [associations, nearby, placeEvents, media, structures, parcel, productions, nearbyFilming] = await Promise.all([
+    getListingAssociations(listing.id),
+    listing.city ? getNearbyListings(listing.city, listing.id, 3) : Promise.resolve([]),
+    getEventsForListing(listing.id),
+    getMediaForListing(listing.id),
+    getStructuresForListing(listing.id),
+    getParcelForListing(listing.id),
+    getProductionsForListing(listing.id),
+    listing.city ? getNearbyFilming(listing.city, listing.id, 5) : Promise.resolve([]),
+  ]);
+
+  // For the films-at-this-address grid: only render if PD images actually exist
+  // for the linked productions. Avoids the empty placeholder strip seen on most
+  // listings (where productions exist but no PD image has been matched yet).
+  const filmsHere = productions.length
+    ? await listPdFilmImages({ productionIds: productions.map(p => p.production_id), limit: 36 })
+    : [];
+
+  // Real LA County assessor data — cache-first lookup. Falls through to local
+  // structure data if ArcGIS misses (e.g. address outside LA County or normalization fails).
+  // Variable name is `laParcel` because `parcel` already refers to the local
+  // pastdoor `parcel` table (`getParcelForListing` above).
+  const laParcel = listing.address_line1 || listing.title
+    ? await getOrFetchParcelForAddress(
+        `${listing.address_line1 ?? listing.title} ${listing.city ?? ''}`.trim(),
+        listing.id
+      ).catch(() => null)
+    : null;
+
+  // LA City permits — only resolves if parcel is in City of LA (not BH/SM/etc).
+  // Cache-first via la_permit table.
+  const laPermits = laParcel?.apn
+    ? await getPermitsForApn(laParcel.apn, { limit: 50 }).catch(() => [])
+    : [];
+
+  const events: TimelineEvent[] = placeEvents.map(pe => ({
+    date: pe.valid_from ? yearOf(pe.valid_from) : 'undated',
+    type: pe.kind,
+    summary: pe.summary,
+    tier: pe.source_tier as 'A' | 'B' | 'C',
+    sourceLabel: pe.source_label ?? undefined,
+    sourceUrl: pe.source_url ?? undefined,
+  }));
+
+  const projection = projectMonthlyFromNightly(
+    listing.nightly_price != null ? Number(listing.nightly_price) : null
+  );
+
+  // schema.org/Place — only the fields actually visible on the page.
+  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`;
+  const jsonLd: Record<string, unknown> = {
+    '@context': 'https://schema.org',
+    '@type': 'Place',
+    name: listing.title,
+    url: `${baseUrl}/address/${listing.slug}`,
+    address: {
+      '@type': 'PostalAddress',
+      streetAddress: listing.address_line1 ?? undefined,
+      addressLocality: listing.city ?? undefined,
+      addressRegion: listing.state ?? undefined,
+      postalCode: listing.postal_code ?? undefined,
+      addressCountry: listing.country ?? 'US',
+    },
+  };
+  if (listing.latitude != null && listing.longitude != null) {
+    jsonLd.geo = {
+      '@type': 'GeoCoordinates',
+      latitude: Number(listing.latitude),
+      longitude: Number(listing.longitude),
+    };
+  }
+  if (structures[0]?.year_built) {
+    // schema.org has no canonical "year built"; use additionalProperty.
+    jsonLd.additionalProperty = [
+      { '@type': 'PropertyValue', name: 'Year built', value: structures[0].year_built },
+      ...(parcel ? [{ '@type': 'PropertyValue', name: 'APN', value: parcel.apn }] : []),
+    ];
+  }
+  if (listing.description) jsonLd.description = listing.description;
+
+  // Roll up source-tier counts for the provenance footer.
+  const tierCounts = { A: 0, B: 0, C: 0 } as { A: number; B: number; C: number };
+  for (const e of placeEvents) {
+    if (e.source_tier === 'A' || e.source_tier === 'B' || e.source_tier === 'C') tierCounts[e.source_tier]++;
+  }
+  for (const a of associations) {
+    if (a.source_tier === 'A' || a.source_tier === 'B' || a.source_tier === 'C') tierCounts[a.source_tier]++;
+  }
+  for (const m of media) {
+    if (m.source_tier === 'A' || m.source_tier === 'B' || m.source_tier === 'C') tierCounts[m.source_tier]++;
+  }
+  for (const p of productions) {
+    if (p.source_tier === 'A' || p.source_tier === 'B' || p.source_tier === 'C') tierCounts[p.source_tier]++;
+  }
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive
+          items={[
+            { label: 'Archive', href: '/browse' },
+            { label: listing.city ?? '—', href: `/neighborhood/${(listing.city ?? '').toLowerCase().replace(/\s+/g, '-')}` },
+            { label: listing.postal_code ?? '—' },
+            { label: listing.title },
+          ]}
+        />
+      </div>
+
+      <JsonLd data={jsonLd} />
+      <AddressMasthead listing={listing} />
+
+      {/* Storytelling layer — narrative paragraphs above the data tables.
+          The story comes first; the citations live in the timeline + facts below. */}
+      <AddressNarrative
+        listing={listing}
+        structures={structures}
+        events={placeEvents}
+        productions={productions}
+        parcel={laParcel}
+        laPermits={laPermits}
+      />
+
+      {listing.latitude != null && listing.longitude != null && (
+        <div className="max-w-6xl mx-auto px-6">
+          <StreetView
+            lat={Number(listing.latitude)}
+            lng={Number(listing.longitude)}
+            addressLabel={listing.address_line1 ?? listing.title}
+          />
+          <StreetViewHistory
+            lat={Number(listing.latitude)}
+            lng={Number(listing.longitude)}
+          />
+        </div>
+      )}
+
+      <div className="max-w-6xl mx-auto px-6 py-12 grid md:grid-cols-[minmax(0,1fr)_320px] gap-10">
+        {/* LEFT — editorial */}
+        <div>
+          <section className="mb-10">
+            <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">History</h2>
+            <Timeline events={events} />
+          </section>
+
+          {associations.length > 0 && (
+            <section className="mb-10">
+              <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Associated (historical)</h2>
+              <ul className="space-y-4">
+                {associations.map(a => (
+                  <li key={a.id} className="border-b border-ink/5 pb-4">
+                    <div className="flex items-baseline gap-3 flex-wrap">
+                      <Link
+                        href={`/entity/${a.entity_slug}`}
+                        className="font-display text-xl text-ink hover:text-coral transition"
+                      >
+                        {a.entity_name}
+                      </Link>
+                      <span className="text-xs uppercase tracking-wider text-ink/50">{a.relation}</span>
+                      {(a.date_from || a.date_to) && (
+                        <span className="font-mono text-xs text-ink/60">
+                          {yearOf(a.date_from)}–{yearOf(a.date_to)}
+                        </span>
+                      )}
+                    </div>
+                    {a.summary && <p className="mt-2 text-sm text-ink/70">{a.summary}</p>}
+                  </li>
+                ))}
+              </ul>
+            </section>
+          )}
+
+          <section className="mb-10">
+            <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Property</h2>
+            <dl>
+              {parcel && (
+                <FactRow
+                  label="APN"
+                  value={<span className="font-mono">{parcel.apn}</span>}
+                  tier="A"
+                  sourceLabel={parcel.jurisdiction}
+                />
+              )}
+              {structures[0]?.year_built && (
+                <FactRow
+                  label="Year built"
+                  value={structures[0].year_built}
+                  tier="A"
+                  sourceLabel="assessor"
+                />
+              )}
+              {listing.property_type && (
+                <FactRow label="Type" value={listing.property_type} tier="A" sourceLabel="assessor" />
+              )}
+              {listing.bedrooms && (
+                <FactRow label="Bedrooms" value={listing.bedrooms} tier="A" sourceLabel="assessor" />
+              )}
+              {listing.bathrooms && (
+                <FactRow label="Bathrooms" value={listing.bathrooms} tier="A" sourceLabel="assessor" />
+              )}
+              {listing.sleeps && (
+                <FactRow label="Sleeps" value={listing.sleeps} tier="C" sourceLabel="owner" />
+              )}
+              {listing.amenities.length > 0 && (
+                <FactRow
+                  label="Amenities"
+                  value={listing.amenities.join(' · ')}
+                  tier="C"
+                  sourceLabel="owner"
+                />
+              )}
+            </dl>
+          </section>
+
+          {media.length > 0 && <MediaWall assets={media} />}
+
+          {productions.length > 0 && (
+            <section className="mb-10">
+              <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Used as filming location</h2>
+              <ul className="space-y-4">
+                {productions.map(p => (
+                  <li key={p.id} className="border-b border-ink/5 pb-4">
+                    <div className="flex items-baseline gap-3 flex-wrap">
+                      <Link
+                        href={`/film/${p.production_slug}`}
+                        className="font-display text-xl text-ink hover:text-coral transition"
+                      >
+                        {p.production_title}
+                      </Link>
+                      <span className="text-xs uppercase tracking-wider text-ink/50">
+                        {p.production_kind.replace('_', ' ')}
+                      </span>
+                      {p.production_year && (
+                        <span className="font-mono text-xs text-ink/60">{p.production_year}</span>
+                      )}
+                      {p.role && (
+                        <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
+                          {p.role.replace('_', ' ')}
+                        </span>
+                      )}
+                    </div>
+                    {p.scene_note && (
+                      <p className="mt-1 text-sm text-ink/70 leading-relaxed">{p.scene_note}</p>
+                    )}
+                    {p.permit_number && (
+                      <p className="mt-1 font-mono text-[10px] text-ink/45 uppercase tracking-[0.15em]">
+                        FilmLA permit · {p.permit_number}
+                      </p>
+                    )}
+                  </li>
+                ))}
+              </ul>
+            </section>
+          )}
+
+          {projection.monthly && (
+            <section className="mb-10">
+              <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">Rental projection</h2>
+              <div className="grid md:grid-cols-[1fr_320px] gap-6 items-start">
+                <Projection
+                  label="Projected monthly"
+                  value={`$${projection.monthly.toLocaleString()}`}
+                  unit="/ mo"
+                  confidence={projection.confidence}
+                  anchorWindow={projection.anchorWindow}
+                  note={projection.note}
+                />
+                <CurveSparkline />
+              </div>
+            </section>
+          )}
+
+          {nearby.length > 0 && (
+            <section className="mb-10">
+              <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">
+                Nearby in {listing.city}
+              </h2>
+              <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+                {nearby.map(n => (
+                  <NodeCard
+                    key={n.id}
+                    kind="address"
+                    href={`/address/${n.slug}`}
+                    title={n.title}
+                    subtitle={n.address_line1 ?? undefined}
+                    imageUrl={n.hero_image ?? undefined}
+                    eyebrow={n.property_type ?? 'Address'}
+                  />
+                ))}
+              </div>
+            </section>
+          )}
+
+          {nearbyFilming.length > 0 && (
+            <section className="mb-10">
+              <h2 className="font-display text-3xl text-ink mb-4 tracking-[-0.01em]">
+                Filmed nearby
+              </h2>
+              <ul className="divide-y divide-ink/5 border-t border-ink/10">
+                {nearbyFilming.map(f => (
+                  <li key={`${f.production_slug}-${f.listing_slug}`} className="py-3 flex items-baseline justify-between gap-3 flex-wrap">
+                    <div className="flex items-baseline gap-3 flex-wrap">
+                      <Link href={`/film/${f.production_slug}`} className="font-display text-lg text-ink hover:text-coral transition">
+                        {f.production_title}
+                      </Link>
+                      <span className="text-ink/30">·</span>
+                      <Link href={`/address/${f.listing_slug}`} className="text-sm text-ink/70 hover:text-coral transition">
+                        {f.listing_title}
+                      </Link>
+                    </div>
+                    <span className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+                      {f.shoot_year ?? '?'} · {f.production_kind.replace('_', ' ')}
+                    </span>
+                  </li>
+                ))}
+              </ul>
+            </section>
+          )}
+        </div>
+
+        {/* RIGHT — sidebar */}
+        <aside className="flex flex-col gap-6">
+          {listing.latitude != null && listing.longitude != null && (() => {
+            const sidebarPins: MapPin[] = [
+              {
+                slug: listing.slug,
+                title: listing.title,
+                subtitle: listing.address_line1 ?? listing.city ?? undefined,
+                lat: Number(listing.latitude),
+                lng: Number(listing.longitude),
+                featured: true,
+              },
+              ...nearby
+                .filter(n => n.latitude != null && n.longitude != null)
+                .map(n => ({
+                  slug: n.slug,
+                  title: n.title,
+                  subtitle: n.address_line1 ?? undefined,
+                  lat: Number(n.latitude),
+                  lng: Number(n.longitude),
+                })),
+            ];
+            return (
+              <div>
+                <InteractiveMapMount pins={sidebarPins} height={260} variant="positron" />
+                <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+                  {listing.address_line1 ?? listing.city}
+                </p>
+              </div>
+            );
+          })()}
+
+          <ClaimCTA slug={listing.slug} />
+
+          {/* --- WALLED OFF SPONSORED MODULE --- */}
+          {listing.source_url && (
+            <SponsoredCard
+              source={
+                listing.source === 'zillow'
+                  ? 'zillow'
+                  : listing.source === 'redfin'
+                  ? 'redfin'
+                  : listing.source === 'airbnb'
+                  ? 'airbnb'
+                  : 'other'
+              }
+              title={`View ${listing.title} on ${listing.source}`}
+              priceLabel={
+                listing.nightly_price
+                  ? `From $${Number(listing.nightly_price).toLocaleString()} /night`
+                  : undefined
+              }
+              url={listing.source_url}
+              imageUrl={listing.hero_image ?? undefined}
+            />
+          )}
+        </aside>
+      </div>
+
+      {/* Only show the films grid if we actually have PD images for these
+          productions. Otherwise the empty grid renders awkward placeholders. */}
+      {filmsHere.length > 0 && (
+        <FilmsGridServer
+          heading="Scenes filmed at this address"
+          kicker="Public-domain stills · provenance per image"
+          productionIds={productions.map(p => p.production_id)}
+          limit={36}
+        />
+      )}
+
+      <ProvenanceFooter counts={tierCounts} lastUpdated={listing.updated_at as unknown as Date | string | null} />
+
+      <CrossPromo current={surface.key} />
+    </>
+  );
+}
diff --git a/src/app/admin/data/page.tsx b/src/app/admin/data/page.tsx
new file mode 100644
index 0000000..bb77023
--- /dev/null
+++ b/src/app/admin/data/page.tsx
@@ -0,0 +1,397 @@
+import Link from 'next/link';
+import { pool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Source = {
+  key: string;
+  label: string;
+  table: string;
+  searchFields: string[];      // columns we ILIKE against
+  displayFields: { col: string; label: string; format?: 'date' | 'money' | 'truncate' }[];
+  sourceUrlField?: string;
+  sourceLabelField?: string;
+  filterable?: { col: string; label: string }[]; // dropdown filters
+  defaultSort?: string;
+};
+
+const SOURCES: Source[] = [
+  {
+    key: 'listing',
+    label: 'Listings (addresses)',
+    table: 'listing',
+    searchFields: ['title', 'address_line1', 'city', 'postal_code', 'description', 'headline', 'property_type'],
+    displayFields: [
+      { col: 'slug', label: 'slug' },
+      { col: 'source', label: 'source' },
+      { col: 'title', label: 'title' },
+      { col: 'address_line1', label: 'address' },
+      { col: 'city', label: 'city' },
+      { col: 'is_public', label: 'public' },
+    ],
+    filterable: [{ col: 'source', label: 'source' }],
+    defaultSort: 'updated_at DESC',
+  },
+  {
+    key: 'permit',
+    label: 'Permits (LADBS / BH / Pasadena / SM)',
+    table: 'permit',
+    searchFields: ['permit_number', 'primary_address', 'work_desc', 'use_desc', 'permit_type', 'permit_sub_type', 'apn', 'zip_code', 'zone'],
+    displayFields: [
+      { col: 'source', label: 'source' },
+      { col: 'permit_number', label: 'permit #' },
+      { col: 'primary_address', label: 'address' },
+      { col: 'permit_type', label: 'type' },
+      { col: 'permit_sub_type', label: 'sub-type' },
+      { col: 'use_desc', label: 'use' },
+      { col: 'issue_date', label: 'issued', format: 'date' },
+      { col: 'valuation', label: 'value', format: 'money' },
+      { col: 'status_desc', label: 'status' },
+      { col: 'work_desc', label: 'work', format: 'truncate' },
+    ],
+    sourceUrlField: 'source_url',
+    sourceLabelField: 'source_label',
+    filterable: [{ col: 'source', label: 'source' }, { col: 'source_dataset', label: 'dataset' }],
+    defaultSort: 'issue_date DESC NULLS LAST',
+  },
+  {
+    key: 'code_enforcement',
+    label: 'Code enforcement cases',
+    table: 'code_enforcement_case',
+    searchFields: ['case_number', 'primary_address', 'case_type', 'apn', 'zip_code'],
+    displayFields: [
+      { col: 'case_number', label: 'case #' },
+      { col: 'primary_address', label: 'address' },
+      { col: 'case_type', label: 'type' },
+      { col: 'status', label: 'status' },
+      { col: 'opened_at', label: 'opened', format: 'date' },
+      { col: 'apn', label: 'APN' },
+    ],
+    sourceUrlField: 'source_url',
+    sourceLabelField: 'source_label',
+    filterable: [{ col: 'source_dataset', label: 'dataset' }, { col: 'status', label: 'status' }],
+    defaultSort: 'opened_at DESC NULLS LAST',
+  },
+  {
+    key: 'parcel',
+    label: 'Parcels (LA County Assessor)',
+    table: 'parcel',
+    searchFields: ['apn', 'situs_full', 'city', 'zip_code', 'use_code', 'use_desc'],
+    displayFields: [
+      { col: 'apn', label: 'APN' },
+      { col: 'situs_full', label: 'address' },
+      { col: 'city', label: 'city' },
+      { col: 'use_desc', label: 'use' },
+      { col: 'year_built', label: 'built' },
+      { col: 'bldg_sqft', label: 'sqft' },
+      { col: 'assessed_total', label: 'AV', format: 'money' },
+      { col: 'roll_year', label: 'roll' },
+    ],
+    filterable: [{ col: 'use_code', label: 'use' }, { col: 'roll_year', label: 'roll year' }],
+    defaultSort: 'assessed_total DESC NULLS LAST',
+  },
+  {
+    key: 'film',
+    label: 'Film productions',
+    table: 'film_production',
+    searchFields: ['title', 'blurb'],
+    displayFields: [
+      { col: 'title', label: 'title' },
+      { col: 'kind', label: 'kind' },
+      { col: 'year', label: 'year' },
+      { col: 'blurb', label: 'blurb', format: 'truncate' },
+      { col: 'source_label', label: 'source' },
+    ],
+    sourceLabelField: 'source_label',
+    filterable: [{ col: 'kind', label: 'kind' }],
+    defaultSort: 'year DESC NULLS LAST',
+  },
+  {
+    key: 'filming_location',
+    label: 'Filming locations (permits)',
+    table: 'filming_location',
+    searchFields: ['permit_number', 'role'],
+    displayFields: [
+      { col: 'permit_number', label: 'permit #' },
+      { col: 'role', label: 'role' },
+      { col: 'shoot_date_from', label: 'from', format: 'date' },
+      { col: 'source_label', label: 'source' },
+    ],
+    sourceLabelField: 'source_label',
+    defaultSort: 'shoot_date_from DESC NULLS LAST',
+  },
+  {
+    key: 'news',
+    label: 'Newspaper issues (OCR)',
+    table: 'news_issue',
+    searchFields: ['paper_name', 'title', 'source_identifier'],
+    displayFields: [
+      { col: 'paper_short', label: 'paper' },
+      { col: 'issue_date', label: 'date' },
+      { col: 'source_identifier', label: 'IA id', format: 'truncate' },
+      { col: 'ocr_fetched_at', label: 'ocr at', format: 'date' },
+    ],
+    sourceUrlField: 'ia_url',
+    sourceLabelField: 'source_label',
+    filterable: [{ col: 'paper_short', label: 'paper' }],
+    defaultSort: 'issue_date DESC NULLS LAST',
+  },
+  {
+    key: 'mention',
+    label: 'Address mentions in newspapers',
+    table: 'news_address_mention',
+    searchFields: ['raw_address', 'street_name_normalized', 'context'],
+    displayFields: [
+      { col: 'raw_address', label: 'mention' },
+      { col: 'street_name_normalized', label: 'normalized' },
+      { col: 'context', label: 'context', format: 'truncate' },
+      { col: 'match_method', label: 'matched' },
+    ],
+    defaultSort: 'extracted_at DESC',
+  },
+  {
+    key: 'entity',
+    label: 'People / entities',
+    table: 'entity',
+    searchFields: ['display_name', 'kind'],
+    displayFields: [
+      { col: 'display_name', label: 'name' },
+      { col: 'kind', label: 'kind' },
+      { col: 'birth_year', label: 'b.' },
+      { col: 'death_year', label: 'd.' },
+    ],
+    filterable: [{ col: 'kind', label: 'kind' }],
+    defaultSort: 'display_name ASC',
+  },
+  {
+    key: 'sponsored_placement',
+    label: 'Sponsored placement intake',
+    table: 'sponsored_placement',
+    searchFields: ['headline', 'external_url', 'contact_email', 'permit_number'],
+    displayFields: [
+      { col: 'headline', label: 'headline' },
+      { col: 'source', label: 'source' },
+      { col: 'status', label: 'status' },
+      { col: 'contact_email', label: 'email' },
+      { col: 'submitted_at', label: 'submitted', format: 'date' },
+    ],
+    filterable: [{ col: 'status', label: 'status' }],
+    defaultSort: 'submitted_at DESC',
+  },
+  {
+    key: 'claim_request',
+    label: 'Claim requests',
+    table: 'claim_request',
+    searchFields: ['email', 'status'],
+    displayFields: [
+      { col: 'email', label: 'email' },
+      { col: 'status', label: 'status' },
+      { col: 'submitted_at', label: 'submitted', format: 'date' },
+    ],
+    filterable: [{ col: 'status', label: 'status' }],
+    defaultSort: 'submitted_at DESC',
+  },
+];
+
+const PAGE_SIZE = 50;
+
+function fmt(v: any, format?: string) {
+  if (v == null) return <span className="text-ink/30">—</span>;
+  if (format === 'date') {
+    const s = String(v);
+    return s.slice(0, 10);
+  }
+  if (format === 'money') {
+    const n = parseFloat(String(v));
+    if (!isFinite(n)) return String(v);
+    return `$${n.toLocaleString()}`;
+  }
+  if (format === 'truncate') {
+    const s = String(v);
+    return s.length > 80 ? s.slice(0, 80) + '…' : s;
+  }
+  if (typeof v === 'boolean') return v ? '✓' : '✗';
+  return String(v);
+}
+
+async function getDistinctValues(table: string, col: string): Promise<string[]> {
+  try {
+    const r = await pool.query<{ v: string }>(
+      `SELECT DISTINCT ${col} as v FROM ${table} WHERE ${col} IS NOT NULL ORDER BY ${col} LIMIT 50`
+    );
+    return r.rows.map(x => String(x.v));
+  } catch { return []; }
+}
+
+async function search(src: Source, q: string, filters: Record<string, string>, page: number) {
+  const where: string[] = [];
+  const params: any[] = [];
+  if (q) {
+    const orParts = src.searchFields.map(f => {
+      params.push(`%${q}%`);
+      return `${f}::text ILIKE $${params.length}`;
+    });
+    where.push(`(${orParts.join(' OR ')})`);
+  }
+  for (const [k, v] of Object.entries(filters)) {
+    if (v) {
+      params.push(v);
+      where.push(`${k}::text = $${params.length}`);
+    }
+  }
+  const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
+  const cols = Array.from(new Set([
+    ...src.displayFields.map(d => d.col),
+    src.sourceUrlField, src.sourceLabelField,
+  ].filter(Boolean) as string[])).join(', ');
+  const sortClause = src.defaultSort ?? '1';
+  const offset = page * PAGE_SIZE;
+  const sql = `SELECT ${cols} FROM ${src.table} ${whereSql} ORDER BY ${sortClause} LIMIT ${PAGE_SIZE} OFFSET ${offset}`;
+  const countSql = `SELECT count(*)::text as n FROM ${src.table} ${whereSql}`;
+  const [rows, count] = await Promise.all([
+    pool.query<any>(sql, params),
+    pool.query<{ n: string }>(countSql, params),
+  ]);
+  return { rows: rows.rows, total: parseInt(count.rows[0]!.n) };
+}
+
+export default async function AdminDataPage({
+  searchParams,
+}: {
+  searchParams: Record<string, string | undefined>;
+}) {
+  const sourceKey = searchParams.source ?? 'listing';
+  const src = SOURCES.find(s => s.key === sourceKey) ?? SOURCES[0]!;
+  const q = (searchParams.q ?? '').trim();
+  const page = parseInt(searchParams.page ?? '0', 10);
+  const filters: Record<string, string> = {};
+  for (const f of src.filterable ?? []) {
+    if (searchParams[f.col]) filters[f.col] = searchParams[f.col]!;
+  }
+
+  const { rows, total } = await search(src, q, filters, page);
+
+  // Filter dropdown values
+  const filterOpts: Record<string, string[]> = {};
+  for (const f of src.filterable ?? []) {
+    filterOpts[f.col] = await getDistinctValues(src.table, f.col);
+  }
+
+  const buildHref = (overrides: Record<string, string | undefined>) => {
+    const p = new URLSearchParams();
+    p.set('source', sourceKey);
+    if (q && overrides.q !== '') p.set('q', overrides.q ?? q);
+    for (const [k, v] of Object.entries(filters)) {
+      if (overrides[k] !== undefined) {
+        if (overrides[k]) p.set(k, overrides[k]!);
+      } else if (v) p.set(k, v);
+    }
+    if (overrides.page !== undefined) {
+      if (overrides.page) p.set('page', overrides.page);
+    } else if (page > 0) p.set('page', String(page));
+    return `/admin/data?${p}`;
+  };
+
+  return (
+    <div className="space-y-6">
+      <header>
+        <h1 className="font-display text-4xl text-ink mb-2">Data browser</h1>
+        <p className="text-sm text-ink/60">Search every source. Click any column header to deep-link in.</p>
+      </header>
+
+      <nav className="flex flex-wrap gap-1 border-b border-ink/10 pb-2">
+        {SOURCES.map(s => (
+          <Link key={s.key} href={`/admin/data?source=${s.key}`}
+            className={`text-xs uppercase tracking-wider px-3 py-2 transition ${
+              s.key === sourceKey
+                ? 'bg-ink text-sand'
+                : 'text-ink/60 hover:bg-ink/10'
+            }`}>
+            {s.label}
+          </Link>
+        ))}
+      </nav>
+
+      <form className="flex flex-wrap items-end gap-3 bg-white border border-ink/10 p-4" action="/admin/data">
+        <input type="hidden" name="source" value={sourceKey} />
+        <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55 flex-1 min-w-[300px]">
+          search
+          <input
+            name="q" type="search" defaultValue={q}
+            placeholder={`Search ${src.searchFields.join(', ')}…`}
+            className="border border-ink/20 px-3 py-2 text-sm font-mono"
+          />
+        </label>
+        {(src.filterable ?? []).map(f => (
+          <label key={f.col} className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+            {f.label}
+            <select name={f.col} defaultValue={filters[f.col] ?? ''}
+              className="border border-ink/20 px-3 py-2 text-sm font-mono min-w-[140px]">
+              <option value="">All</option>
+              {(filterOpts[f.col] ?? []).map(v => (
+                <option key={v} value={v}>{v}</option>
+              ))}
+            </select>
+          </label>
+        ))}
+        <button type="submit" className="bg-ink text-sand px-5 py-2 text-xs uppercase tracking-wider hover:bg-moss transition">
+          Search
+        </button>
+      </form>
+
+      <p className="text-sm text-ink/60">
+        <span className="font-mono tabular-nums font-medium text-ink">{total.toLocaleString()}</span>
+        {' '}rows in <code className="font-mono text-xs">{src.table}</code>
+        {q && <> matching &ldquo;{q}&rdquo;</>}
+        {Object.keys(filters).length > 0 && <> · filtered by {Object.entries(filters).map(([k, v]) => `${k}=${v}`).join(', ')}</>}
+        {' · '}page {page + 1} of {Math.ceil(total / PAGE_SIZE) || 1}
+      </p>
+
+      <div className="bg-white border border-ink/10 overflow-x-auto">
+        <table className="w-full text-xs">
+          <thead className="bg-ink/[0.04] text-[10px] uppercase tracking-[0.15em] text-ink/60">
+            <tr>
+              {src.displayFields.map(f => (
+                <th key={f.col} className="text-left p-2.5 font-mono">{f.label}</th>
+              ))}
+              {src.sourceUrlField && <th className="text-left p-2.5 font-mono">source</th>}
+            </tr>
+          </thead>
+          <tbody>
+            {rows.length === 0 ? (
+              <tr><td colSpan={src.displayFields.length + 1} className="p-6 text-center text-ink/40 italic">no rows</td></tr>
+            ) : (
+              rows.map((r, i) => (
+                <tr key={i} className="border-t border-ink/5 hover:bg-ink/[0.02]">
+                  {src.displayFields.map(f => (
+                    <td key={f.col} className="p-2.5 align-top font-mono">
+                      {fmt(r[f.col], f.format)}
+                    </td>
+                  ))}
+                  {src.sourceUrlField && (
+                    <td className="p-2.5 align-top font-mono">
+                      {r[src.sourceUrlField] ? (
+                        <a href={r[src.sourceUrlField]} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
+                          {r[src.sourceLabelField!] ?? 'link'} ↗
+                        </a>
+                      ) : <span className="text-ink/30">—</span>}
+                    </td>
+                  )}
+                </tr>
+              ))
+            )}
+          </tbody>
+        </table>
+      </div>
+
+      <div className="flex justify-between items-center text-xs">
+        {page > 0 ? (
+          <Link href={buildHref({ page: page === 1 ? undefined : String(page - 1) })} className="text-ink/70 hover:text-coral">← prev</Link>
+        ) : <span />}
+        {(page + 1) * PAGE_SIZE < total ? (
+          <Link href={buildHref({ page: String(page + 1) })} className="text-ink/70 hover:text-coral">next →</Link>
+        ) : <span />}
+      </div>
+    </div>
+  );
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
new file mode 100644
index 0000000..d700338
--- /dev/null
+++ b/src/app/admin/layout.tsx
@@ -0,0 +1,50 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+  robots: { index: false, follow: false },
+  title: { default: 'Admin', template: '%s · Admin' },
+};
+
+const TABS = [
+  { href: '/admin',           label: 'Overview' },
+  { href: '/admin/sources',   label: 'Sources' },
+  { href: '/admin/data',      label: 'Data browser' },
+  { href: '/admin/ingests',   label: 'Ingests' },
+  { href: '/admin/quality',   label: 'Data quality' },
+  { href: '/admin/claims',    label: 'Claims' },
+  { href: '/admin/seeds',     label: 'Seed data' },
+];
+
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <div className="min-h-screen bg-sand">
+      <header className="border-b border-ink/10 bg-ink text-sand">
+        <div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-6">
+          <Link href="/admin" className="font-display text-xl text-sand hover:text-coral transition">
+            ◢ Admin Console
+          </Link>
+          <span className="text-[10px] uppercase tracking-[0.15em] text-sand/50 font-mono">
+            stayclaim · pastdoor backend
+          </span>
+        </div>
+        <nav className="max-w-7xl mx-auto px-6 flex flex-wrap gap-1 text-xs uppercase tracking-wider">
+          {TABS.map(t => (
+            <Link
+              key={t.href}
+              href={t.href}
+              className="px-3 py-2 hover:bg-coral hover:text-ink transition border-b border-transparent"
+            >
+              {t.label}
+            </Link>
+          ))}
+        </nav>
+      </header>
+      <main className="max-w-7xl mx-auto px-6 py-8">{children}</main>
+      <footer className="max-w-7xl mx-auto px-6 py-8 mt-12 border-t border-ink/10 text-[10px] uppercase tracking-[0.15em] text-ink/40 flex justify-between">
+        <span>admin · auth: dev-mode (no auth)</span>
+        <span>localhost only — wire real auth before production</span>
+      </footer>
+    </div>
+  );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
new file mode 100644
index 0000000..090a331
--- /dev/null
+++ b/src/app/admin/page.tsx
@@ -0,0 +1,171 @@
+import Link from 'next/link';
+import { pool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type CountRow = { metric: string; n: string };
+
+async function getOverview(): Promise<CountRow[]> {
+  const sql = `
+    SELECT 'listings (public)'        as metric, count(*)::text as n FROM listing WHERE is_public = true
+    UNION ALL SELECT 'listings (hidden)',         count(*)::text FROM listing WHERE is_public = false
+    UNION ALL SELECT 'listings (manual seeds)',   count(*)::text FROM listing WHERE source = 'manual'
+    UNION ALL SELECT 'permits (LADBS)',           count(*)::text FROM permit
+    UNION ALL SELECT 'parcels',                   count(*)::text FROM parcel
+    UNION ALL SELECT 'film productions',          count(*)::text FROM film_production
+    UNION ALL SELECT 'filming locations',         count(*)::text FROM filming_location
+    UNION ALL SELECT 'newspaper issues',          count(*)::text FROM news_issue
+    UNION ALL SELECT 'newspaper issues (OCR fetched)', count(*) FILTER (WHERE ocr_text IS NOT NULL AND ocr_text <> '')::text FROM news_issue
+    UNION ALL SELECT 'address mentions in news',  count(*)::text FROM news_address_mention
+    UNION ALL SELECT 'address mentions matched',  count(*) FILTER (WHERE listing_id IS NOT NULL)::text FROM news_address_mention
+    UNION ALL SELECT 'entities (people)',         count(*)::text FROM entity
+    UNION ALL SELECT 'place events',              count(*)::text FROM place_event
+    UNION ALL SELECT 'address submissions',       count(*)::text FROM address_submission
+    UNION ALL SELECT 'pending claim requests',    count(*)::text FROM claim_request WHERE status = 'pending'
+    UNION ALL SELECT 'sponsored placements',      count(*)::text FROM sponsored_placement
+  `;
+  const { rows } = await pool.query<CountRow>(sql);
+  return rows;
+}
+
+async function getSourceBreakdown() {
+  const { rows } = await pool.query<{ source: string; n: string; visible: string }>(`
+    SELECT
+      source,
+      count(*)::text as n,
+      count(*) FILTER (WHERE is_public)::text as visible
+    FROM listing
+    GROUP BY source
+    ORDER BY count(*) DESC
+  `);
+  return rows;
+}
+
+async function getRecentIngests() {
+  const { rows } = await pool.query<{ source: string; latest: string; n: string }>(`
+    SELECT
+      'listing/' || source as source,
+      max(updated_at)::text as latest,
+      count(*)::text as n
+    FROM listing
+    GROUP BY source
+    UNION ALL
+    SELECT 'permit/'||source_dataset, max(retrieved_at)::text, count(*)::text FROM permit GROUP BY source_dataset
+    UNION ALL
+    SELECT 'news_issue/'||paper_short, max(metadata_at)::text, count(*)::text FROM news_issue GROUP BY paper_short
+    ORDER BY 2 DESC NULLS LAST
+    LIMIT 20
+  `);
+  return rows;
+}
+
+const QUICK_LINKS = [
+  { href: '/admin/sources',  label: 'Source attribution',     hint: 'every dataset, last refresh, fake/seed flags' },
+  { href: '/admin/listings', label: 'Listing search + edit',  hint: 'find, hide, merge, fix addresses' },
+  { href: '/admin/ingests',  label: 'Run / monitor ingests',  hint: 'trigger LADBS, BH, Pasadena, OCR pulls' },
+  { href: '/admin/quality',  label: 'Data quality reports',   hint: 'orphan rows, missing source_url, geocoding gaps' },
+  { href: '/admin/claims',   label: 'Pending claims',         hint: 'review submitted claim_request rows' },
+  { href: '/admin/seeds',    label: 'Seed / fake data',       hint: 'review and remove demo content' },
+];
+
+export default async function AdminOverviewPage() {
+  const [overview, sources, recent] = await Promise.all([
+    getOverview(),
+    getSourceBreakdown(),
+    getRecentIngests(),
+  ]);
+
+  return (
+    <div className="space-y-12">
+      <section>
+        <h1 className="font-display text-4xl text-ink mb-2">Overview</h1>
+        <p className="text-sm text-ink/60">Live counts from <code>stayclaim</code> Postgres on localhost.</p>
+      </section>
+
+      <section>
+        <h2 className="font-display text-2xl text-ink mb-4">Counts</h2>
+        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
+          {overview.map(r => (
+            <div key={r.metric} className="border border-ink/10 bg-white p-4">
+              <p className="text-[10px] uppercase tracking-[0.15em] text-ink/50">{r.metric}</p>
+              <p className="font-display text-3xl text-ink tabular-nums mt-1">
+                {parseInt(r.n).toLocaleString()}
+              </p>
+            </div>
+          ))}
+        </div>
+      </section>
+
+      <section>
+        <h2 className="font-display text-2xl text-ink mb-4">Listings by source</h2>
+        <table className="w-full text-sm border border-ink/10 bg-white">
+          <thead className="bg-ink/[0.03] text-[10px] uppercase tracking-[0.15em] text-ink/55">
+            <tr>
+              <th className="text-left p-3">source</th>
+              <th className="text-right p-3">total</th>
+              <th className="text-right p-3">public</th>
+              <th className="text-right p-3">hidden</th>
+            </tr>
+          </thead>
+          <tbody>
+            {sources.map(s => {
+              const total = parseInt(s.n);
+              const visible = parseInt(s.visible);
+              const hidden = total - visible;
+              const isFake = s.source === 'manual';
+              return (
+                <tr key={s.source} className={`border-t border-ink/5 ${isFake ? 'bg-coral/5' : ''}`}>
+                  <td className="p-3 font-mono text-xs">
+                    {s.source}
+                    {isFake && <span className="ml-2 text-[10px] uppercase tracking-wider text-coral">SEED</span>}
+                  </td>
+                  <td className="p-3 text-right tabular-nums">{total.toLocaleString()}</td>
+                  <td className="p-3 text-right tabular-nums">{visible.toLocaleString()}</td>
+                  <td className="p-3 text-right tabular-nums text-ink/50">{hidden.toLocaleString()}</td>
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </section>
+
+      <section>
+        <h2 className="font-display text-2xl text-ink mb-4">Most-recent ingests</h2>
+        <table className="w-full text-sm border border-ink/10 bg-white">
+          <thead className="bg-ink/[0.03] text-[10px] uppercase tracking-[0.15em] text-ink/55">
+            <tr>
+              <th className="text-left p-3">source</th>
+              <th className="text-left p-3">last touched</th>
+              <th className="text-right p-3">rows</th>
+            </tr>
+          </thead>
+          <tbody>
+            {recent.map(r => (
+              <tr key={r.source} className="border-t border-ink/5">
+                <td className="p-3 font-mono text-xs">{r.source}</td>
+                <td className="p-3 text-ink/65">{r.latest?.slice(0, 19) ?? '—'}</td>
+                <td className="p-3 text-right tabular-nums">{parseInt(r.n).toLocaleString()}</td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </section>
+
+      <section>
+        <h2 className="font-display text-2xl text-ink mb-4">Admin tasks</h2>
+        <div className="grid md:grid-cols-2 gap-3">
+          {QUICK_LINKS.map(q => (
+            <Link
+              key={q.href}
+              href={q.href}
+              className="block border border-ink/10 bg-white p-5 hover:border-moss transition"
+            >
+              <p className="font-display text-lg text-ink">{q.label}</p>
+              <p className="text-xs text-ink/55 mt-1">{q.hint}</p>
+            </Link>
+          ))}
+        </div>
+      </section>
+    </div>
+  );
+}
diff --git a/src/app/admin/sources/page.tsx b/src/app/admin/sources/page.tsx
new file mode 100644
index 0000000..725b8af
--- /dev/null
+++ b/src/app/admin/sources/page.tsx
@@ -0,0 +1,222 @@
+import Link from 'next/link';
+import { pool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Source = {
+  key: string;
+  name: string;
+  category: 'permits' | 'parcels' | 'newspapers' | 'film' | 'civic' | 'archive';
+  url: string;
+  status: 'live' | 'partial' | 'paused' | 'walled' | 'paid' | 'dead';
+  rows: string;
+  notes: string;
+  ingestScript?: string;
+};
+
+/** Curated source registry — the canonical list. Keep this in sync with the
+ *  la-research-agent's source map at ~/.claude/agents/la-research-agent.md. */
+async function getRegistry(): Promise<Source[]> {
+  // counts from the DB where applicable
+  const counts = await pool.query<{ k: string; n: string }>(`
+    SELECT 'ladbs_permit' as k, count(*)::text as n FROM listing WHERE source='ladbs_permit'
+    UNION ALL SELECT 'pi9x-tg5x', count(*)::text FROM permit WHERE source_dataset='pi9x-tg5x'
+    UNION ALL SELECT 'dyxf-7hc4', count(*)::text FROM permit WHERE source_dataset='dyxf-7hc4'
+    UNION ALL SELECT 'e67z-kt2n', count(*)::text FROM permit WHERE source_dataset='e67z-kt2n'
+    UNION ALL SELECT 'weho_film_permit', count(*)::text FROM filming_location WHERE source_label LIKE '%WeHo%'
+    UNION ALL SELECT 'la_daily_herald', count(*)::text FROM news_issue WHERE paper_short='LADailyHerald'
+    UNION ALL SELECT 'la_times', count(*)::text FROM news_issue WHERE paper_short='LATimes'
+    UNION ALL SELECT 'pomona_progress', count(*)::text FROM news_issue WHERE paper_short='PomonaProgress'
+    UNION ALL SELECT 'santa_ana_register', count(*)::text FROM news_issue WHERE paper_short='SantaAnaRegister'
+  `);
+  const c = Object.fromEntries(counts.rows.map(r => [r.k, parseInt(r.n).toLocaleString()]));
+  const noVal = '—';
+
+  return [
+    // Permits — LA City
+    { key: 'pi9x-tg5x', name: 'LADBS Building Permits 2020+', category: 'permits',
+      url: 'https://data.lacity.org/resource/pi9x-tg5x.json',
+      status: 'live', rows: c['pi9x-tg5x'] ?? noVal, ingestScript: 'ingest-la-city-permits.ts',
+      notes: 'Daily refresh. Canonical. Replaces stale xnhu-aczu (dead 2023-05-19).' },
+    { key: 'dyxf-7hc4', name: 'LADBS Building Permits 2010-2019', category: 'permits',
+      url: 'https://data.lacity.org/resource/dyxf-7hc4.json',
+      status: 'live', rows: c['dyxf-7hc4'] ?? noVal, ingestScript: 'ingest-la-city-permits.ts',
+      notes: 'Static historical.' },
+    { key: 'e67z-kt2n', name: 'LADBS Building Permits <2010', category: 'permits',
+      url: 'https://data.lacity.org/resource/e67z-kt2n.json',
+      status: 'live', rows: c['e67z-kt2n'] ?? noVal, ingestScript: 'ingest-la-city-permits.ts',
+      notes: 'Static historical.' },
+    { key: 'u82d-eh7z', name: 'LADBS Code Enforcement Open', category: 'permits',
+      url: 'https://data.lacity.org/resource/u82d-eh7z.json',
+      status: 'partial', rows: '0 (pending)', notes: '28,931 expected. Ingester pending.' },
+    { key: 'rken-a55j', name: 'LADBS Code Enforcement Closed', category: 'permits',
+      url: 'https://data.lacity.org/resource/rken-a55j.json',
+      status: 'partial', rows: '0 (pending)', notes: '810,798 expected.' },
+
+    // Permits — adjacent cities
+    { key: 'bh-permits', name: 'Beverly Hills Issued Permits', category: 'permits',
+      url: 'https://opendata-hub.beverlyhills.org/datasets/847ebacb94a44b7b880f1c0a7296decf_0/about',
+      status: 'partial', rows: '8 (manual seed)', notes: 'ArcGIS Hub item 847ebacb…. Needs full ingester.' },
+    { key: 'pasadena-permits', name: 'Pasadena Permit Activity', category: 'permits',
+      url: 'https://data.cityofpasadena.net/datasets/24525f546e5e4346957bb55695afc448_0',
+      status: 'partial', rows: '0 (pending)', notes: 'ArcGIS FeatureServer; daily refresh.' },
+    { key: 'sm-permits', name: 'Santa Monica Active Permits', category: 'permits',
+      url: 'https://data.santamonica.gov/dataset/active-building-and-safety-permits',
+      status: 'partial', rows: '0 (pending)', notes: 'CKAN id d6867c7d…. ODC-BY 1.0.' },
+
+    // Parcels
+    { key: 'la-county-roll', name: 'LA County Assessor Roll 2021+', category: 'parcels',
+      url: 'https://data.lacounty.gov/datasets/lacounty::assessor-parcel-data-rolls-2021-present/about',
+      status: 'partial', rows: '0 (pending)', notes: '~2.7M parcels, 674 MB CSV.' },
+    { key: 'navigatela', name: 'NavigateLA ArcGIS REST', category: 'parcels',
+      url: 'https://maps.lacity.org/arcgis/rest/services/Mapping/NavigateLA/MapServer',
+      status: 'live', rows: '451 layers', notes: 'Layer 397=parcels, 71=zoning, 72/74=HCM, 75=HPOZ.' },
+
+    // Film
+    { key: 'weho-film', name: 'WeHo Film Permits xd5y-dzvf', category: 'film',
+      url: 'https://data.weho.org/Filming/WeHo-Filming-Activity/xd5y-dzvf',
+      status: 'live', rows: c['weho_film_permit'] ?? noVal, ingestScript: 'ingest-weho-film-permits.ts',
+      notes: '1,821 expected.' },
+    { key: 'filmla', name: 'FilmLA permits (LA City + County)', category: 'film',
+      url: 'https://www.filmla.com',
+      status: 'walled', rows: 'CPRA pending', notes: 'CPRA request submitted 2026-04-29; deadline 2026-05-09.' },
+
+    // Newspapers
+    { key: 'ia_la_daily_herald', name: 'LA Daily Herald (Internet Archive)', category: 'newspapers',
+      url: 'https://archive.org/details/newspaperarchive-los-angeles-daily-herald',
+      status: 'live', rows: c['la_daily_herald'] ?? noVal, ingestScript: 'pull-la-newspapers-ocr.ts',
+      notes: '2,408 issues 1873-1890. OCR pull active.' },
+    { key: 'ia_la_times', name: 'LA Times (Internet Archive)', category: 'newspapers',
+      url: 'https://archive.org/details/pub_los-angeles-times',
+      status: 'live', rows: c['la_times'] ?? noVal, ingestScript: 'pull-la-newspapers-ocr.ts',
+      notes: '17,298 issues 1881-1951. OCR pull active.' },
+    { key: 'ia_pomona', name: 'Pomona Progress Bulletin (IA)', category: 'newspapers',
+      url: 'https://archive.org/details/newspaperarchive-pomona-progress-bulletin',
+      status: 'partial', rows: c['pomona_progress'] ?? noVal,
+      notes: '3,065 issues; metadata only.' },
+    { key: 'ia_santa_ana', name: 'Santa Ana OC Register (IA)', category: 'newspapers',
+      url: 'https://archive.org/details/newspaperarchive-santa-ana-orange-county-register',
+      status: 'partial', rows: c['santa_ana_register'] ?? noVal,
+      notes: '339 issues; metadata only.' },
+    { key: 'cdnc', name: 'California Digital Newspaper Collection', category: 'newspapers',
+      url: 'https://cdnc.ucr.edu/',
+      status: 'walled', rows: '—', notes: 'Cloudflare anti-bot blocks programmatic ingest. LA Times + Herald-Examiner NOT here.' },
+
+    // Civic / records
+    { key: 'la_rrcc', name: 'LA County Recorder/Registrar (deeds)', category: 'civic',
+      url: 'https://www.lavote.gov/home/recorder/real-estate-records/faqs',
+      status: 'walled', rows: '—', notes: 'No free online deed-index search per Gov Code §6254.21.' },
+    { key: 'la_court', name: 'LA Superior Court Civil Index', category: 'civic',
+      url: 'https://www.lacourt.org/paos/v2public/CivilIndex/',
+      status: 'paid', rows: '—', notes: '$1-$4.75 per search; not bulk-ingestible.' },
+    { key: 'courtlistener', name: 'CourtListener / RECAP', category: 'civic',
+      url: 'https://www.courtlistener.com/help/api/rest/',
+      status: 'live', rows: '0 (not ingested)', notes: '~500M PACER objects via free API.' },
+
+    // Archives
+    { key: 'usc_diglib', name: 'USC Digital Library', category: 'archive',
+      url: 'https://digitallibrary.usc.edu/',
+      status: 'live', rows: '0', notes: '~2M images, OAI-PMH likely available.' },
+    { key: 'calisphere', name: 'UCLA via Calisphere API', category: 'archive',
+      url: 'https://calisphere.org/institution/247/',
+      status: 'live', rows: '0', notes: 'Open API.' },
+    { key: 'historicplacesla', name: 'HistoricPlacesLA (SurveyLA)', category: 'archive',
+      url: 'https://hpla.lacity.org/',
+      status: 'walled', rows: '—', notes: 'Arches v3, no public API. Use NavigateLA HCM/HPOZ layers instead.' },
+
+    // DEAD / DEPRECATED
+    { key: 'xnhu-aczu', name: 'LADBS xnhu-aczu (LEGACY)', category: 'permits',
+      url: 'https://data.lacity.org/resource/xnhu-aczu.json',
+      status: 'dead', rows: '—', notes: 'DO NOT USE. Last issue 2023-05-19. Replaced by pi9x-tg5x family.' },
+    { key: 'long_beach', name: 'Long Beach data portal', category: 'permits',
+      url: 'https://data.longbeach.gov/',
+      status: 'dead', rows: '—', notes: 'Only 13 datasets, no permits/parcels.' },
+    { key: 'culver_city', name: 'Culver City Socrata', category: 'permits',
+      url: 'https://data.culvercity.org/',
+      status: 'dead', rows: '—', notes: 'Redirects to ClearGov financial-only.' },
+  ];
+}
+
+const STATUS_STYLE: Record<Source['status'], string> = {
+  live:    'bg-moss text-sand',
+  partial: 'bg-coral/20 text-coral',
+  paused:  'bg-ink/10 text-ink/60',
+  walled:  'bg-yellow-200 text-ink',
+  paid:    'bg-yellow-300 text-ink',
+  dead:    'bg-ink text-sand',
+};
+
+export default async function AdminSourcesPage() {
+  const sources = await getRegistry();
+  const byCat: Record<string, Source[]> = {};
+  for (const s of sources) (byCat[s.category] ??= []).push(s);
+  const categoryOrder: Array<[string, string]> = [
+    ['permits', 'Building permits + code enforcement'],
+    ['parcels', 'Parcels + assessor'],
+    ['film', 'Film permits'],
+    ['newspapers', 'Newspapers (OCR)'],
+    ['civic', 'Civic / court records'],
+    ['archive', 'Archival / historical'],
+  ];
+
+  return (
+    <div className="space-y-10">
+      <section>
+        <h1 className="font-display text-4xl text-ink mb-2">Sources</h1>
+        <p className="text-sm text-ink/60 max-w-2xl">
+          Every dataset that feeds the archive — verified state, current row count, ingest script if any. Status colors:
+          <span className="mx-2 px-2 py-0.5 bg-moss text-sand text-[10px] uppercase tracking-wider">live</span>
+          <span className="mx-2 px-2 py-0.5 bg-coral/20 text-coral text-[10px] uppercase tracking-wider">partial</span>
+          <span className="mx-2 px-2 py-0.5 bg-yellow-200 text-ink text-[10px] uppercase tracking-wider">walled</span>
+          <span className="mx-2 px-2 py-0.5 bg-yellow-300 text-ink text-[10px] uppercase tracking-wider">paid</span>
+          <span className="mx-2 px-2 py-0.5 bg-ink text-sand text-[10px] uppercase tracking-wider">dead</span>
+        </p>
+      </section>
+
+      {categoryOrder.map(([cat, label]) => byCat[cat] && (
+        <section key={cat}>
+          <h2 className="font-display text-2xl text-ink mb-3">{label}</h2>
+          <table className="w-full text-sm border border-ink/10 bg-white">
+            <thead className="bg-ink/[0.03] text-[10px] uppercase tracking-[0.15em] text-ink/55">
+              <tr>
+                <th className="text-left p-3">source</th>
+                <th className="text-left p-3">status</th>
+                <th className="text-right p-3">rows</th>
+                <th className="text-left p-3">ingest</th>
+                <th className="text-left p-3">notes</th>
+              </tr>
+            </thead>
+            <tbody>
+              {byCat[cat]!.map(s => (
+                <tr key={s.key} className="border-t border-ink/5 align-top">
+                  <td className="p-3">
+                    <a href={s.url} target="_blank" rel="noreferrer noopener"
+                       className="font-display text-base text-ink hover:text-coral transition">
+                      {s.name} ↗
+                    </a>
+                    <p className="font-mono text-[10px] text-ink/40 mt-0.5">{s.key}</p>
+                  </td>
+                  <td className="p-3">
+                    <span className={`text-[10px] uppercase tracking-wider px-2 py-0.5 ${STATUS_STYLE[s.status]}`}>
+                      {s.status}
+                    </span>
+                  </td>
+                  <td className="p-3 text-right tabular-nums font-mono text-xs">{s.rows}</td>
+                  <td className="p-3 font-mono text-[11px] text-ink/65">
+                    {s.ingestScript ?? <span className="text-ink/30">—</span>}
+                  </td>
+                  <td className="p-3 text-xs text-ink/65 leading-relaxed">{s.notes}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </section>
+      ))}
+
+      <p className="text-xs text-ink/40 max-w-prose leading-relaxed">
+        Source registry is hand-curated; keep in sync with <code>~/.claude/agents/la-research-agent.md</code>.
+        Verify any new dataset slug with the LA Research Agent before adding here — Steve has been burned by stale Socrata IDs.
+      </p>
+    </div>
+  );
+}
diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts
new file mode 100644
index 0000000..21bf846
--- /dev/null
+++ b/src/app/api/events/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from 'next/server';
+import { getArchiveTimeline } from '@/lib/db';
+
+/**
+ * GET /api/events?kind=permit&decade=2020s&city=Beverly+Hills
+ * Public read-only feed. Tier-D never returned. Cache-friendly.
+ */
+export async function GET(req: Request) {
+  const url = new URL(req.url);
+  const kind = url.searchParams.get('kind') ?? undefined;
+  const decade = url.searchParams.get('decade') ?? undefined;
+  const city = url.searchParams.get('city') ?? undefined;
+  const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') ?? 100)));
+
+  const events = await getArchiveTimeline({ kind, decade, city, limit });
+  // Trim shape — API consumers don't need internal IDs.
+  const out = events
+    .filter(e => e.source_tier !== 'D')
+    .map(e => ({
+      kind: e.kind,
+      summary: e.summary,
+      valid_from: e.valid_from,
+      valid_to: e.valid_to,
+      recorded_at: e.recorded_at,
+      source_tier: e.source_tier,
+      source_label: e.source_label,
+      source_url: e.source_url,
+      confidence: e.confidence,
+      address: {
+        slug: e.listing_slug,
+        title: e.listing_title,
+        city: e.listing_city,
+      },
+    }));
+
+  return NextResponse.json(
+    {
+      generated_at: new Date().toISOString(),
+      count: out.length,
+      filters: { kind, decade, city, limit },
+      events: out,
+    },
+    {
+      headers: {
+        'Cache-Control': 'public, max-age=300, s-maxage=900',
+      },
+    }
+  );
+}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
new file mode 100644
index 0000000..7a58ff7
--- /dev/null
+++ b/src/app/api/health/route.ts
@@ -0,0 +1,14 @@
+/**
+ * DEPRECATED 2026-05-04: this path /api/health is nginx-routed to Site Factory
+ * on Kamatera (per `feedback_pastdoor_route_collisions.md`). The real handler
+ * lives at /api/pastdoor-health/route.ts. Returning a 410 here so any local
+ * dev (where nginx isn't in the way) hitting the old path notices.
+ */
+import { NextResponse } from 'next/server';
+
+export async function GET() {
+  return NextResponse.json({
+    error: 'gone',
+    message: '/api/health was renamed to /api/pastdoor-health (route-prefix collision with Site Factory). Update your client.',
+  }, { status: 410 });
+}
diff --git a/src/app/api/la-records/lookup/route.ts b/src/app/api/la-records/lookup/route.ts
new file mode 100644
index 0000000..76552da
--- /dev/null
+++ b/src/app/api/la-records/lookup/route.ts
@@ -0,0 +1,46 @@
+/**
+ * Live LA County records lookup endpoint.
+ *
+ * Used by the /submit form to show a real-time preview of what we know about
+ * a typed address — year built, sqft, lot size, assessed value — so the user
+ * sees product value before they hit submit.
+ *
+ * Server-side wrapper around lib/la-arcgis.ts. Cache-first via la_parcel table.
+ */
+import { NextResponse } from 'next/server';
+import { getOrFetchParcelForAddress } from '@/lib/la-arcgis';
+
+export const runtime = 'nodejs';
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const q = searchParams.get('q')?.trim();
+  if (!q || q.length < 6) {
+    return NextResponse.json({ ok: false, error: 'query too short' }, { status: 400 });
+  }
+  try {
+    const parcel = await getOrFetchParcelForAddress(q, null);
+    if (!parcel) {
+      return NextResponse.json({ ok: true, found: false });
+    }
+    return NextResponse.json({
+      ok: true,
+      found: true,
+      apn: parcel.apn,
+      situs_address: parcel.situs_address,
+      city: parcel.city,
+      zip: parcel.zip,
+      year_built: parcel.year_built,
+      sqft_main: parcel.sqft_main,
+      bedrooms: parcel.bedrooms,
+      bathrooms: parcel.bathrooms,
+      use_description: parcel.use_description,
+      total_value: parcel.total_value,
+      roll_year: parcel.roll_year,
+      lat: parcel.lat,
+      lng: parcel.lng,
+    });
+  } catch (e) {
+    return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : String(e) }, { status: 500 });
+  }
+}
diff --git a/src/app/api/pastdoor-health/route.ts b/src/app/api/pastdoor-health/route.ts
new file mode 100644
index 0000000..1ad3ba5
--- /dev/null
+++ b/src/app/api/pastdoor-health/route.ts
@@ -0,0 +1,44 @@
+/**
+ * /api/pastdoor-health — liveness check + per-host smoke test surface.
+ *
+ * Was at /api/health but renamed 2026-05-04: nginx on Kamatera routes
+ * /api/health/* to Site Factory (per `feedback_pastdoor_route_collisions.md`).
+ * The old path was unreachable — every health probe was hitting Site Factory.
+ *
+ * Update deploy-pastdoor.sh and smoke-test.sh to call this URL.
+ */
+import { NextResponse } from 'next/server';
+import { headers } from 'next/headers';
+import { siteForHost } from '@/lib/site';
+import { pool } from '@/lib/db';
+
+export async function GET() {
+  const h = await headers();
+  const host = h.get('x-pastdoor-host') || h.get('host');
+  const surface = siteForHost(host);
+
+  let dbOk = false;
+  let dbLatencyMs = -1;
+  try {
+    const t0 = Date.now();
+    await pool.query('SELECT 1');
+    dbLatencyMs = Date.now() - t0;
+    dbOk = true;
+  } catch {
+    dbOk = false;
+  }
+
+  return NextResponse.json({
+    ok: dbOk,
+    host,
+    surface: {
+      key: surface.key,
+      domain: surface.domain,
+      brand: surface.brandName,
+    },
+    db: { ok: dbOk, latency_ms: dbLatencyMs },
+    node: process.version,
+    uptime_s: Math.round(process.uptime()),
+    timestamp: new Date().toISOString(),
+  });
+}
diff --git a/src/app/api/promote/route.ts b/src/app/api/promote/route.ts
new file mode 100644
index 0000000..22f8511
--- /dev/null
+++ b/src/app/api/promote/route.ts
@@ -0,0 +1,65 @@
+/**
+ * POST /api/promote — sponsored placement intake.
+ *
+ * Stores the submission as `pending`. We email-verify and confirm
+ * affiliation before flipping to `approved`, then `active` after
+ * Stripe checkout (Stripe wiring lives behind the RAK that's pending).
+ *
+ * NOTE: this route is allowed to import from sponsored/* per
+ * .eslintrc.json overrides (api routes are firewall-aware glue).
+ */
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+import { pool } from '@/lib/db';
+import { FREE_FOREVER } from '@/lib/flags';
+
+// Soft-disabled when FREE_FOREVER=true. Returns 410 Gone.
+async function checkRetired(): Promise<NextResponse | null> {
+  if (FREE_FOREVER) {
+    return NextResponse.json(
+      { error: 'retired', message: 'Promotion has been retired. The archive is free forever — no paid placements.' },
+      { status: 410 }
+    );
+  }
+  return null;
+}
+
+const Body = z.object({
+  listing_id: z.string().uuid(),
+  source: z.enum(['zillow', 'redfin', 'airbnb', 'other']),
+  external_url: z.string().url(),
+  headline: z.string().min(1).max(200),
+  price_label: z.string().max(80).optional(),
+  contact_email: z.string().email(),
+  affiliation: z.string().min(10).max(1000),
+  permit_number: z.string().max(80).optional(),
+});
+
+export async function POST(req: Request) {
+  const retired = await checkRetired();
+  if (retired) return retired;
+
+  let parsed;
+  try {
+    parsed = Body.parse(await req.json());
+  } catch (e) {
+    return NextResponse.json({ error: 'invalid body', detail: String(e) }, { status: 400 });
+  }
+  const r = await pool.query(
+    `INSERT INTO sponsored_placement
+     (listing_id, source, external_url, headline, price_label, contact_email, affiliation, permit_number)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+     RETURNING id, status, submitted_at`,
+    [
+      parsed.listing_id,
+      parsed.source,
+      parsed.external_url,
+      parsed.headline,
+      parsed.price_label ?? null,
+      parsed.contact_email,
+      parsed.affiliation,
+      parsed.permit_number ?? null,
+    ]
+  );
+  return NextResponse.json(r.rows[0], { status: 201 });
+}
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 0000000..164868e
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from 'next/server';
+import { pool } from '@/lib/db';
+
+/**
+ * GET /api/search?q=...
+ * Returns up to 6 listing matches + 4 entity matches.
+ * Uses Postgres tsvector for listings (search_doc) and a plain ILIKE
+ * for entity display_name (small table; index isn't worth it yet).
+ */
+export async function GET(req: Request) {
+  const url = new URL(req.url);
+  const q = (url.searchParams.get('q') ?? '').trim();
+  if (q.length < 2) return NextResponse.json({ listings: [], entities: [] });
+
+  const [listings, entities] = await Promise.all([
+    pool.query(
+      `SELECT slug, title, city, state, address_line1, hero_image,
+              ts_rank(search_doc, websearch_to_tsquery('english', $1)) AS rank
+       FROM listing
+       WHERE is_public = true
+         AND (search_doc @@ websearch_to_tsquery('english', $1)
+              OR title ILIKE $2
+              OR address_line1 ILIKE $2)
+       ORDER BY rank DESC NULLS LAST, ingested_at DESC
+       LIMIT 6`,
+      [q, `%${q}%`]
+    ),
+    pool.query(
+      `SELECT slug, display_name, kind, birth_year, death_year
+       FROM entity
+       WHERE display_name ILIKE $1
+       ORDER BY display_name
+       LIMIT 4`,
+      [`%${q}%`]
+    ),
+  ]);
+
+  return NextResponse.json({ listings: listings.rows, entities: entities.rows });
+}
diff --git a/src/app/api/stars/nearby/route.ts b/src/app/api/stars/nearby/route.ts
new file mode 100644
index 0000000..1b13991
--- /dev/null
+++ b/src/app/api/stars/nearby/route.ts
@@ -0,0 +1,130 @@
+/**
+ * /api/stars/nearby?lat=&lng=&radius= — server-side haversine query
+ *
+ * Returns Walk of Fame stars + historical residences + active restaurants
+ * within `radius` meters of the given lat/lng, sorted nearest first. Cap 60.
+ *
+ * Used by /stars/nearby client component (geolocation flow).
+ *
+ * Hardening notes:
+ *  - All four numerics ($1=lat, $2=lng, $3=radius, $4=earth_m) are bound params,
+ *    no SQL template-literal interpolation
+ *  - lat/lng must fall inside the LA bounding box (32–35 N, 116–120 W) — every
+ *    legitimate user is here, and a wider value would scan all 3 tables
+ *  - res CTE deduped via DISTINCT ON to collapse legacy + new seed dupes
+ *  - rst CTE filters closed_year IS NULL (active restaurants only)
+ */
+import { NextResponse } from 'next/server';
+import { pool } from '@/lib/db';
+
+export const runtime = 'nodejs';
+
+const EARTH_M = 6371000;
+
+// LA-area bbox — anything outside is either an error or an attacker probing
+const LAT_MIN = 32, LAT_MAX = 35;
+const LNG_MIN = -120, LNG_MAX = -116;
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const lat = Number(searchParams.get('lat'));
+  const lng = Number(searchParams.get('lng'));
+  const radius = Math.min(Number(searchParams.get('radius')) || 1609, 50000);
+  if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+    return NextResponse.json({ ok: false, error: 'lat/lng required' }, { status: 400 });
+  }
+  if (lat < LAT_MIN || lat > LAT_MAX || lng < LNG_MIN || lng > LNG_MAX) {
+    return NextResponse.json(
+      { ok: false, error: 'lat/lng outside LA service area' },
+      { status: 400 }
+    );
+  }
+
+  // Haversine via a SQL CTE that unions all three data sources.
+  // EARTH_M passed as $4 — no SQL interpolation anywhere.
+  const { rows } = await pool.query<{
+    kind: 'walk' | 'residence' | 'restaurant';
+    name: string;
+    subtitle: string;
+    slug: string;
+    lat: number;
+    lng: number;
+    distance_m: number;
+  }>(`
+    WITH params AS (
+      SELECT $1::float AS lat, $2::float AS lng,
+             $3::float AS radius_m, $4::float AS earth_m
+    ),
+    walk AS (
+      SELECT 'walk'::text AS kind,
+             w.honoree_name AS name,
+             COALESCE(w.category::text, '') || ' · ' || COALESCE(w.address, '') AS subtitle,
+             w.honoree_slug AS slug,
+             w.lat::float AS lat, w.lng::float AS lng,
+             p.earth_m * 2 * asin(sqrt(
+               sin(radians((w.lat - p.lat)/2))^2 +
+               cos(radians(p.lat)) * cos(radians(w.lat)) *
+               sin(radians((w.lng - p.lng)/2))^2
+             )) AS distance_m
+      FROM walk_of_fame w, params p
+      WHERE w.lat IS NOT NULL AND w.lng IS NOT NULL
+        AND w.lat BETWEEN p.lat - (p.radius_m / 111000.0) AND p.lat + (p.radius_m / 111000.0)
+        AND w.lng BETWEEN p.lng - (p.radius_m / 88000.0)  AND p.lng + (p.radius_m / 88000.0)
+    ),
+    res AS (
+      SELECT DISTINCT ON (epa.entity_id, epa.listing_id)
+             'residence'::text AS kind,
+             e.display_name AS name,
+             COALESCE(l.title, '') || ', ' || COALESCE(l.city, '') AS subtitle,
+             l.slug AS slug,
+             l.latitude::float AS lat, l.longitude::float AS lng,
+             p.earth_m * 2 * asin(sqrt(
+               sin(radians((l.latitude - p.lat)/2))^2 +
+               cos(radians(p.lat)) * cos(radians(l.latitude)) *
+               sin(radians((l.longitude - p.lng)/2))^2
+             )) AS distance_m
+      FROM entity_place_association epa
+      JOIN entity e ON e.id = epa.entity_id
+      JOIN listing l ON l.id = epa.listing_id, params p
+      WHERE epa.public_visible = true
+        AND l.is_public = true
+        AND l.latitude IS NOT NULL AND l.longitude IS NOT NULL
+        -- bbox pre-filter (radius_m / 111000 degrees) before haversine
+        AND l.latitude  BETWEEN p.lat - (p.radius_m / 111000.0) AND p.lat + (p.radius_m / 111000.0)
+        AND l.longitude BETWEEN p.lng - (p.radius_m / 88000.0)  AND p.lng + (p.radius_m / 88000.0)
+      ORDER BY epa.entity_id, epa.listing_id, epa.created_at DESC NULLS LAST
+    ),
+    rst AS (
+      SELECT 'restaurant'::text AS kind,
+             r.name AS name,
+             COALESCE(r.cuisine, 'Restaurant') || ' · ' ||
+               COALESCE(sc.cnt::text, '0') || ' sightings' AS subtitle,
+             r.slug AS slug,
+             r.lat::float AS lat, r.lng::float AS lng,
+             p.earth_m * 2 * asin(sqrt(
+               sin(radians((r.lat - p.lat)/2))^2 +
+               cos(radians(p.lat)) * cos(radians(r.lat)) *
+               sin(radians((r.lng - p.lng)/2))^2
+             )) AS distance_m
+      FROM restaurant r
+      LEFT JOIN LATERAL (
+        SELECT COUNT(*) AS cnt FROM restaurant_sighting s
+        WHERE s.restaurant_id = r.id AND s.public_visible = true
+      ) sc ON true
+      , params p
+      WHERE r.lat IS NOT NULL AND r.lng IS NOT NULL
+        AND r.closed_year IS NULL
+        AND r.lat BETWEEN p.lat - (p.radius_m / 111000.0) AND p.lat + (p.radius_m / 111000.0)
+        AND r.lng BETWEEN p.lng - (p.radius_m / 88000.0)  AND p.lng + (p.radius_m / 88000.0)
+    )
+    SELECT * FROM walk WHERE distance_m <= (SELECT radius_m FROM params)
+    UNION ALL
+    SELECT * FROM res  WHERE distance_m <= (SELECT radius_m FROM params)
+    UNION ALL
+    SELECT * FROM rst  WHERE distance_m <= (SELECT radius_m FROM params)
+    ORDER BY distance_m ASC
+    LIMIT 60
+  `, [lat, lng, radius, EARTH_M]);
+
+  return NextResponse.json({ ok: true, stars: rows });
+}
diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts
new file mode 100644
index 0000000..f1fc65c
--- /dev/null
+++ b/src/app/api/submit/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+import { pool } from '@/lib/db';
+
+const Body = z.object({
+  address_line1: z.string().min(3).max(255),
+  city: z.string().min(2).max(120),
+  state: z.string().min(2).max(2).transform(s => s.toUpperCase()),
+  postal_code: z.string().max(12).optional().or(z.literal('')),
+  notes: z.string().max(2000).optional().or(z.literal('')),
+  contact_email: z.string().email(),
+});
+
+export async function POST(req: Request) {
+  let parsed;
+  try {
+    parsed = Body.parse(await req.json());
+  } catch (e) {
+    return NextResponse.json({ error: 'invalid body', detail: String(e) }, { status: 400 });
+  }
+  const r = await pool.query(
+    `INSERT INTO address_submission
+     (address_line1, city, state, postal_code, notes, contact_email)
+     VALUES ($1,$2,$3,$4,$5,$6)
+     RETURNING id, status, submitted_at`,
+    [
+      parsed.address_line1,
+      parsed.city,
+      parsed.state,
+      parsed.postal_code || null,
+      parsed.notes || null,
+      parsed.contact_email,
+    ]
+  );
+  return NextResponse.json(r.rows[0], { status: 201 });
+}
diff --git a/src/app/architects/page.tsx b/src/app/architects/page.tsx
new file mode 100644
index 0000000..ab15454
--- /dev/null
+++ b/src/app/architects/page.tsx
@@ -0,0 +1,83 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { listEntitiesWithCounts } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { CrossPromo } from '@/components/CrossPromo';
+import { currentSurface } from '@/lib/site';
+
+export const metadata: Metadata = {
+  title: 'Architects',
+  description: 'Architects and studios in the archive — addresses they designed, in Beverly Hills and outward.',
+  alternates: { canonical: '/architects' },
+};
+
+export default async function ArchitectsIndex() {
+  const surface = await currentSurface();
+  const studios = await listEntitiesWithCounts('studio');
+  const architects = await listEntitiesWithCounts('architect');
+  const all = [...architects, ...studios].sort((a, b) => b.oeuvre_count - a.oeuvre_count);
+
+  return (
+    <>
+      <div className="max-w-4xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Architects' }]} />
+      </div>
+
+      <header className="max-w-4xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Architects + studios</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Who built it.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Every architect or studio with a verified work in the archive. Click to see their oeuvre on a map.
+        </p>
+      </header>
+
+      <section className="max-w-4xl mx-auto px-6 py-10">
+        {all.length === 0 ? (
+          <p className="text-sm italic text-ink/55">
+            No architects with a verified work in the archive yet.
+          </p>
+        ) : (
+          <ul className="divide-y divide-ink/10">
+            {all.map(e => (
+              <li key={e.id}>
+                <Link
+                  href={`/entity/${e.slug}`}
+                  className="grid grid-cols-[1fr_auto] gap-4 items-baseline py-5 hover:text-coral transition group"
+                >
+                  <div>
+                    <h2 className="font-display text-2xl text-ink group-hover:text-coral tracking-[-0.01em]">
+                      {e.display_name}
+                    </h2>
+                    {e.bio_short && (
+                      <p className="mt-1 text-sm text-ink/65 leading-relaxed max-w-prose">
+                        {e.bio_short}
+                      </p>
+                    )}
+                  </div>
+                  <div className="text-right">
+                    <p className="font-mono text-xs uppercase tracking-[0.15em] text-ink/50">
+                      {e.kind}
+                    </p>
+                    <p className="font-mono text-xs text-ink/45">
+                      {e.birth_year ?? '?'}–{e.death_year ?? '?'}
+                    </p>
+                    <p className="mt-1 font-display text-2xl text-ink/70 group-hover:text-coral">
+                      {e.oeuvre_count}{' '}
+                      <span className="font-sans text-[10px] uppercase tracking-[0.15em] text-ink/40">
+                        {e.oeuvre_count === 1 ? 'work' : 'works'}
+                      </span>
+                    </p>
+                  </div>
+                </Link>
+              </li>
+            ))}
+          </ul>
+        )}
+      </section>
+
+      <CrossPromo current={surface.key} />
+    </>
+  );
+}
diff --git a/src/app/browse/page.tsx b/src/app/browse/page.tsx
new file mode 100644
index 0000000..60bff13
--- /dev/null
+++ b/src/app/browse/page.tsx
@@ -0,0 +1,123 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { searchListings } from '@/lib/db';
+import { NodeCard } from '@/components/NodeCard';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+
+const CITIES = ['Beverly Hills', 'Los Angeles', 'Malibu', 'Joshua Tree', 'Big Bear Lake'];
+
+// Index the bare /browse page; noindex any filtered/searched variant so we
+// don't pollute the index with arbitrary query-string URLs.
+export async function generateMetadata({
+  searchParams,
+}: {
+  searchParams: { q?: string; city?: string };
+}): Promise<Metadata> {
+  const filtered = !!(searchParams.q || searchParams.city);
+  return {
+    title: 'Browse the archive',
+    alternates: { canonical: '/browse' },
+    robots: filtered
+      ? { index: false, follow: true }
+      : { index: true, follow: true },
+  };
+}
+
+export default async function BrowsePage({
+  searchParams,
+}: {
+  searchParams: { q?: string; city?: string };
+}) {
+  const city = searchParams.city;
+  const q = (searchParams.q ?? '').trim();
+  const filtered = await searchListings({ text: q || undefined, city, limit: 60 });
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive' }, { label: 'Browse' }]} />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Browse the archive</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Every address, recorded.
+        </h1>
+        <p className="mt-3 font-display italic text-xl text-ink/70 max-w-prose">
+          Beverly Hills first. The rest of Los Angeles County, then westward.
+        </p>
+
+        <form className="mt-8 flex flex-wrap items-end gap-3" action="/browse">
+          <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/50">
+            Address or keyword
+            <input
+              type="search"
+              name="q"
+              defaultValue={searchParams.q ?? ''}
+              placeholder="Roxbury · Tudor · 1928…"
+              className="w-72 border border-ink/20 bg-sand px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss"
+            />
+          </label>
+          <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/50">
+            Neighborhood
+            <select
+              name="city"
+              defaultValue={city ?? ''}
+              className="border border-ink/20 bg-sand px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss"
+            >
+              <option value="">All</option>
+              {CITIES.map(c => (
+                <option key={c} value={c}>
+                  {c}
+                </option>
+              ))}
+            </select>
+          </label>
+          <button
+            type="submit"
+            className="bg-ink text-sand px-5 py-2 text-xs uppercase tracking-wider hover:bg-moss transition"
+          >
+            Search the archive
+          </button>
+          {(q || city) && (
+            <Link href="/browse" className="text-xs uppercase tracking-wider text-ink/50 hover:text-coral transition">
+              Reset
+            </Link>
+          )}
+        </form>
+      </header>
+
+      <section className="max-w-6xl mx-auto px-6 py-10">
+        <div className="flex items-baseline justify-between mb-6">
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">
+            {filtered.length} {filtered.length === 1 ? 'address' : 'addresses'}
+            {city ? ` in ${city}` : ''}
+          </h2>
+          <span className="text-[10px] uppercase tracking-[0.15em] text-ink/40">
+            ordered by ingest date
+          </span>
+        </div>
+
+        {filtered.length === 0 ? (
+          <p className="text-sm text-ink/50 italic py-10">
+            No addresses match. Try a different city or clear the search.
+          </p>
+        ) : (
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
+            {filtered.map(l => (
+              <NodeCard
+                key={l.id}
+                kind="address"
+                href={`/address/${l.slug}`}
+                title={l.title}
+                subtitle={l.address_line1 ?? l.city ?? undefined}
+                imageUrl={l.hero_image ?? undefined}
+                eyebrow={l.property_type ?? 'Address'}
+              />
+            ))}
+          </div>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/claim/[slug]/page.tsx b/src/app/claim/[slug]/page.tsx
new file mode 100644
index 0000000..3a0ddc9
--- /dev/null
+++ b/src/app/claim/[slug]/page.tsx
@@ -0,0 +1,71 @@
+import { notFound } from 'next/navigation';
+import type { Metadata } from 'next';
+import { getListingBySlug } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+
+export const metadata: Metadata = {
+  robots: { index: false, follow: false },
+};
+
+export default async function ClaimPage({ params }: { params: { slug: string } }) {
+  const listing = await getListingBySlug(params.slug);
+  if (!listing) notFound();
+
+  return (
+    <>
+      <div className="max-w-2xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive
+          items={[
+            { label: 'Archive', href: '/browse' },
+            { label: listing.title, href: `/address/${listing.slug}` },
+            { label: 'Claim' },
+          ]}
+        />
+      </div>
+
+      <section className="max-w-2xl mx-auto px-6 py-10">
+        <h1 className="font-display text-5xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Claim {listing.title}
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70">
+          Confirm ownership to add photos, contact info, and a booking link.
+        </p>
+
+        <form className="mt-8 grid gap-4">
+          <label className="flex flex-col gap-1 text-xs uppercase tracking-wider text-ink/60">
+            Your email
+            <input
+              type="email"
+              required
+              name="email"
+              className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss"
+              placeholder="you@example.com"
+            />
+          </label>
+          <label className="flex flex-col gap-1 text-xs uppercase tracking-wider text-ink/60">
+            Proof of ownership
+            <textarea
+              name="proof"
+              rows={4}
+              className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss"
+              placeholder="Recent utility bill, deed snippet, or grant book/page reference. Notarized upload also accepted."
+            />
+          </label>
+          <button
+            type="submit"
+            className="bg-moss text-sand px-5 py-3 text-xs uppercase tracking-wider hover:bg-ink transition"
+          >
+            Submit claim for review
+          </button>
+        </form>
+
+        <p className="mt-10 text-xs text-ink/40 leading-relaxed">
+          Free forever — no subscription, no upgrade tier. We&rsquo;ll email you to confirm the address
+          matches on public record. Claims that affect historical associations require additional
+          editorial review — you can edit your address copy and photos freely; the historical ledger
+          stays independent.
+        </p>
+      </section>
+    </>
+  );
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..22082c5
--- /dev/null
+++ b/src/app/dashboard/page.tsx
@@ -0,0 +1,142 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { getOwnerByEmail, getListingsForOwner, getPendingClaimsForEmail } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { NodeCard } from '@/components/NodeCard';
+
+export const metadata: Metadata = {
+  robots: { index: false, follow: false },
+};
+
+/**
+ * Owner dashboard. Authentication is stubbed for v0.1: ?email=... query
+ * acts as the session. Real auth lands when the email-verification claim
+ * flow ships (token in claim_request.token).
+ */
+export default async function DashboardPage({
+  searchParams,
+}: {
+  searchParams: { email?: string };
+}) {
+  const email = searchParams.email?.trim();
+
+  if (!email) {
+    return (
+      <>
+        <div className="max-w-2xl mx-auto px-6 pt-6">
+          <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Dashboard' }]} />
+        </div>
+        <section className="max-w-2xl mx-auto px-6 py-12">
+          <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Owner dashboard</p>
+          <h1 className="font-display text-5xl text-ink tracking-[-0.02em] leading-[1.02]">
+            Sign in to your archive.
+          </h1>
+          <p className="mt-4 font-display italic text-xl text-ink/70">
+            v0.1 stub: enter the email you used when you claimed an address. Magic-link auth lands soon.
+          </p>
+          <form className="mt-8 grid gap-4 max-w-md">
+            <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+              Email
+              <input
+                type="email"
+                name="email"
+                required
+                className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+              />
+            </label>
+            <button
+              type="submit"
+              className="bg-moss text-sand px-5 py-2 text-xs uppercase tracking-wider hover:bg-ink transition self-start"
+            >
+              Open dashboard
+            </button>
+          </form>
+        </section>
+      </>
+    );
+  }
+
+  const [owner, claims] = await Promise.all([
+    getOwnerByEmail(email),
+    getPendingClaimsForEmail(email),
+  ]);
+  const listings = owner ? await getListingsForOwner(owner.id) : [];
+
+  return (
+    <>
+      <div className="max-w-5xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Dashboard' }]} />
+      </div>
+
+      <header className="max-w-5xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">
+          {owner?.name ?? 'Owner'} · {email}
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Your addresses.
+        </h1>
+        <p className="mt-3 font-display italic text-xl text-ink/70">
+          Edit photos, copy, and contact info. The historical record stays independent.
+        </p>
+      </header>
+
+      <section className="max-w-5xl mx-auto px-6 py-10">
+        <div className="flex items-baseline justify-between mb-6">
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Claimed</h2>
+          <Link
+            href="/browse"
+            className="text-xs uppercase tracking-wider text-ink/60 hover:text-coral transition"
+          >
+            Find another address →
+          </Link>
+        </div>
+        {listings.length === 0 ? (
+          <p className="text-sm text-ink/60 italic">
+            No claimed addresses yet. Find one in the{' '}
+            <Link href="/browse" className="underline-offset-2 hover:underline text-coral">
+              archive
+            </Link>{' '}
+            and claim it for free.
+          </p>
+        ) : (
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
+            {listings.map(l => (
+              <NodeCard
+                key={l.id}
+                kind="address"
+                href={`/address/${l.slug}`}
+                title={l.title}
+                subtitle={l.address_line1 ?? l.city ?? undefined}
+                imageUrl={l.hero_image ?? undefined}
+                eyebrow={`Claimed · ${l.tier}`}
+              />
+            ))}
+          </div>
+        )}
+      </section>
+
+      <section className="max-w-5xl mx-auto px-6 py-10">
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-6">Pending claims</h2>
+        {claims.length === 0 ? (
+          <p className="text-sm text-ink/60 italic">No pending claims.</p>
+        ) : (
+          <ul className="divide-y divide-ink/10">
+            {claims.map(c => (
+              <li key={c.id} className="py-4 flex items-baseline justify-between gap-3 flex-wrap">
+                <Link
+                  href={`/address/${c.listing_slug}`}
+                  className="font-display text-2xl text-ink hover:text-coral transition"
+                >
+                  {c.listing_title}
+                </Link>
+                <span className="font-mono text-xs uppercase tracking-wider text-ink/50">
+                  {c.status} · {new Date(c.created_at).toISOString().slice(0, 10)}
+                </span>
+              </li>
+            ))}
+          </ul>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/entity/[slug]/opengraph-image.tsx b/src/app/entity/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..0682179
--- /dev/null
+++ b/src/app/entity/[slug]/opengraph-image.tsx
@@ -0,0 +1,91 @@
+import { ImageResponse } from 'next/og';
+import { headers } from 'next/headers';
+import { getEntityBySlug } from '@/lib/db';
+import { siteForHost } from '@/lib/site';
+
+export const runtime = 'nodejs';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default async function og({ params }: { params: { slug: string } }) {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const entity = await getEntityBySlug(params.slug);
+  const name = entity?.display_name ?? 'Entity';
+  const vitals = entity
+    ? [entity.birth_year ?? '?', entity.death_year ?? '?'].join('–')
+    : '';
+  const kindLabel =
+    entity?.kind === 'architect'
+      ? 'Architect'
+      : entity?.kind === 'studio'
+        ? 'Studio'
+        : entity?.kind === 'business'
+          ? 'Business'
+          : 'Person';
+
+  return new ImageResponse(
+    (
+      <div
+        style={{
+          width: '100%',
+          height: '100%',
+          display: 'flex',
+          flexDirection: 'column',
+          justifyContent: 'space-between',
+          padding: '64px',
+          background: '#1a1410',
+          color: '#f6f1e8',
+          fontFamily: 'Georgia, serif',
+        }}
+      >
+        <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, fontSize: 24 }}>
+          <span style={{ fontFamily: 'Georgia, serif' }}>{surface.brandName}</span>
+          <span style={{ color: '#d4664a' }}>·</span>
+          <span style={{ fontSize: 16, letterSpacing: 2, textTransform: 'uppercase', color: 'rgba(246,241,232,0.55)' }}>
+            {kindLabel}
+          </span>
+        </div>
+        <div style={{ display: 'flex', flexDirection: 'column' }}>
+          <div
+            style={{
+              fontSize: 32,
+              letterSpacing: 4,
+              textTransform: 'uppercase',
+              color: 'rgba(246,241,232,0.55)',
+              marginBottom: 24,
+            }}
+          >
+            {vitals}
+          </div>
+          <div
+            style={{
+              fontSize: 112,
+              lineHeight: 1.02,
+              letterSpacing: '-0.02em',
+              color: '#f6f1e8',
+              maxWidth: '92%',
+            }}
+          >
+            {name}
+          </div>
+        </div>
+        <div
+          style={{
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'baseline',
+            fontSize: 18,
+            color: 'rgba(246,241,232,0.55)',
+          }}
+        >
+          <span>Historical associations only.</span>
+          <span style={{ fontFamily: 'Menlo, monospace', fontSize: 14, letterSpacing: 1 }}>
+            {surface.domain}
+          </span>
+        </div>
+      </div>
+    ),
+    { ...size }
+  );
+}
diff --git a/src/app/entity/[slug]/page.tsx b/src/app/entity/[slug]/page.tsx
new file mode 100644
index 0000000..438b0e3
--- /dev/null
+++ b/src/app/entity/[slug]/page.tsx
@@ -0,0 +1,162 @@
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { getEntityBySlug, getEntityAssociations, yearOf } from '@/lib/db';
+
+export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
+  const entity = await getEntityBySlug(params.slug);
+  if (!entity) return { robots: { index: false, follow: false } };
+  const assocs = await getEntityAssociations(entity.id);
+  const visible = assocs.filter(a => a.source_tier !== 'D');
+  return {
+    title: entity.display_name,
+    description: entity.bio_short ?? `${entity.display_name} — addresses associated, in the archive.`,
+    alternates: { canonical: `/entity/${entity.slug}` },
+    robots: visible.length > 0
+      ? { index: true, follow: true }
+      : { index: false, follow: true },
+    openGraph: {
+      title: entity.display_name,
+      url: `/entity/${entity.slug}`,
+      type: 'profile',
+    },
+  };
+}
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { TierBadge, isPublicTier } from '@/components/TierBadge';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+import { JsonLd } from '@/components/JsonLd';
+import { SITE_DOMAIN } from '@/lib/site';
+
+export default async function EntityPage({ params }: { params: { slug: string } }) {
+  const entity = await getEntityBySlug(params.slug);
+  if (!entity) notFound();
+
+  const associations = await getEntityAssociations(entity.id);
+  const visible = associations.filter(a => isPublicTier(a.source_tier));
+
+  const vitals = [entity.birth_year, entity.death_year]
+    .map(y => y ?? '?')
+    .filter((_, i, arr) => !(arr[0] === '?' && arr[1] === '?'))
+    .join('–');
+
+  // schema.org/Person — only fields shown on the page.
+  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`;
+  const jsonLd: Record<string, unknown> = {
+    '@context': 'https://schema.org',
+    '@type': entity.kind === 'business' ? 'Organization' : 'Person',
+    name: entity.display_name,
+    url: `${baseUrl}/entity/${entity.slug}`,
+  };
+  if (entity.birth_year) jsonLd.birthDate = String(entity.birth_year);
+  if (entity.death_year) jsonLd.deathDate = String(entity.death_year);
+  if (entity.bio_short) jsonLd.description = entity.bio_short;
+  if (entity.wiki_url) jsonLd.sameAs = [entity.wiki_url];
+  if (entity.kind === 'architect' && visible.length > 0) {
+    jsonLd.jobTitle = 'Architect';
+  }
+
+  return (
+    <>
+      <JsonLd data={jsonLd} />
+      <div className="max-w-4xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive
+          items={[
+            { label: 'Archive', href: '/browse' },
+            { label: `People`, href: '/people' },
+            { label: entity.display_name },
+          ]}
+        />
+      </div>
+
+      <header className="max-w-4xl mx-auto px-6 py-10 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">{entity.kind}</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          {entity.display_name}
+        </h1>
+        {vitals && <p className="mt-3 text-base text-ink/60 font-mono">{vitals}</p>}
+        {entity.bio_short && (
+          <p className="mt-5 font-display italic text-xl text-ink/80 max-w-prose">{entity.bio_short}</p>
+        )}
+        {entity.wiki_url && (
+          <a
+            href={entity.wiki_url}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="mt-4 inline-flex text-xs uppercase tracking-wider text-ink/60 hover:text-coral underline-offset-4 hover:underline"
+          >
+            Reference
+          </a>
+        )}
+      </header>
+
+      <div className="max-w-4xl mx-auto px-6 py-10">
+        {(() => {
+          const pins: MapPin[] = visible
+            .filter(a => a.listing_lat != null && a.listing_lng != null)
+            .map(a => ({
+              slug: a.listing_slug,
+              title: a.listing_title,
+              subtitle: a.relation ?? 'Associated',
+              lat: Number(a.listing_lat),
+              lng: Number(a.listing_lng),
+            }));
+          if (pins.length === 0) return null;
+          return (
+            <div className="mb-10">
+              <InteractiveMapMount pins={pins} height={320} variant="positron" />
+              <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+                {pins.length} {pins.length === 1 ? 'location' : 'locations'} on record
+              </p>
+            </div>
+          );
+        })()}
+
+        <h2 className="font-display text-3xl text-ink mb-6 tracking-[-0.01em]">
+          {entity.kind === 'architect' || entity.kind === 'studio'
+            ? 'Oeuvre — known works'
+            : 'Addresses associated'}
+        </h2>
+        {visible.length === 0 ? (
+          <p className="text-sm text-ink/50 italic">
+            No public addresses recorded for this person yet. Privacy-by-design: if they are living and
+            the association is recent, it will not appear here.
+          </p>
+        ) : (
+          <ol className="space-y-5">
+            {visible.map(a => (
+              <li key={a.id} className="border-b border-ink/5 pb-5">
+                <div className="flex items-baseline justify-between gap-3 flex-wrap">
+                  <Link
+                    href={`/address/${a.listing_slug}`}
+                    className="font-display text-2xl text-ink hover:text-coral transition"
+                  >
+                    {a.listing_title}
+                  </Link>
+                  <span className="text-xs uppercase tracking-wider text-ink/50">{a.relation}</span>
+                </div>
+                <div className="mt-1 flex items-baseline gap-3 flex-wrap">
+                  <span className="text-sm text-ink/60">{a.listing_city}</span>
+                  {(a.date_from || a.date_to) && (
+                    <span className="font-mono text-xs text-ink/60">
+                      {yearOf(a.date_from)}–{yearOf(a.date_to)}
+                    </span>
+                  )}
+                </div>
+                {a.summary && <p className="mt-2 text-sm text-ink/70">{a.summary}</p>}
+                <div className="mt-2">
+                  <TierBadge tier={a.source_tier as 'A' | 'B' | 'C'} sourceLabel={a.source_urls?.[0]} />
+                </div>
+              </li>
+            ))}
+          </ol>
+        )}
+        <p className="mt-10 text-xs text-ink/40 max-w-prose leading-relaxed">
+          Associations are stored as dated claims with evidence, not as eternal truths. Current addresses
+          of living persons are blocked from public display at the database level and will not appear
+          regardless of search, share, or scrape.
+        </p>
+      </div>
+    </>
+  );
+}
diff --git a/src/app/film/[slug]/page.tsx b/src/app/film/[slug]/page.tsx
new file mode 100644
index 0000000..cc3e45f
--- /dev/null
+++ b/src/app/film/[slug]/page.tsx
@@ -0,0 +1,183 @@
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import type { Metadata } from 'next';
+import { getProductionBySlug, getLocationsForProduction, yearOf } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { TierBadge } from '@/components/TierBadge';
+import { JsonLd } from '@/components/JsonLd';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+import { PosterPlaceholder } from '@/components/PosterPlaceholder';
+import { CrossPromo } from '@/components/CrossPromo';
+import { FilmsGridServer } from '@/components/FilmsGridServer';
+import { SITE_DOMAIN, currentSurface } from '@/lib/site';
+
+const KIND_LABEL: Record<string, string> = {
+  film: 'Feature film',
+  tv: 'Television',
+  commercial: 'Commercial',
+  music_video: 'Music video',
+  documentary: 'Documentary',
+  short: 'Short',
+  student: 'Student',
+};
+
+export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
+  const p = await getProductionBySlug(params.slug);
+  if (!p) return { robots: { index: false, follow: false } };
+  return {
+    title: `${p.title}${p.year ? ` (${p.year})` : ''}`,
+    description: `${KIND_LABEL[p.kind] ?? p.kind} — addresses in the archive used as filming locations.`,
+    alternates: { canonical: `/film/${p.slug}` },
+    openGraph: { title: p.title, type: 'article' },
+  };
+}
+
+export default async function FilmPage({ params }: { params: { slug: string } }) {
+  const production = await getProductionBySlug(params.slug);
+  if (!production) notFound();
+  const surface = await currentSurface();
+  const locs = await getLocationsForProduction(production.id);
+
+  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`;
+  const jsonLd: Record<string, unknown> = {
+    '@context': 'https://schema.org',
+    '@type': production.kind === 'tv' ? 'TVSeries' : 'Movie',
+    name: production.title,
+    url: `${baseUrl}/film/${production.slug}`,
+  };
+  if (production.year) jsonLd.dateCreated = String(production.year);
+  if (production.imdb_id) jsonLd.sameAs = [`https://www.imdb.com/title/${production.imdb_id}/`];
+
+  const pins: MapPin[] = locs
+    .filter(l => l.listing_lat != null && l.listing_lng != null)
+    .map(l => ({
+      slug: l.listing_slug,
+      title: l.listing_title,
+      subtitle: l.role ?? 'Location',
+      lat: Number(l.listing_lat),
+      lng: Number(l.listing_lng),
+    }));
+
+  return (
+    <>
+      <JsonLd data={jsonLd} />
+      <div className="max-w-4xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive
+          items={[
+            { label: 'Archive', href: '/' },
+            { label: 'Films + TV', href: '/films' },
+            { label: production.title },
+          ]}
+        />
+      </div>
+
+      <header className="max-w-4xl mx-auto px-6 py-12 border-b border-ink/10 grid md:grid-cols-[200px_1fr] gap-8 items-start">
+        <div className="border border-ink/10 overflow-hidden">
+          {production.poster_url ? (
+            // eslint-disable-next-line @next/next/no-img-element
+            <img src={production.poster_url} alt={production.title} className="w-full block" />
+          ) : (
+            <PosterPlaceholder
+              title={production.title}
+              year={production.year}
+              kind={production.kind}
+              width={200}
+              height={300}
+              className="w-full block"
+            />
+          )}
+        </div>
+        <div>
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">
+          {KIND_LABEL[production.kind] ?? production.kind} {production.year ? `· ${production.year}` : ''}
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          {production.title}
+        </h1>
+        {production.blurb && (
+          <p className="mt-5 font-display italic text-xl text-ink/80 max-w-prose">{production.blurb}</p>
+        )}
+        <div className="mt-4 flex flex-wrap items-center gap-4">
+          <p className="font-mono text-[11px] text-ink/50">
+            {locs.length} {locs.length === 1 ? 'location' : 'locations'} pulled from public film permits
+          </p>
+          {production.imdb_id && (
+            <a
+              href={`https://www.imdb.com/title/${production.imdb_id}/`}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="font-mono text-[11px] uppercase tracking-[0.15em] text-ink/60 hover:text-coral transition"
+            >
+              IMDb · {production.imdb_id} →
+            </a>
+          )}
+          {production.wikidata_qid && (
+            <a
+              href={`https://www.wikidata.org/wiki/${production.wikidata_qid}`}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="font-mono text-[11px] uppercase tracking-[0.15em] text-ink/60 hover:text-coral transition"
+            >
+              Wikidata · {production.wikidata_qid} →
+            </a>
+          )}
+        </div>
+        </div>
+      </header>
+
+      {pins.length > 0 && (
+        <div className="max-w-4xl mx-auto px-6 mt-10">
+          <InteractiveMapMount pins={pins} height={320} variant="positron" />
+        </div>
+      )}
+
+      <section className="max-w-4xl mx-auto px-6 py-12">
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-6">Where it filmed</h2>
+        {locs.length === 0 ? (
+          <p className="text-sm italic text-ink/55">No public location permits on file yet.</p>
+        ) : (
+          <ol className="divide-y divide-ink/10">
+            {locs.map(l => (
+              <li key={l.id} className="py-5 grid md:grid-cols-[120px_1fr_auto] gap-3 md:gap-6 items-baseline">
+                <time className="font-mono text-xs uppercase tracking-wider text-ink/60">
+                  {yearOf(l.shoot_date_from)}
+                </time>
+                <div>
+                  <Link href={`/address/${l.listing_slug}`} className="font-display text-2xl text-ink hover:text-coral transition leading-tight">
+                    {l.listing_title}
+                  </Link>
+                  {l.scene_note && (
+                    <p className="mt-1 text-sm text-ink/65 leading-relaxed">{l.scene_note}</p>
+                  )}
+                  <div className="mt-2 flex gap-3 items-center flex-wrap">
+                    <span className="text-[10px] uppercase tracking-[0.15em] text-ink/50">{l.role ?? 'on location'}</span>
+                    {l.source_url ? (
+                      <a href={l.source_url} target="_blank" rel="noopener noreferrer">
+                        <TierBadge tier={l.source_tier as 'A' | 'B' | 'C'} sourceLabel={l.source_label ?? undefined} />
+                      </a>
+                    ) : (
+                      <TierBadge tier={l.source_tier as 'A' | 'B' | 'C'} sourceLabel={l.source_label ?? undefined} />
+                    )}
+                  </div>
+                </div>
+                <span className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45 md:text-right">
+                  {l.permit_number}
+                </span>
+              </li>
+            ))}
+          </ol>
+        )}
+      </section>
+
+      <FilmsGridServer
+        heading="Public-domain stills + posters"
+        kicker="Provenance: Wikimedia · Internet Archive · LoC · NARA"
+        defaultCols={5}
+        productionId={production.id}
+        limit={36}
+      />
+
+      <CrossPromo current={surface.key} />
+    </>
+  );
+}
diff --git a/src/app/films/page.tsx b/src/app/films/page.tsx
new file mode 100644
index 0000000..64f0b06
--- /dev/null
+++ b/src/app/films/page.tsx
@@ -0,0 +1,212 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { listProductionsWithCounts } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { PosterPlaceholder } from '@/components/PosterPlaceholder';
+import { FilmsGridServer } from '@/components/FilmsGridServer';
+
+export async function generateMetadata({
+  searchParams,
+}: {
+  searchParams: { kind?: string; decade?: string };
+}): Promise<Metadata> {
+  const filtered = !!(searchParams.kind || searchParams.decade);
+  return {
+    title: 'Films + TV',
+    description: 'Productions filmed at addresses in the archive — features, television, music videos, commercials. Sourced from FilmLA / LA City Open Data permits.',
+    alternates: { canonical: '/films' },
+    robots: filtered ? { index: false, follow: true } : { index: true, follow: true },
+  };
+}
+
+const KIND_LABEL: Record<string, string> = {
+  film: 'Feature',
+  tv: 'Television',
+  commercial: 'Commercial',
+  music_video: 'Music video',
+  documentary: 'Documentary',
+  short: 'Short',
+  student: 'Student',
+};
+
+const KINDS = ['film', 'tv', 'commercial', 'music_video', 'documentary'] as const;
+const DECADES = ['2020s', '2010s', '2000s', '1990s', '1980s', '1970s', '1960s', '1950s'] as const;
+
+function FilterChip({
+  active,
+  href,
+  children,
+}: {
+  active: boolean;
+  href: string;
+  children: React.ReactNode;
+}) {
+  return (
+    <Link
+      href={href}
+      className={`inline-flex items-center px-3 py-1 text-[11px] uppercase tracking-[0.15em] border transition ${
+        active
+          ? 'bg-ink text-sand border-ink'
+          : 'border-ink/20 text-ink/65 hover:border-coral hover:text-coral'
+      }`}
+    >
+      {children}
+    </Link>
+  );
+}
+
+function buildHref(current: { kind?: string; decade?: string }, patch: Record<string, string | undefined>) {
+  const next = { ...current, ...patch };
+  const params = new URLSearchParams();
+  for (const [k, v] of Object.entries(next)) {
+    if (v) params.set(k, v);
+  }
+  const qs = params.toString();
+  return qs ? `/films?${qs}` : '/films';
+}
+
+export default async function FilmsIndex({
+  searchParams,
+}: {
+  searchParams: { kind?: string; decade?: string; show?: string };
+}) {
+  const allProductions = await listProductionsWithCounts({
+    kind: searchParams.kind,
+    decade: searchParams.decade,
+  });
+
+  // Page weight: 1085 productions × ~3KB = ~3MB. Cap at 60 unless explicit ?show=all.
+  const PAGE_LIMIT = 60;
+  const showAll = searchParams.show === 'all';
+  const productions = showAll ? allProductions : allProductions.slice(0, PAGE_LIMIT);
+  const truncated = !showAll && allProductions.length > PAGE_LIMIT;
+
+  const byKind: Record<string, typeof productions> = {};
+  for (const p of productions) (byKind[p.kind] ??= []).push(p);
+  const kindOrder = ['film', 'tv', 'documentary', 'commercial', 'music_video', 'short', 'student'];
+
+  return (
+    <>
+      <div className="max-w-5xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Films + TV' }]} />
+      </div>
+
+      <header className="max-w-5xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Films + television</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          What was filmed where.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Productions that pulled a public film permit at an address in the archive.
+        </p>
+        <p className="mt-3 font-mono text-[11px] text-ink/45">
+          {productions.length} {productions.length === 1 ? 'production' : 'productions'} on file ·
+          source: FilmLA / data.lacity.org
+        </p>
+        <div className="mt-8 flex flex-col gap-3">
+          <div className="flex flex-wrap items-center gap-2">
+            <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">Kind</span>
+            <FilterChip active={!searchParams.kind} href={buildHref(searchParams, { kind: undefined })}>All</FilterChip>
+            {KINDS.map(k => (
+              <FilterChip key={k} active={searchParams.kind === k} href={buildHref(searchParams, { kind: k })}>
+                {KIND_LABEL[k]}
+              </FilterChip>
+            ))}
+          </div>
+          <div className="flex flex-wrap items-center gap-2">
+            <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">Decade</span>
+            <FilterChip active={!searchParams.decade} href={buildHref(searchParams, { decade: undefined })}>All</FilterChip>
+            {DECADES.map(d => (
+              <FilterChip key={d} active={searchParams.decade === d} href={buildHref(searchParams, { decade: d })}>
+                {d}
+              </FilterChip>
+            ))}
+          </div>
+        </div>
+      </header>
+
+      <FilmsGridServer
+        heading="From the public-domain film archive"
+        kicker="Wikimedia · Internet Archive · LoC · MHDL · Met · Openverse"
+        defaultCols={5}
+        limit={24}
+      />
+
+      <section className="max-w-5xl mx-auto px-6 py-10">
+        {productions.length === 0 ? (
+          <p className="text-sm italic text-ink/55">
+            No productions match these filters.
+          </p>
+        ) : (
+          kindOrder
+            .filter(k => byKind[k]?.length)
+            .map(kind => (
+              <div key={kind} className="mb-12">
+                <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-4 pb-2 border-b border-ink/10">
+                  {KIND_LABEL[kind] ?? kind}
+                  <span className="ml-3 font-mono text-[11px] uppercase tracking-[0.15em] text-ink/45">
+                    {byKind[kind]!.length}
+                  </span>
+                </h2>
+                <ul className="divide-y divide-ink/10">
+                  {byKind[kind]!.map(p => (
+                    <li key={p.id}>
+                      <Link
+                        href={`/film/${p.slug}`}
+                        className="grid grid-cols-[64px_1fr_auto] gap-4 items-center py-4 hover:text-coral transition group"
+                      >
+                        <div className="border border-ink/10 overflow-hidden bg-ink/[0.04]">
+                          {p.poster_url ? (
+                            // eslint-disable-next-line @next/next/no-img-element
+                            <img src={p.poster_url} alt="" className="w-full block aspect-[2/3] object-cover" />
+                          ) : (
+                            <PosterPlaceholder
+                              title={p.title}
+                              year={p.year}
+                              kind={p.kind}
+                              width={64}
+                              height={96}
+                              className="w-full block"
+                            />
+                          )}
+                        </div>
+                        <div>
+                          <h3 className="font-display text-2xl text-ink group-hover:text-coral tracking-[-0.005em]">
+                            {p.title}
+                          </h3>
+                          {p.blurb && (
+                            <p className="mt-1 text-sm text-ink/65 leading-relaxed max-w-prose">
+                              {p.blurb}
+                            </p>
+                          )}
+                        </div>
+                        <div className="text-right">
+                          <p className="font-mono text-xs text-ink/55">{p.year ?? '—'}</p>
+                          <p className="font-mono text-xs uppercase tracking-[0.15em] text-ink/45 mt-1">
+                            {p.location_count} {p.location_count === 1 ? 'location' : 'locations'}
+                          </p>
+                        </div>
+                      </Link>
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            ))
+        )}
+        {truncated && (
+          <div className="mt-12 pt-8 border-t border-ink/10 text-center">
+            <p className="text-sm text-ink/55 mb-4">
+              Showing {productions.length} of {allProductions.length} productions on file.
+            </p>
+            <Link
+              href={`/films?${new URLSearchParams({ ...searchParams, show: 'all' } as Record<string, string>).toString()}`}
+              className="inline-block bg-ink text-sand px-6 py-2 text-sm hover:opacity-90 transition"
+            >
+              Show all {allProductions.length}
+            </Link>
+          </div>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..f4a8d55
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,8 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap');
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html { background: #f6f1e8; color: #1a1410; }
+body { font-family: Inter, system-ui, sans-serif; }
+.font-display { font-family: 'Instrument Serif', Georgia, serif; }
diff --git a/src/app/hosts/page.tsx b/src/app/hosts/page.tsx
new file mode 100644
index 0000000..25e9d1e
--- /dev/null
+++ b/src/app/hosts/page.tsx
@@ -0,0 +1,159 @@
+import Link from 'next/link';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { SITE_NAME, SITE_TAGLINE } from '@/lib/site';
+import { FREE_FOREVER } from '@/lib/flags';
+import { RetiredFeatureNotice } from '@/components/RetiredFeatureNotice';
+
+export default function HostsPage() {
+  if (FREE_FOREVER) {
+    return (
+      <RetiredFeatureNotice
+        title="Hosts pitch has been retired"
+      />
+    );
+  }
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'For owners' }]} />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">For owners, hosts, and agents</p>
+        <h1 className="font-display text-5xl md:text-7xl text-ink tracking-[-0.02em] leading-[0.98] max-w-4xl">
+          Your address has a record. Now make it yours.
+        </h1>
+        <p className="mt-6 font-display italic text-2xl text-ink/70 max-w-prose">
+          {SITE_TAGLINE}
+        </p>
+      </header>
+
+      <section id="claim" className="max-w-6xl mx-auto px-6 py-16">
+        <div className="grid md:grid-cols-2 gap-10 mb-16">
+          <div>
+            <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">How it works</p>
+            <h2 className="font-display text-4xl text-ink tracking-[-0.01em]">
+              Three steps. No real-estate-agent voice.
+            </h2>
+          </div>
+          <ol className="space-y-6">
+            <li className="flex gap-4">
+              <span className="font-display text-3xl text-coral leading-none">1</span>
+              <div>
+                <h3 className="font-display text-xl text-ink mb-1">Find your address</h3>
+                <p className="text-sm text-ink/70 leading-relaxed">
+                  We probably already have a free record for it. If not, you can submit it.
+                </p>
+              </div>
+            </li>
+            <li className="flex gap-4">
+              <span className="font-display text-3xl text-coral leading-none">2</span>
+              <div>
+                <h3 className="font-display text-xl text-ink mb-1">Claim it</h3>
+                <p className="text-sm text-ink/70 leading-relaxed">
+                  Verify with a deed or utility match. Notarized upload also accepted.
+                </p>
+              </div>
+            </li>
+            <li className="flex gap-4">
+              <span className="font-display text-3xl text-coral leading-none">3</span>
+              <div>
+                <h3 className="font-display text-xl text-ink mb-1">Curate</h3>
+                <p className="text-sm text-ink/70 leading-relaxed">
+                  Add photos, copy, contact info, and a booking link. The historical ledger stays
+                  independent — you cannot rewrite the record, only add to it.
+                </p>
+              </div>
+            </li>
+          </ol>
+        </div>
+
+        <h2 className="font-display text-4xl text-ink tracking-[-0.01em] mb-8">Tiers</h2>
+        <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+          <article className="border border-ink/10 bg-sand p-6 flex flex-col">
+            <h3 className="font-display text-2xl text-ink">Free</h3>
+            <p className="font-mono text-xs uppercase tracking-[0.15em] text-ink/50 mt-1">$0</p>
+            <p className="text-sm text-ink/70 mt-3 leading-relaxed">
+              Every address gets a public record. Read-only. Tier-badged facts.
+            </p>
+            <ul className="text-xs text-ink/60 mt-5 space-y-1.5 leading-relaxed">
+              <li>• Address page</li>
+              <li>• Permits, sales, history</li>
+              <li>• Tier-badged sources</li>
+              <li>• Rental projection (LA 1978-84 anchor)</li>
+            </ul>
+          </article>
+
+          <article className="border border-moss bg-moss/5 p-6 flex flex-col relative">
+            <span className="absolute -top-3 left-6 bg-moss text-sand text-[10px] uppercase tracking-[0.15em] px-2 py-1">
+              Recommended
+            </span>
+            <h3 className="font-display text-2xl text-ink">Basic</h3>
+            <p className="font-mono text-xs uppercase tracking-[0.15em] text-ink/50 mt-1">$9.99 / mo</p>
+            <p className="text-sm text-ink/70 mt-3 leading-relaxed">
+              Claim your address. Add photos, contact info, a booking link. Hosting included.
+            </p>
+            <ul className="text-xs text-ink/60 mt-5 space-y-1.5 leading-relaxed">
+              <li>• Everything in Free</li>
+              <li>• Owner-curated photos + copy</li>
+              <li>• Contact info + booking link</li>
+              <li>• Rental projection export</li>
+              <li>• Hosting on {SITE_NAME}.com</li>
+            </ul>
+            <Link
+              href="/browse?q="
+              className="mt-6 inline-flex items-center gap-2 bg-moss text-sand px-4 py-2 text-xs uppercase tracking-wider hover:bg-ink transition"
+            >
+              Find your address
+            </Link>
+          </article>
+
+          <article className="border border-ink/10 bg-sand p-6 flex flex-col">
+            <h3 className="font-display text-2xl text-ink">Pro</h3>
+            <p className="font-mono text-xs uppercase tracking-[0.15em] text-ink/50 mt-1">$19.99 / mo</p>
+            <p className="text-sm text-ink/70 mt-3 leading-relaxed">
+              Multi-property dashboard, custom domain, analytics, API access.
+            </p>
+            <ul className="text-xs text-ink/60 mt-5 space-y-1.5 leading-relaxed">
+              <li>• Everything in Basic</li>
+              <li>• Custom domain</li>
+              <li>• Owner analytics</li>
+              <li>• 10k API calls/mo</li>
+              <li>• Multi-address dashboard</li>
+            </ul>
+          </article>
+
+          <article className="border border-coral bg-ink text-sand p-6 flex flex-col">
+            <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-coral mb-2 inline-flex items-center gap-2">
+              <span className="inline-block w-1.5 h-1.5 bg-coral rounded-full"></span>
+              Sponsored placement
+            </span>
+            <h3 className="font-display text-2xl text-sand">Promote</h3>
+            <p className="font-mono text-xs uppercase tracking-[0.15em] text-sand/60 mt-1">$29 / mo flat</p>
+            <p className="text-sm text-sand/75 mt-3 leading-relaxed">
+              Real-estate agents and short-term hosts: link your Zillow/Redfin/Airbnb listing on top of the historical page.
+            </p>
+            <ul className="text-xs text-sand/60 mt-5 space-y-1.5 leading-relaxed">
+              <li>• "Available now" sponsored card</li>
+              <li>• Walled off from editorial</li>
+              <li>• Source-logo badging</li>
+              <li>• No sneaky native-style placement</li>
+            </ul>
+            <Link
+              href="/promote"
+              className="mt-6 inline-flex items-center gap-2 border border-coral text-coral px-4 py-2 text-xs uppercase tracking-wider hover:bg-coral hover:text-ink transition"
+            >
+              Promote a listing →
+            </Link>
+          </article>
+        </div>
+
+        <p className="mt-12 text-xs text-ink/40 max-w-prose leading-relaxed">
+          Editorial firewall: paid placements never appear inside the historical record. They live in a dedicated
+          "Available now" module with their source logo. They never write to the event ledger. Tier-badged facts
+          mean the archive vouches for them. A coral fill means somebody paid.
+        </p>
+      </section>
+    </>
+  );
+}
diff --git a/src/app/houses/page.tsx b/src/app/houses/page.tsx
new file mode 100644
index 0000000..d65f6cf
--- /dev/null
+++ b/src/app/houses/page.tsx
@@ -0,0 +1,145 @@
+/**
+ * /houses — public index of all named historic estates ingested from
+ * Wikipedia + LA Historic-Cultural Monuments.
+ *
+ * Editorial register-of-houses, magazine-style. Each entry drill-downs to
+ * the Wikipedia article (or NavigateLA HCM detail page) for the canonical
+ * source.
+ */
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { pool } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { GridSlider, ListingGrid } from '@/components/ListingGrid';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata: Metadata = {
+  title: 'Named houses',
+  description: 'Every named historic estate in the archive — Pickfair, Greystone, Falcon Lair, and 250+ more, each linked to its primary source.',
+  alternates: { canonical: '/houses' },
+};
+
+type Row = {
+  id: string;
+  slug: string;
+  title: string;
+  description: string | null;
+  city: string | null;
+  latitude: string | null;
+  longitude: string | null;
+  source_url: string | null;
+  kind: 'wikipedia' | 'hcm';
+};
+
+async function getHouses(filter: string | undefined): Promise<Row[]> {
+  const where: string[] = ["l.is_public = true", "l.source IN ('wikipedia_house','la_hcm')"];
+  const params: any[] = [];
+  if (filter) {
+    params.push(`%${filter}%`);
+    where.push(`(l.title ILIKE $${params.length} OR l.description ILIKE $${params.length} OR l.city ILIKE $${params.length})`);
+  }
+  const { rows } = await pool.query<any>(
+    `SELECT l.id, l.slug, l.title, l.description, l.city,
+            l.latitude::text as latitude, l.longitude::text as longitude,
+            (SELECT pe.source_url FROM place_event pe WHERE pe.listing_id = l.id AND pe.kind IN ('named_estate','historic_designation') LIMIT 1) as source_url,
+            CASE WHEN l.source = 'wikipedia_house' THEN 'wikipedia' ELSE 'hcm' END as kind
+     FROM listing l
+     WHERE ${where.join(' AND ')}
+     ORDER BY l.title
+     LIMIT 500`,
+    params
+  );
+  return rows;
+}
+
+export default async function HousesPage({ searchParams }: { searchParams: { q?: string } }) {
+  const q = searchParams.q?.trim();
+  const houses = await getHouses(q);
+  const wpCount = houses.filter(h => h.kind === 'wikipedia').length;
+  const hcmCount = houses.filter(h => h.kind === 'hcm').length;
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Houses' }]} />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.25em] text-moss font-mono mb-3">Register of houses</p>
+        <h1 className="font-display text-6xl md:text-8xl text-ink tracking-[-0.03em] leading-[0.92]">
+          {houses.length} named<br/>
+          <span className="italic text-ink/65">houses.</span>
+        </h1>
+        <p className="mt-6 font-display italic text-2xl text-ink/65 max-w-3xl leading-tight">
+          Every estate, mansion, and named historic building in the archive — each linked back to the source.
+        </p>
+        <div className="mt-6 flex gap-3 flex-wrap items-center text-xs">
+          <span className="font-mono uppercase tracking-[0.15em] text-ink/45">
+            {wpCount} via Wikipedia · {hcmCount} via LA Historic-Cultural Monuments
+          </span>
+        </div>
+        <form action="/houses" className="mt-8 flex gap-3 max-w-2xl">
+          <input
+            type="search"
+            name="q"
+            defaultValue={q ?? ''}
+            placeholder="Pickfair · Greystone · Beverly Hills · architect…"
+            className="flex-1 border-2 border-ink/20 bg-sand px-4 py-3 text-base font-sans focus:outline-none focus:border-moss"
+          />
+          <button type="submit" className="bg-ink text-sand px-6 py-3 text-xs uppercase tracking-[0.2em] hover:bg-moss transition font-mono">
+            Filter
+          </button>
+          {q && <Link href="/houses" className="self-center text-xs uppercase tracking-wider text-ink/50 hover:text-coral">Clear</Link>}
+        </form>
+      </header>
+
+      <section className="max-w-6xl mx-auto px-6 py-12">
+        {houses.length === 0 ? (
+          <p className="text-sm italic text-ink/55">No named houses match.</p>
+        ) : (
+          <>
+            <GridSlider className="mb-6 pb-3 border-b border-ink/10" />
+            <ListingGrid>
+              {houses.map(h => (
+                <article key={h.id} className="border border-ink/10 bg-white p-5 hover:border-ink/40 transition group flex flex-col">
+                  <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-coral/80">
+                    {h.kind === 'wikipedia' ? 'Wikipedia' : 'LA Historic-Cultural Monument'}
+                  </p>
+                  <Link href={`/address/${h.slug}`} className="font-display text-2xl text-ink mt-2 leading-tight tracking-[-0.005em] group-hover:text-coral transition">
+                    {h.title}
+                  </Link>
+                  {h.city && (
+                    <p className="font-mono text-[10px] uppercase tracking-wider text-ink/45 mt-1">{h.city}</p>
+                  )}
+                  {h.description && (
+                    <p className="mt-3 text-sm text-ink/65 leading-relaxed flex-1">
+                      {h.description.slice(0, 140)}{h.description.length > 140 ? '…' : ''}
+                    </p>
+                  )}
+                  <div className="mt-4 pt-3 border-t border-ink/10 flex items-baseline justify-between gap-2">
+                    {h.latitude && h.longitude && (
+                      <span className="font-mono text-[10px] tabular-nums text-ink/40">
+                        {parseFloat(h.latitude).toFixed(4)}, {parseFloat(h.longitude).toFixed(4)}
+                      </span>
+                    )}
+                    {h.source_url && (
+                      <a
+                        href={h.source_url}
+                        target="_blank"
+                        rel="noreferrer noopener"
+                        className="font-mono text-[10px] uppercase tracking-wider text-coral hover:underline shrink-0"
+                      >
+                        source ↗
+                      </a>
+                    )}
+                  </div>
+                </article>
+              ))}
+            </ListingGrid>
+          </>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..ecc77ab
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,129 @@
+import './globals.css';
+import type { Metadata } from 'next';
+import { headers } from 'next/headers';
+import Link from 'next/link';
+import { SITE_NAME, siteForHost } from '@/lib/site';
+import { FREE_FOREVER } from '@/lib/flags';
+import { Wordmark } from '@/components/Wordmark';
+import { HeaderSearch } from '@/components/HeaderSearch';
+import { GucciStripe } from '@/components/GucciStripe';
+
+/**
+ * Per-host metadata: each surface (wholivedthere, claimmyaddress, bubbesblock)
+ * gets its own <title>, OG, description. The middleware sets x-pastdoor-host;
+ * we read it here so social shares + SERPs see the right brand per domain.
+ */
+export async function generateMetadata(): Promise<Metadata> {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${surface.domain}`;
+  return {
+    metadataBase: new URL(baseUrl),
+    title: {
+      default: `${surface.brandName} — ${surface.tagline}`,
+      template: `%s · ${surface.brandName}`,
+    },
+    description: surface.description,
+    alternates: { canonical: '/' },
+    openGraph: {
+      type: 'website',
+      siteName: surface.brandName,
+      title: surface.ogTitle,
+      description: surface.description,
+      url: baseUrl,
+    },
+    twitter: {
+      card: 'summary_large_image',
+      title: surface.brandName,
+      description: surface.tagline,
+    },
+  };
+}
+
+// Per-surface chrome treatment — keeps GucciStripe shared (the family signal)
+// but lets each site wear its own accent on the wordmark dot, header bg tint,
+// and primary-CTA button. Resolves UX critique that the surfaces still read as
+// "same site, three colors" despite the FilmsGrid differentiation.
+const SURFACE_CHROME: Record<'flagship' | 'claim' | 'community', {
+  accent: string;
+  headerBg: string;
+  headerBorder: string;
+  ctaText: string;
+}> = {
+  flagship:  { accent: '#1f4d40', headerBg: 'bg-[#f3ebd9]/85', headerBorder: 'border-b border-[#1f4d40]/15',  ctaText: '#f3ebd9' },
+  claim:     { accent: '#c9292e', headerBg: 'bg-[#e8dcc4]/90', headerBorder: 'border-b-2 border-[#c9292e]',    ctaText: '#fdf9ee' },
+  community: { accent: '#c5a572', headerBg: 'bg-[#ede0c4]/85', headerBorder: 'border-b border-[#c5a572]/30',  ctaText: '#1a1a1a' },
+};
+
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const chrome = SURFACE_CHROME[surface.key];
+
+  return (
+    <html lang="en">
+      <body>
+        {/* Per-surface GA4 — distinct property per domain, injected here so
+            host-routed traffic flows to the right brand bucket. Measurement ID
+            comes from env (NEXT_PUBLIC_GA_FLAGSHIP / _CLAIM / _COMMUNITY) and
+            is created by the analytics skill once the service account has
+            GA Admin access. Snippet stays inert until env var is set. */}
+        {(() => {
+          const env = {
+            flagship:  process.env.NEXT_PUBLIC_GA_FLAGSHIP,
+            claim:     process.env.NEXT_PUBLIC_GA_CLAIM,
+            community: process.env.NEXT_PUBLIC_GA_COMMUNITY,
+          }[surface.key];
+          const id = env || surface.gaMeasurementId;
+          if (!id) return null;
+          return (
+            <>
+              <script async src={`https://www.googletagmanager.com/gtag/js?id=${id}`}></script>
+              <script
+                dangerouslySetInnerHTML={{
+                  __html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}',{send_page_view:true});`,
+                }}
+              />
+            </>
+          );
+        })()}
+        <header className={`${chrome.headerBg} ${chrome.headerBorder} backdrop-blur sticky top-0 z-20`}>
+          <div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between gap-6">
+            <Link href="/" aria-label={surface.brandName}>
+              <Wordmark name={surface.domain} accent={chrome.accent} />
+            </Link>
+            <div className="flex-1 hidden md:flex justify-end">
+              <HeaderSearch />
+            </div>
+            <nav className="flex items-center gap-6 text-sm">
+              <Link href="/map" className="hidden md:inline hover:opacity-70 transition">Map</Link>
+              <Link href="/films" className="hidden md:inline hover:opacity-70 transition">Films</Link>
+              <Link href="/browse" className="hidden lg:inline hover:opacity-70 transition">Browse</Link>
+              {!FREE_FOREVER && (
+                <Link href="/hosts" className="hidden lg:inline hover:opacity-70 transition">For owners</Link>
+              )}
+              <Link
+                href={surface.primaryCta.href}
+                className="px-4 py-1.5 hover:opacity-90 transition font-medium"
+                style={{ background: chrome.accent, color: chrome.ctaText }}
+              >
+                {surface.primaryCta.label}
+              </Link>
+            </nav>
+          </div>
+        </header>
+        <GucciStripe />
+        <main>{children}</main>
+        <footer className="border-t border-ink/10 mt-24">
+          <div className="max-w-6xl mx-auto px-6 py-8 text-xs text-ink/60 flex flex-col md:flex-row justify-between gap-2">
+            <span>© {new Date().getFullYear()} {surface.brandName}</span>
+            <span className="text-ink/40">
+              Public record. Historical associations only. Living persons protected by design.
+              {FREE_FOREVER && ' Free forever.'}
+            </span>
+          </div>
+        </footer>
+      </body>
+    </html>
+  );
+}
diff --git a/src/app/locations-by-year/page.tsx b/src/app/locations-by-year/page.tsx
new file mode 100644
index 0000000..f22739f
--- /dev/null
+++ b/src/app/locations-by-year/page.tsx
@@ -0,0 +1,124 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { listAllFilmingShoots, yearOf } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+
+export const metadata: Metadata = {
+  title: 'Locations by year',
+  description: 'Every public filming permit in the archive, sorted by year. The visual record of what was shot in LA when.',
+  alternates: { canonical: '/locations-by-year' },
+};
+
+const KIND_LABEL: Record<string, string> = {
+  film: 'Feature',
+  tv: 'TV',
+  commercial: 'Commercial',
+  music_video: 'Music video',
+  documentary: 'Documentary',
+  short: 'Short',
+  student: 'Student',
+};
+
+export default async function LocationsByYearPage() {
+  const shoots = await listAllFilmingShoots();
+
+  // Group by year.
+  const byYear: Record<string, typeof shoots> = {};
+  for (const s of shoots) {
+    const y = s.shoot_date_from ? yearOf(s.shoot_date_from) : 'Undated';
+    (byYear[y] ??= []).push(s);
+  }
+  const years = Object.keys(byYear).sort((a, b) =>
+    a === 'Undated' ? 1 : b === 'Undated' ? -1 : Number(b) - Number(a)
+  );
+
+  return (
+    <>
+      <div className="max-w-5xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Locations by year' }]} />
+      </div>
+
+      <header className="max-w-5xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Locations by year</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Every shoot, sorted by year.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Public film permits at addresses in the archive — pulled from FilmLA / data.lacity.org.
+        </p>
+        <p className="mt-3 font-mono text-[11px] text-ink/45">
+          {shoots.length} {shoots.length === 1 ? 'shoot' : 'shoots'} across {years.length} {years.length === 1 ? 'year' : 'years'}
+        </p>
+        {years.length > 0 && (
+          <nav className="mt-6 flex flex-wrap gap-2">
+            {years.map(y => (
+              <a
+                key={y}
+                href={`#y-${y}`}
+                className="font-mono text-[11px] uppercase tracking-[0.15em] border border-ink/15 px-2 py-1 hover:border-coral hover:text-coral transition"
+              >
+                {y}
+              </a>
+            ))}
+          </nav>
+        )}
+      </header>
+
+      <section className="max-w-5xl mx-auto px-6 py-10">
+        {shoots.length === 0 ? (
+          <p className="text-sm italic text-ink/55">
+            No shoots ingested yet. Run <code className="font-mono text-xs">scripts/ingest-la-film-permits.ts</code>.
+          </p>
+        ) : (
+          years.map(year => (
+            <div key={year} id={`y-${year}`} className="mb-12 scroll-mt-24">
+              <div className="flex items-baseline gap-4 mb-4 pb-2 border-b border-ink/10">
+                <h2 className="font-display text-4xl text-ink tracking-[-0.01em]">{year}</h2>
+                <span className="font-mono text-[11px] text-ink/45">
+                  {byYear[year]!.length} {byYear[year]!.length === 1 ? 'shoot' : 'shoots'}
+                </span>
+              </div>
+              <ol className="divide-y divide-ink/5">
+                {byYear[year]!.map(s => (
+                  <li key={s.id} className="grid md:grid-cols-[120px_1fr_auto] gap-3 md:gap-6 py-4 items-baseline">
+                    <span className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/55">
+                      {KIND_LABEL[s.production_kind] ?? s.production_kind}
+                      {s.role ? ` · ${s.role.replace('_', ' ')}` : ''}
+                    </span>
+                    <div>
+                      <div className="flex items-baseline gap-3 flex-wrap">
+                        <Link
+                          href={`/film/${s.production_slug}`}
+                          className="font-display text-xl text-ink hover:text-coral transition"
+                        >
+                          {s.production_title}
+                          {s.production_year ? <span className="ml-2 text-ink/50">({s.production_year})</span> : null}
+                        </Link>
+                        <span className="text-ink/30">·</span>
+                        <Link
+                          href={`/address/${s.listing_slug}`}
+                          className="text-base text-ink/70 hover:text-coral transition"
+                        >
+                          {s.listing_title}
+                        </Link>
+                        {s.listing_city && (
+                          <span className="text-[11px] text-ink/50">{s.listing_city}</span>
+                        )}
+                      </div>
+                      {s.scene_note && (
+                        <p className="mt-1 text-sm text-ink/65 leading-relaxed">{s.scene_note}</p>
+                      )}
+                    </div>
+                    <span className="font-mono text-[10px] text-ink/45 md:text-right">
+                      {s.permit_number}
+                    </span>
+                  </li>
+                ))}
+              </ol>
+            </div>
+          ))
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/map/MapWithLayers.tsx b/src/app/map/MapWithLayers.tsx
new file mode 100644
index 0000000..78d12be
--- /dev/null
+++ b/src/app/map/MapWithLayers.tsx
@@ -0,0 +1,81 @@
+'use client';
+import { useMemo, useState } from 'react';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+
+type FilmingPin = {
+  slug: string;
+  title: string;
+  lat: number;
+  lng: number;
+  productions: { slug: string; title: string; year: number | null }[];
+};
+
+export function MapWithLayers({
+  archivePins,
+  filmingPins,
+}: {
+  archivePins: MapPin[];
+  filmingPins: FilmingPin[];
+}) {
+  const [showArchive, setShowArchive] = useState(true);
+  const [showFilming, setShowFilming] = useState(true);
+
+  const pins: MapPin[] = useMemo(() => {
+    const out: MapPin[] = [];
+    if (showArchive) {
+      // Mark archive pins so they pin moss-colored.
+      out.push(...archivePins.map(p => ({ ...p, featured: false })));
+    }
+    if (showFilming) {
+      // Filming pins overlay — coral (featured = true).
+      out.push(
+        ...filmingPins.map(f => {
+          const titles = f.productions.slice(0, 3).map(p => `${p.title}${p.year ? ` (${p.year})` : ''}`).join(' · ');
+          const more = f.productions.length > 3 ? ` +${f.productions.length - 3} more` : '';
+          return {
+            slug: f.slug,
+            title: f.title,
+            subtitle: `Filmed: ${titles}${more}`,
+            lat: f.lat,
+            lng: f.lng,
+            featured: true,
+          };
+        })
+      );
+    }
+    return out;
+  }, [archivePins, filmingPins, showArchive, showFilming]);
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center gap-3 flex-wrap">
+        <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">Layers</span>
+        <button
+          type="button"
+          onClick={() => setShowArchive(s => !s)}
+          className={`inline-flex items-center gap-2 px-3 py-1 text-[11px] uppercase tracking-[0.15em] border transition ${
+            showArchive
+              ? 'bg-moss text-sand border-moss'
+              : 'border-ink/20 text-ink/55 hover:border-ink/45'
+          }`}
+        >
+          <span className="inline-block w-2 h-2 rounded-full bg-moss" />
+          Archive ({archivePins.length})
+        </button>
+        <button
+          type="button"
+          onClick={() => setShowFilming(s => !s)}
+          className={`inline-flex items-center gap-2 px-3 py-1 text-[11px] uppercase tracking-[0.15em] border transition ${
+            showFilming
+              ? 'bg-ink text-sand border-ink'
+              : 'border-ink/20 text-ink/55 hover:border-ink/45'
+          }`}
+        >
+          <span className="inline-block w-2 h-2 rounded-full bg-coral" />
+          Filming ({filmingPins.length})
+        </button>
+      </div>
+      <InteractiveMapMount pins={pins} height={680} variant="voyager" />
+    </div>
+  );
+}
diff --git a/src/app/map/page.tsx b/src/app/map/page.tsx
new file mode 100644
index 0000000..5388f9b
--- /dev/null
+++ b/src/app/map/page.tsx
@@ -0,0 +1,49 @@
+import type { Metadata } from 'next';
+import { searchListings, getAllFilmingPins } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { type MapPin } from '@/components/InteractiveMapMount';
+import { MapWithLayers } from './MapWithLayers';
+
+export const metadata: Metadata = {
+  title: 'Map',
+  description: 'Every public address in the archive on a single interactive map. Toggle the filming layer to see what was shot where.',
+  alternates: { canonical: '/map' },
+};
+
+export default async function MapPage() {
+  const [listings, filming] = await Promise.all([
+    searchListings({ limit: 500 }),
+    getAllFilmingPins(),
+  ]);
+  const archivePins: MapPin[] = listings
+    .filter(l => l.latitude != null && l.longitude != null)
+    .map(l => ({
+      slug: l.slug,
+      title: l.title,
+      subtitle: l.city ?? undefined,
+      lat: Number(l.latitude),
+      lng: Number(l.longitude),
+    }));
+
+  return (
+    <>
+      <div className="max-w-7xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Map' }]} />
+      </div>
+
+      <header className="max-w-7xl mx-auto px-6 py-8">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Map</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Every address. Every shot.
+        </h1>
+        <p className="mt-3 font-mono text-[11px] text-ink/45">
+          {archivePins.length} archive pins · {filming.length} filmed locations · click any pin to open its record
+        </p>
+      </header>
+
+      <div className="max-w-7xl mx-auto px-6 pb-12">
+        <MapWithLayers archivePins={archivePins} filmingPins={filming} />
+      </div>
+    </>
+  );
+}
diff --git a/src/app/neighborhood/[slug]/page.tsx b/src/app/neighborhood/[slug]/page.tsx
new file mode 100644
index 0000000..f3052f3
--- /dev/null
+++ b/src/app/neighborhood/[slug]/page.tsx
@@ -0,0 +1,78 @@
+import type { Metadata } from 'next';
+import { searchListings } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { NodeCard } from '@/components/NodeCard';
+
+const CITY_FOR_SLUG: Record<string, string> = {
+  'beverly-hills': 'Beverly Hills',
+  'los-angeles': 'Los Angeles',
+  'malibu': 'Malibu',
+  'big-bear-lake': 'Big Bear Lake',
+  'joshua-tree': 'Joshua Tree',
+};
+
+export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
+  const cityName = CITY_FOR_SLUG[params.slug] ?? params.slug.split('-').map(w => w[0]!.toUpperCase() + w.slice(1)).join(' ');
+  const listings = await searchListings({ city: cityName, limit: 1 });
+  return {
+    title: cityName,
+    description: `${cityName} addresses in the archive — permits, sales, architects, historical associations.`,
+    alternates: { canonical: `/neighborhood/${params.slug}` },
+    // Threshold: ≥3 listings before we index the neighborhood index page.
+    robots: listings.length >= 1
+      ? { index: listings.length >= 3, follow: true }
+      : { index: false, follow: true },
+  };
+}
+
+export default async function NeighborhoodPage({ params }: { params: { slug: string } }) {
+  const cityName = CITY_FOR_SLUG[params.slug] ?? params.slug.split('-').map(w => w[0]!.toUpperCase() + w.slice(1)).join(' ');
+  const listings = await searchListings({ city: cityName, limit: 24 });
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive
+          items={[
+            { label: 'Archive', href: '/browse' },
+            { label: 'Neighborhoods', href: '/neighborhoods' },
+            { label: cityName },
+          ]}
+        />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Neighborhood</p>
+        <h1 className="font-display text-6xl md:text-7xl text-ink tracking-[-0.02em] leading-[0.95]">
+          {cityName}
+        </h1>
+        <p className="mt-5 font-display italic text-xl text-ink/70 max-w-prose">
+          Every address a record. Every record a story.
+        </p>
+      </header>
+
+      <section className="max-w-6xl mx-auto px-6 py-12">
+        <h2 className="font-display text-3xl text-ink mb-6 tracking-[-0.01em]">Addresses in the archive</h2>
+        {listings.length === 0 ? (
+          <p className="text-sm text-ink/50 italic">
+            No addresses yet in this neighborhood. Ingest pipeline pending.
+          </p>
+        ) : (
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
+            {listings.map(l => (
+              <NodeCard
+                key={l.id}
+                kind="address"
+                href={`/address/${l.slug}`}
+                title={l.title}
+                subtitle={l.address_line1 ?? undefined}
+                imageUrl={l.hero_image ?? undefined}
+                eyebrow={l.property_type ?? 'Address'}
+              />
+            ))}
+          </div>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/neighborhoods/page.tsx b/src/app/neighborhoods/page.tsx
new file mode 100644
index 0000000..c8f806f
--- /dev/null
+++ b/src/app/neighborhoods/page.tsx
@@ -0,0 +1,93 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { listNeighborhoodsWithGeo, searchListings } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+import { CrossPromo } from '@/components/CrossPromo';
+import { FilmsGridServer } from '@/components/FilmsGridServer';
+import { HeroCollage } from '@/components/HeroCollage';
+import { StarsCtaRow } from '@/components/StarsCtaRow';
+import { currentSurface } from '@/lib/site';
+
+export const metadata: Metadata = {
+  title: 'Neighborhoods',
+  description: 'Where the archive lives — Beverly Hills first, concentric rings outward.',
+  alternates: { canonical: '/neighborhoods' },
+};
+
+function slugify(s: string) {
+  return s.toLowerCase().replace(/\s+/g, '-');
+}
+
+export default async function NeighborhoodsIndex() {
+  const surface = await currentSurface();
+  const items = await listNeighborhoodsWithGeo();
+  // Pull all listings with lat/lng for a master map of every place we have.
+  const allListings = await searchListings({ limit: 100 });
+  const pins: MapPin[] = allListings
+    .filter(l => l.latitude != null && l.longitude != null)
+    .map(l => ({
+      slug: l.slug,
+      title: l.title,
+      subtitle: l.city ?? undefined,
+      lat: Number(l.latitude),
+      lng: Number(l.longitude),
+    }));
+
+  return (
+    <>
+      <HeroCollage />
+      <StarsCtaRow surface={surface.key} />
+      <div className="max-w-5xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Neighborhoods' }]} />
+      </div>
+
+      <header className="max-w-5xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Neighborhoods</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Where the archive lives.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Beverly Hills first. Concentric rings outward as ingestion stabilizes.
+        </p>
+      </header>
+
+      {pins.length > 0 && (
+        <div className="max-w-5xl mx-auto px-6 mt-10">
+          <InteractiveMapMount pins={pins} height={420} variant="positron" />
+          <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+            {pins.length} addresses across {items.length} {items.length === 1 ? 'neighborhood' : 'neighborhoods'}
+          </p>
+        </div>
+      )}
+
+      <section className="max-w-5xl mx-auto px-6 py-12">
+        <ul className="divide-y divide-ink/10">
+          {items.map(n => (
+            <li key={n.city}>
+              <Link
+                href={`/neighborhood/${slugify(n.city)}`}
+                className="flex items-baseline justify-between py-5 hover:text-coral transition group"
+              >
+                <span className="font-display text-3xl text-ink group-hover:text-coral tracking-[-0.01em]">
+                  {n.city}
+                </span>
+                <span className="font-mono text-xs text-ink/50">
+                  {n.count} {n.count === 1 ? 'address' : 'addresses'} · {n.state}
+                </span>
+              </Link>
+            </li>
+          ))}
+        </ul>
+      </section>
+
+      <FilmsGridServer
+        heading="The block, before you knew it"
+        kicker="Neighborhood screenings"
+        limit={48}
+      />
+
+      <CrossPromo current={surface.key} />
+    </>
+  );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..7913dd3
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,29 @@
+import Link from 'next/link';
+
+export default function NotFound() {
+  return (
+    <section className="max-w-2xl mx-auto px-6 py-32 text-center">
+      <p className="text-[10px] uppercase tracking-[0.2em] text-ink/40 mb-3">404 · file not in the archive</p>
+      <h1 className="font-display text-7xl text-ink tracking-[-0.02em] leading-[0.95]">
+        No record at this address.
+      </h1>
+      <p className="mt-6 font-display italic text-2xl text-ink/60">
+        Either the page hasn&rsquo;t been ingested yet, or it never existed.
+      </p>
+      <div className="mt-10 flex justify-center gap-3">
+        <Link
+          href="/"
+          className="bg-ink text-sand px-5 py-2 text-xs uppercase tracking-wider hover:bg-moss transition"
+        >
+          Back to the archive
+        </Link>
+        <Link
+          href="/browse"
+          className="border border-ink/20 text-ink px-5 py-2 text-xs uppercase tracking-wider hover:border-coral hover:text-coral transition"
+        >
+          Browse what we do have
+        </Link>
+      </div>
+    </section>
+  );
+}
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
new file mode 100644
index 0000000..76d0f51
--- /dev/null
+++ b/src/app/opengraph-image.tsx
@@ -0,0 +1,84 @@
+import { ImageResponse } from 'next/og';
+import { headers } from 'next/headers';
+import { siteForHost } from '@/lib/site';
+
+// Per-host OG card. Next.js auto-routes this for every page in app/ unless a
+// page-specific opengraph-image is provided. Picks up the same surface as the
+// rest of layout.tsx via x-pastdoor-host (set in middleware).
+//
+// Output is 1200x630 (Open Graph standard) and renders a typographic title
+// card themed to the surface — green/cream for flagship, red/bone for claim,
+// gold/cream for community.
+
+export const runtime = 'edge';
+export const contentType = 'image/png';
+export const size = { width: 1200, height: 630 };
+export const alt = 'pastdoor — every address has a story';
+
+const SURFACE_OG = {
+  flagship:  { bg: '#f3ebd9', accent: '#1f4d40', ink: '#1a1a1a', kicker: 'Open archive' },
+  claim:     { bg: '#e8dcc4', accent: '#c9292e', ink: '#1a1a1a', kicker: 'Filed here'   },
+  community: { bg: '#ede0c4', accent: '#c5a572', ink: '#1a1a1a', kicker: 'Neighborhood screenings' },
+} as const;
+
+export default async function OG() {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const t = SURFACE_OG[surface.key];
+
+  return new ImageResponse(
+    (
+      <div
+        style={{
+          width: '100%', height: '100%',
+          display: 'flex', flexDirection: 'column',
+          background: t.bg,
+          padding: '80px 96px',
+          fontFamily: 'serif',
+        }}
+      >
+        {/* GucciStripe at top — shared family signal */}
+        <div style={{ display: 'flex', height: 6, width: '100%', marginBottom: 60 }}>
+          <div style={{ flex: 1, background: '#1f4d40' }} />
+          <div style={{ flex: 1, background: '#c9292e' }} />
+          <div style={{ flex: 1, background: '#1f4d40' }} />
+        </div>
+
+        {/* Kicker */}
+        <div
+          style={{
+            display: 'flex',
+            background: t.accent,
+            color: '#fdf9ee',
+            padding: '6px 14px',
+            fontSize: 18,
+            letterSpacing: 4,
+            textTransform: 'uppercase',
+            fontWeight: 700,
+            alignSelf: 'flex-start',
+            marginBottom: 32,
+          }}
+        >
+          {t.kicker}
+        </div>
+
+        {/* Brand line */}
+        <div style={{ display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center' }}>
+          <div style={{ fontSize: 96, color: t.ink, fontWeight: 400, letterSpacing: -3, lineHeight: 1.05, fontStyle: 'italic' }}>
+            {surface.tagline}
+          </div>
+          <div style={{ fontSize: 36, color: t.accent, marginTop: 24, fontWeight: 500 }}>
+            {surface.brandName}
+          </div>
+        </div>
+
+        {/* Footer */}
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', fontSize: 18, color: t.ink, opacity: 0.55 }}>
+          <span>{surface.domain}</span>
+          <span>Public record · public domain · public archive</span>
+        </div>
+      </div>
+    ),
+    size
+  );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..df7aa62
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,96 @@
+import { searchListings, getRecentPermits, yearOf } from '@/lib/db';
+import { MapHero } from '@/components/MapHero';
+import { Rail } from '@/components/Rail';
+import { NodeCard } from '@/components/NodeCard';
+import { FilmsGridServer } from '@/components/FilmsGridServer';
+import { HeroCollage } from '@/components/HeroCollage';
+import { StarsCtaRow } from '@/components/StarsCtaRow';
+import { currentSurface } from '@/lib/site';
+
+// Storytelling-tone copy — facts framed as scenes / chapters / pages, not as data.
+const FILMS_HEADING: Record<'flagship' | 'claim' | 'community', { kicker: string; heading: string }> = {
+  flagship:  { kicker: 'Open archive',           heading: 'Stories caught on film, near here' },
+  claim:     { kicker: 'Filmed here',            heading: 'Scenes the cameras came for' },
+  community: { kicker: 'Pages from the block',   heading: 'What the neighborhood has lived through' },
+};
+
+export default async function HomePage() {
+  const [listings, permitEvents, surface] = await Promise.all([
+    searchListings({ limit: 24 }),
+    getRecentPermits(6),
+    currentSurface(),
+  ]);
+  const filmCopy = FILMS_HEADING[surface.key];
+
+  const pins = listings
+    .filter(l => l.latitude != null && l.longitude != null)
+    .map((l, i) => ({
+      slug: l.slug,
+      title: l.title,
+      lat: Number(l.latitude),
+      lng: Number(l.longitude),
+      featured: i === 0,
+    }));
+
+  const recentlyResearched = listings.slice(0, 3);
+  const recentPermits = permitEvents.slice(0, 3);
+  const recentlyClaimed = listings.filter(l => l.claimed_by).slice(0, 3);
+
+  return (
+    <>
+      <HeroCollage />
+      <StarsCtaRow surface={surface.key} />
+      <MapHero pins={pins} />
+
+      <Rail label="Recently researched">
+        {recentlyResearched.map(l => (
+          <NodeCard
+            key={l.id}
+            kind="address"
+            href={`/address/${l.slug}`}
+            title={l.title}
+            subtitle={l.city ? `${l.city}, ${l.state}` : undefined}
+            imageUrl={l.hero_image ?? undefined}
+            eyebrow={l.property_type ?? 'Address'}
+          />
+        ))}
+      </Rail>
+
+      <Rail label="This week in permits">
+        {recentPermits.map(p => (
+          <NodeCard
+            key={`permit-${p.id}`}
+            kind="address"
+            href={`/address/${p.listing_slug}`}
+            title={p.listing_title}
+            subtitle={`${p.summary.slice(0, 80)}${p.summary.length > 80 ? '…' : ''}`}
+            imageUrl={p.listing_hero ?? undefined}
+            eyebrow={`Permit · ${yearOf(p.valid_from)}`}
+          />
+        ))}
+      </Rail>
+
+      {recentlyClaimed.length > 0 && (
+        <Rail label="Recently claimed by owners">
+          {recentlyClaimed.map(l => (
+            <NodeCard
+              key={`claim-${l.id}`}
+              kind="address"
+              href={`/address/${l.slug}`}
+              title={l.title}
+              subtitle="Claimed"
+              imageUrl={l.hero_image ?? undefined}
+              eyebrow="Owner-curated"
+            />
+          ))}
+        </Rail>
+      )}
+
+      <FilmsGridServer
+        heading={filmCopy.heading}
+        kicker={filmCopy.kicker}
+        limit={60}
+      />
+    </>
+  );
+}
diff --git a/src/app/parcel/[apn]/page.tsx b/src/app/parcel/[apn]/page.tsx
new file mode 100644
index 0000000..22cdd0d
--- /dev/null
+++ b/src/app/parcel/[apn]/page.tsx
@@ -0,0 +1,249 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { pool } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { SourceLink } from '@/components/SourceLink';
+import { PermitTable, CodeEnforcementTable } from '@/components/PermitTable';
+import type { RealPermit, CodeEnforcementCase } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+function normalizeApn(input: string): string {
+  return input.replace(/[^0-9]/g, '');
+}
+
+function formatApn(apn: string): string {
+  // 4349040002 → 4349-040-002 (LA County standard)
+  if (apn.length !== 10) return apn;
+  return `${apn.slice(0, 4)}-${apn.slice(4, 7)}-${apn.slice(7, 10)}`;
+}
+
+export async function generateMetadata({ params }: { params: { apn: string } }): Promise<Metadata> {
+  const apn = normalizeApn(params.apn);
+  return {
+    title: `Parcel ${formatApn(apn)}`,
+    description: `LA County parcel ${formatApn(apn)} — assessor record, permits, code enforcement.`,
+    alternates: { canonical: `/parcel/${formatApn(apn)}` },
+    robots: { index: true, follow: true },
+  };
+}
+
+type ParcelRow = {
+  id: string;
+  apn: string;
+  jurisdiction: string | null;
+  situs_full: string | null;
+  city: string | null;
+  zip_code: string | null;
+  use_code: string | null;
+  use_desc: string | null;
+  year_built: number | null;
+  bedrooms: number | null;
+  bathrooms: string | null;
+  bldg_sqft: number | null;
+  assessed_total: string | null;
+  assessed_land: string | null;
+  assessed_improvements: string | null;
+  roll_year: number | null;
+  latitude: string | null;
+  longitude: string | null;
+  current_listing_id: string | null;
+  source_url: string | null;
+};
+
+async function getParcel(apn: string): Promise<ParcelRow[]> {
+  const { rows } = await pool.query<ParcelRow>(
+    `SELECT id, apn, jurisdiction, situs_full, city, zip_code, use_code, use_desc,
+            year_built, bedrooms, bathrooms::text as bathrooms, bldg_sqft,
+            assessed_total::text as assessed_total,
+            assessed_land::text as assessed_land,
+            assessed_improvements::text as assessed_improvements,
+            roll_year, latitude::text as latitude, longitude::text as longitude,
+            current_listing_id, source_url
+     FROM parcel
+     WHERE apn = $1
+     ORDER BY roll_year DESC NULLS LAST`,
+    [apn]
+  );
+  return rows;
+}
+
+async function getPermitsForApn(apn: string): Promise<RealPermit[]> {
+  const { rows } = await pool.query<RealPermit>(
+    `SELECT DISTINCT ON (permit_number) id, source, source_dataset, permit_number, primary_address,
+            permit_group, permit_type, permit_sub_type, use_desc,
+            issue_date::text as issue_date, status_desc, valuation::text as valuation,
+            work_desc, source_url, source_label
+     FROM permit
+     WHERE apn = $1
+     ORDER BY permit_number, issue_date DESC NULLS LAST
+     LIMIT 100`,
+    [apn]
+  );
+  return rows.sort((a, b) => (b.issue_date ?? '').localeCompare(a.issue_date ?? ''));
+}
+
+async function getCodeForApn(apn: string): Promise<CodeEnforcementCase[]> {
+  const { rows } = await pool.query<CodeEnforcementCase>(
+    `SELECT id, source_dataset, case_number, case_type, status,
+            opened_at::text as opened_at, source_url, source_label
+     FROM code_enforcement_case
+     WHERE apn = $1
+     ORDER BY opened_at DESC NULLS LAST
+     LIMIT 25`,
+    [apn]
+  );
+  return rows;
+}
+
+function fmt$(s: string | null): string {
+  if (!s) return '—';
+  const n = parseFloat(s);
+  if (!isFinite(n) || n === 0) return '—';
+  return `$${n.toLocaleString()}`;
+}
+
+export default async function ParcelPage({ params }: { params: { apn: string } }) {
+  const apnRaw = params.apn;
+  const apn = normalizeApn(apnRaw);
+  if (apn.length !== 10) notFound();
+
+  const [parcels, permits, codeCases] = await Promise.all([
+    getParcel(apn),
+    getPermitsForApn(apn),
+    getCodeForApn(apn),
+  ]);
+
+  const latest = parcels[0] ?? null;
+  const assessorPortalUrl = `https://portal.assessor.lacounty.gov/parceldetail/${apn}`;
+  const formattedApn = formatApn(apn);
+  const inDb = !!latest;
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Parcel', href: '/search?kind=parcel' }, { label: formattedApn }]} />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">LA County parcel</p>
+        <h1 className="font-display text-5xl md:text-7xl text-ink tracking-[-0.02em] leading-[1.0] tabular-nums">
+          {formattedApn}
+        </h1>
+        {latest?.situs_full ? (
+          <p className="mt-4 font-display italic text-2xl text-ink/70">{latest.situs_full}</p>
+        ) : !inDb ? (
+          <p className="mt-4 font-display italic text-xl text-ink/55">
+            APN not in our 2021-Present roll snapshot. View the canonical record on the Assessor portal →
+          </p>
+        ) : null}
+        <div className="mt-6">
+          <SourceLink
+            label="LA County Assessor Property Detail"
+            url={assessorPortalUrl}
+            tier="A"
+          />
+        </div>
+      </header>
+
+      {inDb && latest && (
+        <section className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-5">
+            Latest roll record <span className="font-mono text-sm text-ink/45 ml-2">({latest.roll_year ?? '—'})</span>
+          </h2>
+          <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+            <Fact label="Year built" value={latest.year_built ?? '—'} />
+            <Fact label="Bldg sqft" value={latest.bldg_sqft != null ? latest.bldg_sqft.toLocaleString() : '—'} />
+            <Fact label="Bedrooms" value={latest.bedrooms ?? '—'} />
+            <Fact label="Bathrooms" value={latest.bathrooms ?? '—'} />
+            <Fact label="City" value={latest.city ?? '—'} />
+            <Fact label="ZIP" value={latest.zip_code ?? '—'} />
+            <Fact label="Use code" value={latest.use_code ?? '—'} />
+            <Fact label="Use" value={latest.use_desc ?? '—'} />
+            <Fact label="Assessed total" value={fmt$(latest.assessed_total)} highlight />
+            <Fact label="Land value" value={fmt$(latest.assessed_land)} />
+            <Fact label="Improvements" value={fmt$(latest.assessed_improvements)} />
+            <Fact label="Jurisdiction" value={latest.jurisdiction ?? '—'} />
+          </div>
+        </section>
+      )}
+
+      {parcels.length > 1 && (
+        <section className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-5">
+            Roll history <span className="font-mono text-sm text-ink/45 ml-2">{parcels.length} years</span>
+          </h2>
+          <div className="bg-white border border-ink/10 overflow-x-auto">
+            <table className="w-full text-sm">
+              <thead className="bg-ink/[0.04] text-[10px] uppercase tracking-[0.15em] text-ink/55">
+                <tr>
+                  <th className="text-left p-3">roll year</th>
+                  <th className="text-right p-3">total AV</th>
+                  <th className="text-right p-3">land</th>
+                  <th className="text-right p-3">improvements</th>
+                  <th className="text-left p-3">use</th>
+                </tr>
+              </thead>
+              <tbody>
+                {parcels.map(p => (
+                  <tr key={p.id} className="border-t border-ink/5">
+                    <td className="p-3 font-mono">{p.roll_year ?? '—'}</td>
+                    <td className="p-3 text-right tabular-nums font-mono">{fmt$(p.assessed_total)}</td>
+                    <td className="p-3 text-right tabular-nums font-mono text-ink/65">{fmt$(p.assessed_land)}</td>
+                    <td className="p-3 text-right tabular-nums font-mono text-ink/65">{fmt$(p.assessed_improvements)}</td>
+                    <td className="p-3 text-ink/65">{p.use_desc ?? '—'}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        </section>
+      )}
+
+      <PermitTable permits={permits} />
+      <CodeEnforcementTable cases={codeCases} />
+
+      <section className="max-w-6xl mx-auto px-6 py-12 border-t border-ink/10">
+        <h2 className="font-display text-2xl text-ink tracking-[-0.01em] mb-3">External records</h2>
+        <ul className="space-y-2 text-sm">
+          <li>
+            <a href={assessorPortalUrl} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
+              Assessor Property Detail (PAIS) ↗
+            </a>
+            <span className="text-ink/45 ml-2">— official current value, owner, characteristics</span>
+          </li>
+          <li>
+            <a href={`https://lavote.gov/home/recorder/online-public-records-search?apn=${apn}`} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
+              LA Recorder/County Clerk index ↗
+            </a>
+            <span className="text-ink/45 ml-2">— deeds, liens (in-person index only, free)</span>
+          </li>
+          <li>
+            <a href={`https://zimas.lacity.org/?address=&apn=${apn}`} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
+              ZIMAS zoning lookup ↗
+            </a>
+            <span className="text-ink/45 ml-2">— LA City zoning, historic flags, planning cases</span>
+          </li>
+          <li>
+            <a href={`https://www.ladbsservices2.lacity.org/OnlineServices/PermitReport/PermitReport?apn=${apn}`} target="_blank" rel="noreferrer noopener" className="text-coral hover:underline">
+              LADBS Permit Report ↗
+            </a>
+            <span className="text-ink/45 ml-2">— LA City permits, inspections, complaints</span>
+          </li>
+        </ul>
+      </section>
+    </>
+  );
+}
+
+function Fact({ label, value, highlight = false }: { label: string; value: string | number; highlight?: boolean }) {
+  return (
+    <div className="border-l-2 border-ink/10 pl-3">
+      <p className="text-[10px] uppercase tracking-[0.15em] text-ink/50">{label}</p>
+      <p className={`font-display ${highlight ? 'text-2xl text-coral' : 'text-xl text-ink'} mt-1 tabular-nums`}>
+        {value}
+      </p>
+    </div>
+  );
+}
diff --git a/src/app/people/page.tsx b/src/app/people/page.tsx
new file mode 100644
index 0000000..bb5367d
--- /dev/null
+++ b/src/app/people/page.tsx
@@ -0,0 +1,120 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { listEntitiesWithCounts } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { CrossPromo } from '@/components/CrossPromo';
+import { currentSurface } from '@/lib/site';
+
+export const metadata: Metadata = {
+  title: 'People',
+  description: 'Architects, residents, and studios in the archive — historical only.',
+  alternates: { canonical: '/people' },
+};
+
+const KIND_LABEL: Record<string, string> = {
+  architect: 'Architects',
+  studio: 'Studios',
+  person: 'People',
+  business: 'Businesses',
+};
+
+export default async function PeopleIndex() {
+  const surface = await currentSurface();
+  const all = await listEntitiesWithCounts();
+
+  // Featured = entities with the most public associations; max 4 across kinds.
+  const featured = [...all].sort((a, b) => b.oeuvre_count - a.oeuvre_count).slice(0, 4);
+  const grouped: Record<string, typeof all> = {};
+  for (const e of all) {
+    if (!grouped[e.kind]) grouped[e.kind] = [];
+    grouped[e.kind]!.push(e);
+  }
+
+  return (
+    <>
+      <div className="max-w-5xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'People' }]} />
+      </div>
+
+      <header className="max-w-5xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">People</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          The people in the archive.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Historical only. Architects, studios, and prior occupants whose ties to a place are documented.
+        </p>
+      </header>
+
+      {featured.length > 0 && featured[0]!.oeuvre_count > 0 && (
+        <section className="max-w-5xl mx-auto px-6 py-10 border-b border-ink/10">
+          <h2 className="font-display text-2xl text-ink tracking-[-0.01em] mb-5">Most cited</h2>
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+            {featured.map(e => (
+              <Link
+                key={e.id}
+                href={`/entity/${e.slug}`}
+                className="block border border-ink/10 bg-sand p-5 hover:border-ink/40 transition group"
+              >
+                <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45">
+                  {e.kind}
+                </p>
+                <h3 className="font-display text-xl text-ink leading-tight mt-1 group-hover:text-coral transition">
+                  {e.display_name}
+                </h3>
+                <p className="font-mono text-xs text-ink/50 mt-1">
+                  {e.birth_year ?? '?'}–{e.death_year ?? '?'}
+                </p>
+                <p className="mt-3 font-display text-3xl text-ink/80 group-hover:text-coral">
+                  {e.oeuvre_count}
+                  <span className="ml-1 font-sans text-[10px] uppercase tracking-[0.15em] text-ink/40">
+                    {e.oeuvre_count === 1 ? 'work' : 'works'}
+                  </span>
+                </p>
+              </Link>
+            ))}
+          </div>
+        </section>
+      )}
+
+      <section className="max-w-5xl mx-auto px-6 py-12">
+        {Object.entries(grouped).map(([kind, list]) => (
+          <div key={kind} className="mb-12">
+            <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-5">{KIND_LABEL[kind] ?? kind}</h2>
+            <ul className="grid md:grid-cols-2 gap-3">
+              {list.map(e => (
+                <li key={e.id}>
+                  <Link
+                    href={`/entity/${e.slug}`}
+                    className="block border border-ink/10 bg-sand p-4 hover:border-ink/40 transition group"
+                  >
+                    <div className="flex items-baseline justify-between gap-3">
+                      <div>
+                        <div className="font-display text-2xl text-ink leading-tight group-hover:text-coral">
+                          {e.display_name}
+                        </div>
+                        <div className="mt-1 text-xs text-ink/60 font-mono">
+                          {e.birth_year ?? '?'}–{e.death_year ?? '?'} · {e.kind}
+                        </div>
+                      </div>
+                      <span className="font-mono text-xs text-ink/50 whitespace-nowrap">
+                        {e.oeuvre_count} {e.oeuvre_count === 1 ? 'addr' : 'addrs'}
+                      </span>
+                    </div>
+                  </Link>
+                </li>
+              ))}
+            </ul>
+          </div>
+        ))}
+        <p className="mt-12 text-xs text-ink/40 max-w-prose leading-relaxed">
+          We do not include living persons. The privacy gate enforces this at the database level — any
+          association with a living person ending within the last 25 years cannot be made public, no
+          matter how it&rsquo;s entered.
+        </p>
+      </section>
+
+      <CrossPromo current={surface.key} />
+    </>
+  );
+}
diff --git a/src/app/promote/[slug]/PromoteFormClient.tsx b/src/app/promote/[slug]/PromoteFormClient.tsx
new file mode 100644
index 0000000..e876425
--- /dev/null
+++ b/src/app/promote/[slug]/PromoteFormClient.tsx
@@ -0,0 +1,163 @@
+'use client';
+import { useState } from 'react';
+
+export function PromoteFormClient({
+  listingId,
+  defaultHeadline,
+}: {
+  listingId: string;
+  defaultHeadline: string;
+}) {
+  const [status, setStatus] = useState<'idle' | 'submitting' | 'ok' | 'error'>('idle');
+  const [error, setError] = useState<string | null>(null);
+  const [submissionId, setSubmissionId] = useState<string | null>(null);
+
+  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
+    e.preventDefault();
+    setStatus('submitting');
+    setError(null);
+    const fd = new FormData(e.currentTarget);
+    const body = {
+      listing_id: listingId,
+      source: fd.get('source'),
+      external_url: fd.get('external_url'),
+      headline: fd.get('headline'),
+      price_label: fd.get('price_label') || undefined,
+      contact_email: fd.get('contact_email'),
+      affiliation: fd.get('affiliation'),
+      permit_number: fd.get('permit_number') || undefined,
+    };
+    const res = await fetch('/api/promote', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    if (!res.ok) {
+      const j = await res.json().catch(() => ({}));
+      setError(j.error ?? `HTTP ${res.status}`);
+      setStatus('error');
+      return;
+    }
+    const j = await res.json();
+    setSubmissionId(j.id);
+    setStatus('ok');
+  }
+
+  if (status === 'ok' && submissionId) {
+    return (
+      <div className="mt-10 border border-moss/30 bg-moss/5 p-6">
+        <p className="font-display text-2xl text-ink">Submission received.</p>
+        <p className="mt-2 text-sm text-ink/70 leading-relaxed">
+          Confirmation #{' '}
+          <span className="font-mono text-xs">{submissionId.slice(0, 8)}</span>
+          . We&rsquo;ll verify your affiliation within one business day and email you when your placement is approved.
+        </p>
+      </div>
+    );
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="mt-10 grid gap-5">
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        Listing source
+        <select
+          name="source"
+          required
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        >
+          <option value="">Select…</option>
+          <option value="zillow">Zillow</option>
+          <option value="redfin">Redfin</option>
+          <option value="airbnb">Airbnb</option>
+          <option value="other">Other</option>
+        </select>
+      </label>
+
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        Listing URL
+        <input
+          required
+          type="url"
+          name="external_url"
+          placeholder="https://www.zillow.com/homedetails/…"
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        />
+      </label>
+
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        Headline (max 200 chars)
+        <input
+          type="text"
+          name="headline"
+          required
+          maxLength={200}
+          defaultValue={defaultHeadline}
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        />
+      </label>
+
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        Price label (optional)
+        <input
+          type="text"
+          name="price_label"
+          maxLength={80}
+          placeholder="$4,250,000 · Open Saturday 1–4pm"
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        />
+      </label>
+
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        Your email
+        <input
+          type="email"
+          name="contact_email"
+          required
+          placeholder="agent@brokerage.com"
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        />
+      </label>
+
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        How are you affiliated with this address? (min 10 chars)
+        <textarea
+          required
+          name="affiliation"
+          rows={3}
+          minLength={10}
+          placeholder="Listing agent, owner, or representative. We verify before the placement goes live."
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        />
+      </label>
+
+      <label className="flex flex-col gap-1 text-[10px] uppercase tracking-[0.15em] text-ink/55">
+        Permit # (Beverly Hills / Santa Monica / etc., if required by your jurisdiction)
+        <input
+          type="text"
+          name="permit_number"
+          placeholder="Optional — self-attested"
+          className="border border-ink/20 px-3 py-2 text-sm font-sans focus:outline-none focus:border-moss bg-sand"
+        />
+      </label>
+
+      <button
+        type="submit"
+        disabled={status === 'submitting'}
+        className="bg-coral text-ink px-5 py-3 text-xs uppercase tracking-wider hover:bg-ink hover:text-sand transition self-start disabled:opacity-50"
+      >
+        {status === 'submitting' ? 'Submitting…' : 'Submit for review'}
+      </button>
+
+      {error && (
+        <p className="text-xs text-coral" role="alert">
+          {error}
+        </p>
+      )}
+
+      <p className="text-[11px] text-ink/45 max-w-prose leading-relaxed">
+        We&rsquo;ll verify the affiliation and load your placement within 1 business day. Stripe checkout sent
+        after approval. We do not write your listing into the historical ledger.
+      </p>
+    </form>
+  );
+}
diff --git a/src/app/promote/[slug]/page.tsx b/src/app/promote/[slug]/page.tsx
new file mode 100644
index 0000000..18f9af0
--- /dev/null
+++ b/src/app/promote/[slug]/page.tsx
@@ -0,0 +1,64 @@
+import { notFound } from 'next/navigation';
+import type { Metadata } from 'next';
+import { getListingBySlug } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { SponsoredCard } from '@/components/sponsored/SponsoredCard';
+import { PromoteFormClient } from './PromoteFormClient';
+import { FREE_FOREVER } from '@/lib/flags';
+import { RetiredFeatureNotice } from '@/components/RetiredFeatureNotice';
+
+export const metadata: Metadata = {
+  robots: { index: false, follow: false },
+};
+
+export default async function PromoteForm({ params }: { params: { slug: string } }) {
+  if (FREE_FOREVER) {
+    return (
+      <RetiredFeatureNotice
+        title="Promotion has been retired"
+      />
+    );
+  }
+  const listing = await getListingBySlug(params.slug);
+  if (!listing) notFound();
+
+  return (
+    <>
+      <div className="max-w-3xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive
+          items={[
+            { label: 'Archive', href: '/' },
+            { label: 'Promote', href: '/promote' },
+            { label: listing.title },
+          ]}
+        />
+      </div>
+
+      <section className="max-w-3xl mx-auto px-6 py-10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Sponsored placement</p>
+        <h1 className="font-display text-5xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Promote {listing.title}
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          $29/mo flat. Renders on the address page above the timeline. Editorial-firewalled by design.
+        </p>
+
+        <PromoteFormClient
+          listingId={listing.id}
+          defaultHeadline={`${listing.title} — for sale`}
+        />
+
+        <div className="mt-14">
+          <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-3">Preview — what readers will see</p>
+          <SponsoredCard
+            source="zillow"
+            title={`${listing.title} — for sale`}
+            priceLabel="$4,250,000 · Open Saturday 1–4pm"
+            url="#"
+            imageUrl={listing.hero_image ?? undefined}
+          />
+        </div>
+      </section>
+    </>
+  );
+}
diff --git a/src/app/promote/page.tsx b/src/app/promote/page.tsx
new file mode 100644
index 0000000..13ae552
--- /dev/null
+++ b/src/app/promote/page.tsx
@@ -0,0 +1,63 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { searchListings } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { NodeCard } from '@/components/NodeCard';
+import { FREE_FOREVER } from '@/lib/flags';
+import { RetiredFeatureNotice } from '@/components/RetiredFeatureNotice';
+
+export const metadata: Metadata = {
+  robots: { index: false, follow: false },
+};
+
+export default async function PromoteIndex() {
+  if (FREE_FOREVER) {
+    return (
+      <RetiredFeatureNotice
+        title="Promotion has been retired"
+      />
+    );
+  }
+  const recent = await searchListings({ limit: 6 });
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Promote a listing' }]} />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Sponsored placements</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02] max-w-4xl">
+          Put your current listing on top of the record.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Walled off from editorial. Logo of source (Zillow / Redfin / Airbnb). $29/mo flat.
+        </p>
+      </header>
+
+      <section className="max-w-6xl mx-auto px-6 py-10">
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em] mb-6">
+          Pick the address you&rsquo;re promoting
+        </h2>
+        <p className="text-sm text-ink/60 mb-8 max-w-prose leading-relaxed">
+          Choose an existing record. We&rsquo;ll verify ownership or agent representation before the placement goes
+          live. If your address isn&rsquo;t in the archive yet, <Link href="/browse" className="underline-offset-2 hover:underline text-coral">submit it first</Link>.
+        </p>
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
+          {recent.map(l => (
+            <NodeCard
+              key={l.id}
+              kind="address"
+              href={`/promote/${l.slug}`}
+              title={l.title}
+              subtitle={l.address_line1 ?? l.city ?? undefined}
+              imageUrl={l.hero_image ?? undefined}
+              eyebrow="Promote this address"
+            />
+          ))}
+        </div>
+      </section>
+    </>
+  );
+}
diff --git a/src/app/restaurants/[slug]/page.tsx b/src/app/restaurants/[slug]/page.tsx
new file mode 100644
index 0000000..2c0d98e
--- /dev/null
+++ b/src/app/restaurants/[slug]/page.tsx
@@ -0,0 +1,283 @@
+/**
+ * /restaurants/[slug] — Restaurant detail with sightings list
+ *
+ * Per PLAN.md privacy guardrail: each sighting carries source_tier + source_url
+ * + date_range or era. Sightings are CLAIMS, not eternal truths. Public
+ * sources only — no tabloid rumor without an A/B/C tier citation.
+ */
+import Link from 'next/link';
+import { cache } from 'react';
+import { notFound } from 'next/navigation';
+import type { Metadata } from 'next';
+import { pool } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { MiniMap } from '@/components/MiniMap';
+
+type Restaurant = {
+  id: string;
+  slug: string;
+  name: string;
+  cuisine: string | null;
+  address: string | null;
+  city: string | null;
+  state: string | null;
+  zip: string | null;
+  phone: string | null;
+  lat: number | null;
+  lng: number | null;
+  opened_year: number | null;
+  closed_year: number | null;
+  blurb: string | null;
+  source_url: string | null;
+  hero_image: string | null;
+  hero_image_credit: string | null;
+  hero_image_license: string | null;
+  hero_image_source_url: string | null;
+};
+
+function formatPhone(p: string): string {
+  const m = p.match(/^\+1[-\s]?(\d{3})[-\s]?(\d{3})[-\s]?(\d{4})$/);
+  return m ? `(${m[1]}) ${m[2]}-${m[3]}` : p;
+}
+
+type Sighting = {
+  id: string;
+  person_name: string;
+  entity_slug: string | null;
+  entity_kind: string | null;
+  era: string | null;
+  seen_date: string | null;
+  notes: string | null;
+  source_tier: 'A' | 'B' | 'C' | 'D';
+  source_url: string | null;
+  source_label: string | null;
+};
+
+// Wrapped in React cache() so generateMetadata + the page component share
+// one DB hit per request rather than firing two identical queries.
+const getRestaurant = cache(async function getRestaurant(slug: string): Promise<Restaurant | null> {
+  const { rows } = await pool.query<Restaurant>(
+    `SELECT id, slug, name, cuisine, address, city, state, zip, phone,
+            lat::float AS lat, lng::float AS lng,
+            opened_year, closed_year, blurb, source_url, hero_image,
+            hero_image_credit, hero_image_license, hero_image_source_url
+     FROM restaurant WHERE slug = $1 LIMIT 1`,
+    [slug]
+  );
+  return rows[0] ?? null;
+});
+
+async function getSightings(restaurantId: string): Promise<Sighting[]> {
+  const { rows } = await pool.query<Sighting>(
+    `SELECT s.id, s.person_name,
+            e.slug AS entity_slug, e.kind AS entity_kind,
+            s.era, s.seen_date::text, s.notes,
+            s.source_tier, s.source_url, s.source_label
+     FROM restaurant_sighting s
+     LEFT JOIN entity e ON e.id = s.entity_id
+     WHERE s.restaurant_id = $1 AND s.public_visible = true
+     ORDER BY s.person_name`,
+    [restaurantId]
+  );
+  return rows;
+}
+
+export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
+  const { slug } = await params;
+  const r = await getRestaurant(slug);
+  if (!r) return { title: 'Restaurant not found' };
+  return {
+    title: `${r.name} — celebrity sightings`,
+    description: r.blurb ?? `${r.name} in ${r.city ?? 'Los Angeles'}: documented celebrity patrons, sourced and dated.`,
+    alternates: { canonical: `/restaurants/${r.slug}` },
+  };
+}
+
+const TIER_LABEL: Record<Sighting['source_tier'], string> = {
+  A: 'Government',
+  B: 'Archive',
+  C: 'Verified',
+  D: 'Tabloid',
+};
+const TIER_COLOR: Record<Sighting['source_tier'], string> = {
+  A: '#1f4d40',
+  B: '#1f4d40',
+  C: '#7a6a3f',
+  D: '#999',
+};
+
+export default async function RestaurantPage({ params }: { params: Promise<{ slug: string }> }) {
+  const { slug } = await params;
+  const r = await getRestaurant(slug);
+  if (!r) notFound();
+  const sightings = await getSightings(r.id);
+
+  const yearLine = `${r.opened_year ?? '?'}${r.closed_year ? `–${r.closed_year} (closed)` : '–present'}`;
+
+  return (
+    <>
+      <div className="max-w-3xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[
+          { label: 'Archive', href: '/' },
+          { label: 'Restaurants', href: '/restaurants' },
+          { label: r.name },
+        ]} />
+      </div>
+
+      <header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/55 mb-3 font-mono">
+          {r.cuisine ? `${r.cuisine} · ` : ''}{yearLine}
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          {r.name}
+        </h1>
+        <p className="mt-3 text-ink/70 text-lg">
+          {r.address}{r.city ? `, ${r.city}` : ''}{r.state ? `, ${r.state}` : ''}
+        </p>
+        {r.phone && (
+          <p className="mt-1.5 font-mono text-sm text-ink/65 tabular-nums">
+            <a
+              href={`tel:${r.phone}`}
+              className="hover:text-coral underline-offset-2 hover:underline"
+              aria-label={`Call ${r.name}`}
+            >
+              {formatPhone(r.phone)}
+            </a>
+          </p>
+        )}
+        {r.blurb && (
+          <p className="mt-5 font-display italic text-xl text-ink/75 max-w-prose leading-snug">
+            {r.blurb}
+          </p>
+        )}
+        {r.source_url && (
+          <p className="mt-3 text-[12px] font-mono text-ink/50">
+            <a href={r.source_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-coral">
+              Restaurant source
+            </a>
+          </p>
+        )}
+      </header>
+
+      {r.hero_image && (
+        <figure className="max-w-3xl mx-auto px-6 pt-8">
+          {/* Use a plain <img> rather than next/image: archive thumbs come from
+              many domains (LAPL, Wikimedia, USC, Calisphere, LoC) and we don't
+              want to maintain a remotePatterns allowlist for the long tail. */}
+          {/* eslint-disable-next-line @next/next/no-img-element */}
+          <img
+            src={r.hero_image}
+            alt={`${r.name} — historical photo`}
+            className="w-full h-auto block"
+            loading="lazy"
+          />
+          <figcaption className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/55 leading-relaxed">
+            {r.hero_image_credit && <span>{r.hero_image_credit}</span>}
+            {r.hero_image_license && (
+              <span className="text-ink/45"> · {r.hero_image_license}</span>
+            )}
+            {r.hero_image_source_url && (
+              <>
+                {' '}·{' '}
+                <a
+                  href={r.hero_image_source_url}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="underline hover:text-coral"
+                >
+                  archive record
+                </a>
+              </>
+            )}
+          </figcaption>
+        </figure>
+      )}
+
+      {r.lat != null && r.lng != null && (
+        <section className="max-w-3xl mx-auto px-6 py-8">
+          <MiniMap
+            pins={[{ lat: Number(r.lat), lng: Number(r.lng), label: r.name, featured: true }]}
+            height={280}
+            caption={`${r.address ?? ''}${r.city ? ', ' + r.city : ''}`}
+          />
+        </section>
+      )}
+
+      <section className="max-w-3xl mx-auto px-6 py-10">
+        <h2 className="font-mono text-[11px] uppercase tracking-[0.18em] text-ink/55 mb-6">
+          Documented sightings · {sightings.length}
+        </h2>
+        {sightings.length === 0 ? (
+          <p className="font-display italic text-xl text-ink/55 py-8">
+            No sourced sightings yet. Add one with a citation.
+          </p>
+        ) : (
+          <ol className="divide-y divide-ink/10">
+            {sightings.map(s => {
+              const inner = (
+                <div className="grid grid-cols-[1fr_auto] gap-4 py-5 items-baseline">
+                  <div>
+                    <h3 className="font-display text-2xl text-ink group-hover:text-coral transition">
+                      {s.person_name}
+                    </h3>
+                    {s.notes && (
+                      <p className="mt-1 text-sm text-ink/70 leading-snug">{s.notes}</p>
+                    )}
+                    <p className="mt-1.5 flex flex-wrap items-center gap-2 text-[11px] font-mono">
+                      <span
+                        className="px-1.5 py-0.5 text-white uppercase tracking-[0.15em] text-[9px] font-bold"
+                        style={{ background: TIER_COLOR[s.source_tier] }}
+                        title={`Source tier ${s.source_tier} — ${TIER_LABEL[s.source_tier]}`}
+                      >
+                        {TIER_LABEL[s.source_tier]}
+                      </span>
+                      {s.source_url ? (
+                        <a
+                          href={s.source_url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="text-ink/55 italic underline hover:text-coral"
+                        >
+                          {s.source_label ?? 'source'}
+                        </a>
+                      ) : (
+                        <span className="text-ink/45 italic">{s.source_label ?? 'unsourced'}</span>
+                      )}
+                    </p>
+                  </div>
+                  <div className="text-right">
+                    <p className="font-mono text-xs text-ink/60 tabular-nums">
+                      {s.seen_date ? s.seen_date.slice(0, 10) : (s.era ?? '—')}
+                    </p>
+                    {s.entity_slug && (
+                      <p className="font-mono text-[10px] text-ink/40 mt-1 uppercase tracking-[0.15em]">
+                        {s.entity_kind ?? 'person'}
+                      </p>
+                    )}
+                  </div>
+                </div>
+              );
+              return (
+                <li key={s.id}>
+                  {s.entity_slug ? (
+                    <Link href={`/entity/${s.entity_slug}`} className="group block hover:bg-[#fdf9ee] -mx-3 px-3 transition">
+                      {inner}
+                    </Link>
+                  ) : (
+                    /* Non-interactive: drop `group` so inner h3's group-hover doesn't mislead */
+                    <div className="block">{inner}</div>
+                  )}
+                </li>
+              );
+            })}
+          </ol>
+        )}
+        <p className="mt-8 text-[11px] font-mono text-ink/40 leading-relaxed border-t border-ink/10 pt-6">
+          Sightings are claims, not eternal truths. Each carries a source tier
+          (A=govt, B=archive, C=verified secondary, D=tabloid) and a citation.
+          Spot something wrong? <Link href="/submit" className="underline">Submit a correction</Link>.
+        </p>
+      </section>
+    </>
+  );
+}
diff --git a/src/app/restaurants/page.tsx b/src/app/restaurants/page.tsx
new file mode 100644
index 0000000..17f5c2b
--- /dev/null
+++ b/src/app/restaurants/page.tsx
@@ -0,0 +1,179 @@
+/**
+ * /restaurants — Map of celebrity restaurants
+ *
+ * Curated list of LA restaurants with documented celebrity sightings, sourced
+ * from Wikipedia + LA Times + restaurant histories. Per PLAN.md privacy
+ * guardrail: each sighting carries source_tier + source_url + date/era.
+ * Sightings are CLAIMS, not eternal truths.
+ *
+ * Restaurant pin → click → /restaurants/[slug] for the patron list.
+ */
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { pool } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+
+export const metadata: Metadata = {
+  title: 'Celebrity restaurants',
+  description:
+    'A map of Los Angeles restaurants where movie stars actually ate — Musso & Frank to Dan Tana\'s to The Ivy. Each sighting is sourced and dated.',
+  alternates: { canonical: '/restaurants' },
+};
+
+type RestRow = {
+  id: string;
+  slug: string;
+  name: string;
+  cuisine: string | null;
+  address: string | null;
+  city: string | null;
+  phone: string | null;
+  lat: number | null;
+  lng: number | null;
+  opened_year: number | null;
+  closed_year: number | null;
+  blurb: string | null;
+  sighting_count: number;
+  top_patrons: string[];
+};
+
+function formatPhone(p: string): string {
+  // E.164-ish "+1-323-467-7788" → "(323) 467-7788" for US numbers; otherwise return as-is.
+  const m = p.match(/^\+1[-\s]?(\d{3})[-\s]?(\d{3})[-\s]?(\d{4})$/);
+  return m ? `(${m[1]}) ${m[2]}-${m[3]}` : p;
+}
+
+async function getRestaurants(): Promise<RestRow[]> {
+  try {
+    const { rows } = await pool.query<RestRow>(`
+      SELECT r.id, r.slug, r.name, r.cuisine, r.address, r.city, r.phone,
+             r.lat::float AS lat, r.lng::float AS lng,
+             r.opened_year, r.closed_year, r.blurb,
+             COUNT(s.id)::int AS sighting_count,
+             COALESCE(
+               (SELECT array_agg(person_name)
+                FROM (
+                  SELECT DISTINCT person_name
+                  FROM restaurant_sighting
+                  WHERE restaurant_id = r.id AND public_visible = true
+                  ORDER BY person_name
+                  LIMIT 4
+                ) t),
+               ARRAY[]::text[]
+             ) AS top_patrons
+      FROM restaurant r
+      LEFT JOIN restaurant_sighting s
+        ON s.restaurant_id = r.id AND s.public_visible = true
+      GROUP BY r.id
+      ORDER BY r.opened_year ASC NULLS LAST, r.name
+    `);
+    return rows;
+  } catch (e) {
+    console.error('[restaurants/page] DB error:', e);
+    return [];
+  }
+}
+
+export default async function RestaurantsPage() {
+  const rests = await getRestaurants();
+
+  const pins: MapPin[] = rests
+    .filter(r => r.lat != null && r.lng != null)
+    .map(r => ({
+      slug: r.slug,
+      title: r.name,
+      subtitle: `${r.city ?? ''} · ${r.sighting_count} sightings`,
+      lat: Number(r.lat),
+      lng: Number(r.lng),
+      href: `/restaurants/${r.slug}`,
+    }));
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Restaurants' }]} />
+      </div>
+
+      <header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/55 mb-3 font-mono">
+          Where they ate · sourced sightings
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Celebrity restaurants.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Musso &amp; Frank to The Ivy — the rooms where movie stars actually
+          showed up. Each sighting is dated and cited.
+        </p>
+        <p className="mt-3 text-sm text-ink/55 max-w-prose">
+          {rests.length} restaurants on the map · {' '}
+          {rests.reduce((n, r) => n + r.sighting_count, 0)} documented sightings.
+          Every claim links to its source — Wikipedia, LATimes, restaurant
+          histories. We don&rsquo;t do tabloid rumor.
+        </p>
+      </header>
+
+      {pins.length > 0 && (
+        <section className="max-w-6xl mx-auto px-6 py-8">
+          <InteractiveMapMount pins={pins} height={520} variant="positron" />
+          <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+            Click any pin for the patron list and source citations
+          </p>
+        </section>
+      )}
+
+      <section className="max-w-3xl mx-auto px-6 py-10">
+        <ol className="divide-y divide-ink/10">
+          {rests.map(r => (
+            <li key={r.slug}>
+              <div className="group grid grid-cols-[1fr_auto] gap-4 py-5 items-baseline hover:bg-[#fdf9ee] -mx-3 px-3 transition">
+                <div>
+                  <Link href={`/restaurants/${r.slug}`} className="block">
+                    <h2 className="font-display text-3xl text-ink tracking-[-0.01em] group-hover:text-coral transition">
+                      {r.name}
+                    </h2>
+                  </Link>
+                  <p className="mt-1 text-sm text-ink/70">
+                    {r.cuisine ? <span>{r.cuisine} · </span> : null}
+                    {r.address}{r.city ? `, ${r.city}` : ''}
+                  </p>
+                  {r.phone && (
+                    <p className="mt-1 text-[12px] font-mono text-ink/65 tabular-nums">
+                      <a
+                        href={`tel:${r.phone}`}
+                        className="hover:text-coral underline-offset-2 hover:underline"
+                        aria-label={`Call ${r.name}`}
+                      >
+                        {formatPhone(r.phone)}
+                      </a>
+                    </p>
+                  )}
+                  {r.top_patrons.length > 0 && (
+                    <p className="mt-1 text-[12px] font-mono text-ink/55 italic">
+                      seen: {r.top_patrons.join(' · ')}
+                      {r.sighting_count > r.top_patrons.length ? ` +${r.sighting_count - r.top_patrons.length} more` : ''}
+                    </p>
+                  )}
+                </div>
+                <Link href={`/restaurants/${r.slug}`} className="text-right block">
+                  <p className="font-mono text-xs text-ink/60 tabular-nums">
+                    {r.opened_year ?? '?'}{r.closed_year ? `–${r.closed_year}` : '–present'}
+                  </p>
+                  <p className="font-mono text-[10px] text-ink/40 mt-1">
+                    {r.sighting_count} sighting{r.sighting_count === 1 ? '' : 's'}
+                  </p>
+                </Link>
+              </div>
+            </li>
+          ))}
+        </ol>
+        {rests.length === 0 && (
+          <p className="font-display italic text-2xl text-ink/55 text-center py-12">
+            The list is loading. Restaurants are added as sightings are sourced.
+          </p>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..593cb4c
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,30 @@
+import type { MetadataRoute } from 'next';
+import { headers } from 'next/headers';
+import { siteForHost } from '@/lib/site';
+import { FREE_FOREVER } from '@/lib/flags';
+
+/**
+ * robots.txt — host-aware. Each surface (wholivedthere/claimmyaddress/bubbesblock)
+ * gets its own robots block rooted at its base URL. Owner/admin/promo flows
+ * disallowed; retired pay surfaces (/hosts, /promote) disallowed when FREE_FOREVER.
+ */
+export default async function robots(): Promise<MetadataRoute.Robots> {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const base = process.env.NEXT_PUBLIC_BASE_URL ?? `https://${surface.domain}`;
+
+  const baseDisallow = ['/api/', '/dashboard', '/claim/', '/submit', '/browse?'];
+  const retiredDisallow = FREE_FOREVER ? ['/hosts', '/promote/', '/promote'] : [];
+
+  return {
+    rules: [
+      {
+        userAgent: '*',
+        allow: ['/'],
+        disallow: [...baseDisallow, ...retiredDisallow],
+      },
+    ],
+    sitemap: `${base}/sitemap.xml`,
+    host: base,
+  };
+}
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
new file mode 100644
index 0000000..0bc8863
--- /dev/null
+++ b/src/app/search/page.tsx
@@ -0,0 +1,168 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { headers } from 'next/headers';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { siteForHost } from '@/lib/site';
+
+export const metadata: Metadata = { title: 'Search', alternates: { canonical: '/search' } };
+export const dynamic = 'force-dynamic';
+
+type Result = {
+  kind: string;
+  id: string;
+  title: string;
+  subtitle?: string | null;
+  snippet?: string | null;
+  href: string;
+  source_label?: string | null;
+  source_url?: string | null;
+  date?: string | null;
+};
+
+const KIND_LABEL: Record<string, string> = {
+  address: 'Address',
+  permit: 'Building permit',
+  film: 'Film / TV',
+  news: 'Newspaper page',
+  mention: 'Address mention',
+  entity: 'Person',
+  parcel: 'Parcel',
+};
+
+const KIND_COLOR: Record<string, string> = {
+  address: 'border-moss text-moss',
+  permit: 'border-ink/40 text-ink/70',
+  film: 'border-coral text-coral',
+  news: 'border-purple-700 text-purple-700',
+  mention: 'border-amber-700 text-amber-700',
+  entity: 'border-blue-700 text-blue-700',
+  parcel: 'border-emerald-700 text-emerald-700',
+};
+
+function renderSnippet(snippet: string) {
+  const parts = snippet.split(/(<\/?mark>)/g);
+  let marked = false;
+  return parts.map((part, index) => {
+    if (part === '<mark>') {
+      marked = true;
+      return null;
+    }
+    if (part === '</mark>') {
+      marked = false;
+      return null;
+    }
+    return marked ? <mark key={index}>{part}</mark> : part;
+  });
+}
+
+async function doSearch(q: string, kind: string | undefined): Promise<Result[]> {
+  if (!q) return [];
+  const h = await headers();
+  const host = h.get('host') ?? 'localhost:9821';
+  const proto = host.includes('localhost') ? 'http' : 'https';
+  const url = `${proto}://${host}/api/search?q=${encodeURIComponent(q)}${kind ? `&kind=${kind}` : ''}`;
+  const r = await fetch(url, { cache: 'no-store' });
+  if (!r.ok) return [];
+  const j = await r.json();
+  return j.results ?? [];
+}
+
+export default async function SearchPage({
+  searchParams,
+}: {
+  searchParams: { q?: string; kind?: string };
+}) {
+  const q = searchParams.q?.trim() ?? '';
+  const kind = searchParams.kind;
+  const results = await doSearch(q, kind);
+  const h = await headers();
+  const surface = siteForHost(h.get('host') ?? 'wholivedthere.com');
+  const byKind: Record<string, Result[]> = {};
+  for (const r of results) (byKind[r.kind] ??= []).push(r);
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Search' }]} />
+      </div>
+
+      <header className="max-w-6xl mx-auto px-6 py-10 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Search the archive</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          {q ? <>Results for &ldquo;{q}&rdquo;</> : 'What are you looking for?'}
+        </h1>
+        <p className="mt-3 text-sm text-ink/60">
+          Address · permit · film · person · parcel · newspaper OCR. Searches every field across {surface.brandName}.
+        </p>
+        <form className="mt-6 flex gap-3" action="/search">
+          <input
+            name="q"
+            type="search"
+            defaultValue={q}
+            placeholder="Address, name, movie, permit number, anything…"
+            className="flex-1 max-w-2xl border border-ink/20 bg-sand px-4 py-3 text-base font-sans focus:outline-none focus:border-moss"
+          />
+          <button type="submit" className="bg-ink text-sand px-6 py-3 text-xs uppercase tracking-wider hover:bg-moss transition">
+            Search
+          </button>
+        </form>
+        {q && results.length > 0 && (
+          <div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
+            <span className="text-ink/50 uppercase tracking-[0.15em]">Filter:</span>
+            <Link href={`/search?q=${encodeURIComponent(q)}`} className={`px-3 py-1 border ${!kind ? 'border-ink bg-ink text-sand' : 'border-ink/20 hover:border-ink/50'}`}>
+              All ({results.length})
+            </Link>
+            {Object.entries(byKind).map(([k, list]) => (
+              <Link key={k} href={`/search?q=${encodeURIComponent(q)}&kind=${k}`}
+                 className={`px-3 py-1 border ${kind === k ? 'border-ink bg-ink text-sand' : `${KIND_COLOR[k] ?? 'border-ink/20'} hover:bg-ink/5`}`}>
+                {KIND_LABEL[k] ?? k} ({list.length})
+              </Link>
+            ))}
+          </div>
+        )}
+      </header>
+
+      <section className="max-w-6xl mx-auto px-6 py-10">
+        {q && results.length === 0 ? (
+          <p className="text-sm text-ink/55 italic py-10">No results across listings, permits, films, news OCR, mentions, parcels, or people.</p>
+        ) : (
+          <ul className="divide-y divide-ink/10">
+            {results.map(r => (
+              <li key={`${r.kind}-${r.id}`} className="py-5 flex flex-col md:flex-row md:items-baseline gap-3">
+                <span className={`shrink-0 text-[10px] uppercase tracking-[0.15em] px-2 py-1 border ${KIND_COLOR[r.kind] ?? 'border-ink/20 text-ink/60'} self-start`}>
+                  {KIND_LABEL[r.kind] ?? r.kind}
+                </span>
+                <div className="flex-1 min-w-0">
+                  {r.href && r.href !== '#' ? (
+                    <Link href={r.href} className="font-display text-2xl text-ink hover:text-coral transition tracking-[-0.005em]" target={r.href.startsWith('http') ? '_blank' : undefined} rel={r.href.startsWith('http') ? 'noreferrer noopener' : undefined}>
+                      {r.title}
+                    </Link>
+                  ) : (
+                    <span className="font-display text-2xl text-ink/70">{r.title}</span>
+                  )}
+                  {r.subtitle && <p className="text-sm text-ink/65 mt-0.5">{r.subtitle}</p>}
+                  {r.snippet && (
+                    <p className="text-sm text-ink/70 mt-1 leading-relaxed max-w-prose">
+                      {renderSnippet(r.snippet)}
+                    </p>
+                  )}
+                  {(r.source_label || r.source_url) && (
+                    <p className="mt-2 font-mono text-[11px] text-ink/45">
+                      source:{' '}
+                      {r.source_url ? (
+                        <a href={r.source_url} target="_blank" rel="noreferrer noopener" className="hover:text-coral underline-offset-2 hover:underline">
+                          {r.source_label ?? r.source_url} ↗
+                        </a>
+                      ) : (r.source_label)}
+                      {r.date && ` · ${r.date}`}
+                    </p>
+                  )}
+                </div>
+              </li>
+            ))}
+          </ul>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..caac4a9
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,95 @@
+import type { MetadataRoute } from 'next';
+import { headers } from 'next/headers';
+import { pool } from '@/lib/db';
+import { siteForHost } from '@/lib/site';
+import { FREE_FOREVER } from '@/lib/flags';
+
+/**
+ * sitemap.xml — host-aware: each domain (wholivedthere/claimmyaddress/bubbesblock)
+ * gets a sitemap rooted at its own base URL. Editorial pages are public-indexable;
+ * admin/owner/promo flows are excluded.
+ */
+export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  const base = (process.env.NEXT_PUBLIC_BASE_URL ?? `https://${surface.domain}`).replace(/\/$/, '');
+  const now = new Date();
+
+  // Static editorial pages — only real content. /hosts excluded when FREE_FOREVER.
+  const staticUrls: MetadataRoute.Sitemap = [
+    { url: `${base}/`, lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
+    { url: `${base}/about`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
+    { url: `${base}/people`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
+    { url: `${base}/neighborhoods`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
+    { url: `${base}/timeline`, lastModified: now, changeFrequency: 'daily', priority: 0.7 },
+    { url: `${base}/architects`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
+    { url: `${base}/map`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
+    { url: `${base}/films`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
+    { url: `${base}/locations-by-year`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
+    ...(!FREE_FOREVER
+      ? [{ url: `${base}/hosts`, lastModified: now, changeFrequency: 'monthly' as const, priority: 0.5 }]
+      : []),
+  ];
+
+  // Address pages — only if they have at least one publicly-visible piece of evidence
+  // (event OR association OR media). Pure stub pages stay out of the index.
+  const { rows: addressRows } = await pool.query<{ slug: string; updated_at: Date; evidence_count: number }>(
+    `SELECT l.slug, l.updated_at,
+            (SELECT count(*) FROM place_event WHERE listing_id = l.id AND public_visible) +
+            (SELECT count(*) FROM entity_place_association WHERE listing_id = l.id AND public_visible) +
+            (SELECT count(*) FROM media_asset WHERE listing_id = l.id AND public_visible) AS evidence_count
+     FROM listing l
+     WHERE l.is_public = true`
+  );
+  const addressUrls: MetadataRoute.Sitemap = addressRows
+    .filter(r => Number(r.evidence_count) > 0)
+    .map(r => ({
+      url: `${base}/address/${r.slug}`,
+      lastModified: r.updated_at ?? now,
+      changeFrequency: 'monthly',
+      priority: 0.8,
+    }));
+
+  // Entity pages — only if they have at least one publicly-visible association.
+  const { rows: entityRows } = await pool.query<{ slug: string; assoc_count: number }>(
+    `SELECT e.slug,
+            (SELECT count(*) FROM entity_place_association WHERE entity_id = e.id AND public_visible) AS assoc_count
+     FROM entity e`
+  );
+  const entityUrls: MetadataRoute.Sitemap = entityRows
+    .filter(r => Number(r.assoc_count) > 0)
+    .map(r => ({
+      url: `${base}/entity/${r.slug}`,
+      lastModified: now,
+      changeFrequency: 'monthly',
+      priority: 0.6,
+    }));
+
+  // Neighborhood pages — only when ≥3 listings exist (avoid solitary stubs).
+  const { rows: hoodRows } = await pool.query<{ city: string; count: number }>(
+    `SELECT city, count(*)::int AS count FROM listing WHERE is_public = true GROUP BY city HAVING count(*) >= 3`
+  );
+  const hoodUrls: MetadataRoute.Sitemap = hoodRows.map(r => ({
+    url: `${base}/neighborhood/${r.city.toLowerCase().replace(/\s+/g, '-')}`,
+    lastModified: now,
+    changeFrequency: 'weekly',
+    priority: 0.6,
+  }));
+
+  // Film productions — only those with at least one public location.
+  const { rows: filmRows } = await pool.query<{ slug: string; loc_count: number }>(
+    `SELECT fp.slug,
+            (SELECT count(*) FROM filming_location WHERE production_id = fp.id AND public_visible) AS loc_count
+     FROM film_production fp`
+  );
+  const filmUrls: MetadataRoute.Sitemap = filmRows
+    .filter(r => Number(r.loc_count) > 0)
+    .map(r => ({
+      url: `${base}/film/${r.slug}`,
+      lastModified: now,
+      changeFrequency: 'monthly' as const,
+      priority: 0.6,
+    }));
+
+  return [...staticUrls, ...addressUrls, ...entityUrls, ...hoodUrls, ...filmUrls];
+}
diff --git a/src/app/stars/nearby/StarsNearbyClient.tsx b/src/app/stars/nearby/StarsNearbyClient.tsx
new file mode 100644
index 0000000..3d13650
--- /dev/null
+++ b/src/app/stars/nearby/StarsNearbyClient.tsx
@@ -0,0 +1,185 @@
+'use client';
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+
+type NearbyStar = {
+  kind: 'walk' | 'residence' | 'restaurant';
+  name: string;
+  subtitle: string;
+  slug: string;
+  lat: number;
+  lng: number;
+  distance_m: number;
+};
+
+const KIND_LABEL: Record<NearbyStar['kind'], string> = {
+  walk: 'Walk',
+  residence: 'Residence',
+  restaurant: 'Restaurant',
+};
+const KIND_COLOR: Record<NearbyStar['kind'], string> = {
+  walk: '#c9292e',
+  residence: '#1f4d40',
+  restaurant: '#7a4a1f',
+};
+
+type State =
+  | { kind: 'idle' }
+  | { kind: 'asking' }
+  | { kind: 'denied'; msg: string }
+  | { kind: 'loading'; lat: number; lng: number }
+  | { kind: 'ok'; lat: number; lng: number; stars: NearbyStar[] };
+
+function metersToFeet(m: number): string {
+  if (m < 100) return `${Math.round(m * 3.28)}ft`;
+  // Round to nearest 100ft AFTER converting (not before), else 200m → "700ft" (off by 7%)
+  if (m < 1609) return `${Math.round(m * 3.28 / 100) * 100}ft`;
+  return `${(m / 1609).toFixed(1)}mi`;
+}
+
+function mapsLink(name: string, lat: number, lng: number): string {
+  // query_place_id requires an actual Google Place ID, not a name; without it
+  // Google ignores the parameter and the name disambiguation is lost. Pass the
+  // name in the search query so the map opens with both the coords pin AND
+  // a labeled result.
+  return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${name} ${lat},${lng}`)}`;
+}
+
+export function StarsNearbyClient() {
+  const [state, setState] = useState<State>({ kind: 'idle' });
+  const [radiusMi, setRadiusMi] = useState(1);
+
+  // Only fire when transitioning into 'loading' — avoids re-running on every
+  // state change (e.g. when transitioning to 'ok').
+  const loadKey = state.kind === 'loading' ? `${state.lat},${state.lng},${radiusMi}` : null;
+  useEffect(() => {
+    if (state.kind !== 'loading') return;
+    let cancelled = false;
+    (async () => {
+      try {
+        const r = await fetch(`/api/stars/nearby?lat=${state.lat}&lng=${state.lng}&radius=${radiusMi * 1609}`);
+        const j = await r.json().catch(() => null);
+        if (cancelled) return;
+        if (!r.ok) {
+          setState({ kind: 'denied', msg: j?.error ?? `lookup failed (HTTP ${r.status})` });
+          return;
+        }
+        setState({ kind: 'ok', lat: state.lat, lng: state.lng, stars: j?.stars ?? [] });
+      } catch {
+        if (!cancelled) setState({ kind: 'denied', msg: 'lookup failed' });
+      }
+    })();
+    return () => { cancelled = true; };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [loadKey]);
+
+  function requestLocation() {
+    if (!('geolocation' in navigator)) {
+      setState({ kind: 'denied', msg: "your browser doesn't support geolocation" });
+      return;
+    }
+    setState({ kind: 'asking' });
+    navigator.geolocation.getCurrentPosition(
+      pos => setState({ kind: 'loading', lat: pos.coords.latitude, lng: pos.coords.longitude }),
+      err => setState({ kind: 'denied', msg: err.message }),
+      { enableHighAccuracy: true, maximumAge: 60_000, timeout: 12_000 }
+    );
+  }
+
+  return (
+    <section className="max-w-3xl mx-auto px-6 py-10">
+      {state.kind === 'idle' && (
+        <div className="text-center">
+          <button
+            onClick={requestLocation}
+            className="bg-[#c9292e] text-white px-6 py-4 text-sm uppercase tracking-[0.2em] font-mono font-bold hover:opacity-90 transition"
+          >
+            Use my location →
+          </button>
+          <p className="mt-4 text-[12px] font-mono uppercase tracking-[0.15em] text-ink/45">
+            Your coordinates are sent to our server only to filter the list. We don&rsquo;t log or store them.
+          </p>
+        </div>
+      )}
+
+      {state.kind === 'asking' && (
+        <p className="text-center font-mono uppercase tracking-[0.18em] text-[10px] text-ink/55 py-12">
+          Asking your browser for location…
+        </p>
+      )}
+
+      {state.kind === 'denied' && (
+        <div className="border border-[#c9292e]/40 bg-[#c9292e]/5 p-6 text-center">
+          <p className="font-display text-2xl text-ink mb-2">Couldn&rsquo;t get your location.</p>
+          <p className="text-sm text-ink/65 mb-4">{state.msg}</p>
+          <button onClick={requestLocation} className="bg-ink text-white px-4 py-2 text-xs uppercase tracking-[0.2em] font-mono">
+            Try again
+          </button>
+        </div>
+      )}
+
+      {state.kind === 'loading' && (
+        <p className="text-center font-mono uppercase tracking-[0.18em] text-[10px] text-ink/55 py-12">
+          Finding stars within {radiusMi}mi…
+        </p>
+      )}
+
+      {state.kind === 'ok' && (
+        <>
+          <div className="flex items-center justify-between mb-6 flex-wrap gap-3">
+            <p className="font-mono text-[11px] uppercase tracking-[0.15em] text-ink/55">
+              {state.stars.length} stars within {radiusMi}mi · {state.lat.toFixed(2)}, {state.lng.toFixed(2)}
+            </p>
+            <label className="flex items-center gap-2 text-[10px] uppercase tracking-[0.15em] text-ink/55 font-mono">
+              Radius
+              <select
+                value={radiusMi}
+                onChange={e => { setRadiusMi(Number(e.target.value)); setState({ kind: 'loading', lat: state.lat, lng: state.lng }); }}
+                className="border border-ink/20 px-2 py-1 text-xs bg-white"
+              >
+                <option value={0.25}>0.25 mi</option>
+                <option value={0.5}>0.5 mi</option>
+                <option value={1}>1 mi</option>
+                <option value={2}>2 mi</option>
+                <option value={5}>5 mi</option>
+              </select>
+            </label>
+          </div>
+          {state.stars.length === 0 ? (
+            <p className="font-display italic text-2xl text-ink/55 text-center py-12">
+              No stars within walking distance. Try a wider radius.
+            </p>
+          ) : (
+            <ol className="divide-y divide-ink/10">
+              {state.stars.map(s => (
+                <li key={`${s.kind}-${s.slug}`}>
+                  <a
+                    href={mapsLink(s.name, s.lat, s.lng)}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="grid grid-cols-[1fr_auto] gap-3 py-4 items-baseline group"
+                  >
+                    <div>
+                      <span className="inline-block text-[9px] uppercase tracking-[0.18em] font-mono font-bold mr-2 px-2 py-0.5 align-middle"
+                        style={{ background: KIND_COLOR[s.kind], color: 'white' }}>
+                        {KIND_LABEL[s.kind]}
+                      </span>
+                      <span className="font-display text-2xl text-ink group-hover:text-coral transition">
+                        {s.name}
+                      </span>
+                      <p className="text-sm text-ink/65 mt-0.5">{s.subtitle}</p>
+                    </div>
+                    <div className="text-right">
+                      <p className="font-mono text-sm text-ink tabular-nums">{metersToFeet(s.distance_m)}</p>
+                      <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45 mt-0.5">walk</p>
+                    </div>
+                  </a>
+                </li>
+              ))}
+            </ol>
+          )}
+        </>
+      )}
+    </section>
+  );
+}
diff --git a/src/app/stars/nearby/page.tsx b/src/app/stars/nearby/page.tsx
new file mode 100644
index 0000000..8ab5ee4
--- /dev/null
+++ b/src/app/stars/nearby/page.tsx
@@ -0,0 +1,46 @@
+/**
+ * /stars/nearby — Mobile-GPS-aware "stars near me" page
+ *
+ * Browser geolocation API → query stars + Walk of Fame within 1mi radius.
+ * Renders walking-distance list with click-to-open-in-Maps links.
+ *
+ * Server component shells the page; client component does geolocation +
+ * fetches /api/stars/nearby?lat=&lng=&radius= for live results.
+ */
+import type { Metadata } from 'next';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { StarsNearbyClient } from './StarsNearbyClient';
+
+export const metadata: Metadata = {
+  title: 'Stars near me',
+  description: 'Find Hollywood Walk of Fame stars and historical celebrity residences within walking distance of your current location.',
+  alternates: { canonical: '/stars/nearby' },
+};
+
+export default function NearbyPage() {
+  return (
+    <>
+      <div className="max-w-3xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[
+          { label: 'Archive', href: '/' },
+          { label: 'Map of the stars', href: '/stars' },
+          { label: 'Near me' },
+        ]} />
+      </div>
+
+      <header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/55 mb-3 font-mono">
+          Mobile · GPS
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Stars near me.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Walk of Fame stars and historical residences within walking distance of where you&rsquo;re standing right now.
+        </p>
+      </header>
+
+      <StarsNearbyClient />
+    </>
+  );
+}
diff --git a/src/app/stars/page.tsx b/src/app/stars/page.tsx
new file mode 100644
index 0000000..8b80b66
--- /dev/null
+++ b/src/app/stars/page.tsx
@@ -0,0 +1,168 @@
+/**
+ * /stars — Map of the Stars (historical residences)
+ *
+ * Curated, source-cited historical celebrity residences in LA / BH.
+ * Per PLAN.md privacy guardrail: no current addresses, only past residences
+ * with date_range + source_tier + source_url. Living/recently-deceased people
+ * are blocked at the DB level via the entity_place_association privacy gate.
+ *
+ * Each star pin → click → /address/[slug] for the full record.
+ */
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { pool } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+
+export const metadata: Metadata = {
+  title: 'Map of the Stars',
+  description:
+    'A map of historical celebrity residences in Los Angeles and Beverly Hills, anchored to verifiable public records. Date-ranged, source-cited, never current.',
+  alternates: { canonical: '/stars' },
+};
+
+type StarRow = {
+  listing_id: string;
+  listing_slug: string;
+  listing_title: string;
+  city: string | null;
+  lat: number | null;
+  lng: number | null;
+  year_built: number | null;
+  person_slug: string;
+  person_name: string;
+  person_kind: string;
+  birth_year: number | null;
+  death_year: number | null;
+  date_from: string | null;
+  date_to: string | null;
+  source_label: string | null;
+  source_url: string | null;
+};
+
+async function getStars(): Promise<StarRow[]> {
+  try {
+    // DISTINCT ON (entity_id, listing_id) — collapses dup associations
+    // (legacy seed + new seed both inserted) into one row per person+address.
+    const { rows } = await pool.query<StarRow>(`
+      SELECT DISTINCT ON (epa.entity_id, epa.listing_id)
+             l.id AS listing_id, l.slug AS listing_slug, l.title AS listing_title, l.city,
+             l.latitude::float AS lat, l.longitude::float AS lng,
+             p.year_built,
+             e.slug AS person_slug, e.display_name AS person_name, e.kind AS person_kind,
+             e.birth_year, e.death_year,
+             epa.date_from::text, epa.date_to::text,
+             epa.source_notes AS source_label,
+             COALESCE(epa.source_urls[1], e.wiki_url) AS source_url
+      FROM entity_place_association epa
+      JOIN entity e   ON e.id = epa.entity_id
+      JOIN listing l  ON l.id = epa.listing_id
+      LEFT JOIN la_parcel p ON p.listing_id = l.id
+      WHERE epa.public_visible = true
+        AND l.is_public = true
+        AND l.latitude IS NOT NULL AND l.longitude IS NOT NULL
+      ORDER BY epa.entity_id, epa.listing_id, epa.date_from ASC NULLS LAST
+    `);
+    return rows;
+  } catch {
+    return [];
+  }
+}
+
+function yearOnly(d: string | null): string | null {
+  if (!d) return null;
+  const m = d.match(/^(\d{4})/);
+  return m ? m[1] : null;
+}
+
+export default async function StarsPage() {
+  const stars = await getStars();
+
+  const pins: MapPin[] = stars
+    .filter(s => s.lat != null && s.lng != null)
+    .map(s => ({
+      slug: s.listing_slug,
+      title: s.person_name,
+      subtitle: s.listing_title,
+      lat: Number(s.lat),
+      lng: Number(s.lng),
+    }));
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Map of the stars' }]} />
+      </div>
+
+      <header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/55 mb-3 font-mono">
+          Historical residences only
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Map of the stars.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Where they really lived — anchored to LA County public records, dated,
+          and sourced. We don&rsquo;t publish current addresses.
+        </p>
+        <p className="mt-3 text-sm text-ink/55 max-w-prose">
+          {stars.length} historical residence{stars.length === 1 ? '' : 's'} on the map.
+          Each pin links to the full address record: assessor data, films shot there,
+          permits filed, and the documented historical association.
+        </p>
+      </header>
+
+      {pins.length > 0 && (
+        <section className="max-w-6xl mx-auto px-6 py-8">
+          <InteractiveMapMount pins={pins} height={520} variant="positron" />
+          <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+            Pins anchored to LA County GIS centroids · click any pin for the full record
+          </p>
+        </section>
+      )}
+
+      <section className="max-w-3xl mx-auto px-6 py-10">
+        <ol className="divide-y divide-ink/10">
+          {stars.map(s => (
+            <li key={`${s.person_slug}-${s.listing_slug}`}>
+              <Link
+                href={`/address/${s.listing_slug}`}
+                className="group grid grid-cols-[1fr_auto] gap-4 py-5 items-baseline hover:bg-[#fdf9ee] -mx-3 px-3 transition"
+              >
+                <div>
+                  <h2 className="font-display text-3xl text-ink tracking-[-0.01em] group-hover:text-coral transition">
+                    {s.person_name}
+                  </h2>
+                  <p className="mt-1 text-sm text-ink/70">
+                    {s.listing_title}{s.city ? `, ${s.city}` : ''}
+                    {s.year_built && <span className="text-ink/45"> · built {s.year_built}</span>}
+                  </p>
+                  {s.source_label && (
+                    <p className="mt-1 text-[11px] font-mono text-ink/50 italic">
+                      {s.source_label}
+                    </p>
+                  )}
+                </div>
+                <div className="text-right">
+                  <p className="font-mono text-xs text-ink/60 tabular-nums">
+                    {yearOnly(s.date_from)}{s.date_to ? `–${yearOnly(s.date_to)}` : '–present'}
+                  </p>
+                  {(s.birth_year || s.death_year) && (
+                    <p className="font-mono text-[10px] text-ink/40 mt-1">
+                      {s.birth_year ?? '?'}{s.death_year ? `–${s.death_year}` : ''}
+                    </p>
+                  )}
+                </div>
+              </Link>
+            </li>
+          ))}
+        </ol>
+        {stars.length === 0 && (
+          <p className="font-display italic text-2xl text-ink/55 text-center py-12">
+            The map is loading. Stars are pinned as the archive grows.
+          </p>
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/stars/walk/page.tsx b/src/app/stars/walk/page.tsx
new file mode 100644
index 0000000..4919ceb
--- /dev/null
+++ b/src/app/stars/walk/page.tsx
@@ -0,0 +1,126 @@
+/**
+ * /stars/walk — Hollywood Walk of Fame
+ *
+ * Sidewalk stars on Hollywood Blvd + Vine St. Distinct from /stars (residences).
+ * Each star: name, category (motion pictures / TV / recording / radio / theater),
+ * exact address + lat/lng, dedicated date.
+ */
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { pool } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { InteractiveMapMount, type MapPin } from '@/components/InteractiveMapMount';
+
+export const metadata: Metadata = {
+  title: 'Hollywood Walk of Fame',
+  description: 'Sidewalk stars on Hollywood Blvd and Vine St — names, categories, addresses, dedication dates. Mapped.',
+  alternates: { canonical: '/stars/walk' },
+};
+
+type WalkStar = {
+  honoree_slug: string;
+  honoree_name: string;
+  category: string;
+  address: string | null;
+  lat: number | null;
+  lng: number | null;
+  dedicated_date: string | null;
+};
+
+const CATEGORY_LABEL: Record<string, string> = {
+  motion_pictures: 'Motion Pictures',
+  television:      'Television',
+  recording:       'Recording',
+  radio:           'Radio',
+  theater:         'Theater',
+  sports:          'Sports',
+  special:         'Special',
+};
+
+async function getStars(): Promise<WalkStar[]> {
+  try {
+    const { rows } = await pool.query<WalkStar>(`
+      SELECT honoree_slug, honoree_name, category, address,
+             lat::float AS lat, lng::float AS lng,
+             dedicated_date::text
+      FROM walk_of_fame
+      WHERE lat IS NOT NULL AND lng IS NOT NULL
+      ORDER BY honoree_name
+    `);
+    return rows;
+  } catch {
+    return [];
+  }
+}
+
+export default async function WalkPage() {
+  const stars = await getStars();
+  const pins: MapPin[] = stars.map(s => ({
+    slug: s.honoree_slug,
+    title: s.honoree_name,
+    subtitle: `${CATEGORY_LABEL[s.category] ?? s.category} · ${s.address ?? ''}`,
+    lat: Number(s.lat),
+    lng: Number(s.lng),
+    href: null,
+  }));
+
+  // Group by category
+  const byCategory: Record<string, WalkStar[]> = {};
+  for (const s of stars) (byCategory[s.category] ??= []).push(s);
+
+  return (
+    <>
+      <div className="max-w-6xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[
+          { label: 'Archive', href: '/' },
+          { label: 'Map of the stars', href: '/stars' },
+          { label: 'Walk of Fame' },
+        ]} />
+      </div>
+
+      <header className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/55 mb-3 font-mono">
+          Sidewalk · Hollywood &amp; Vine
+        </p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Hollywood Walk of Fame.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          {stars.length} stars on the map &mdash; addresses verified against the
+          Hollywood Chamber of Commerce list, coordinates anchored to the
+          actual sidewalk inscription.
+        </p>
+        <p className="mt-4 text-sm text-ink/55">
+          On your phone? <Link href="/stars/nearby" className="underline hover:text-coral">Find stars near you →</Link>
+        </p>
+      </header>
+
+      {pins.length > 0 && (
+        <section className="max-w-6xl mx-auto px-6 py-8">
+          <InteractiveMapMount pins={pins} height={520} variant="positron" />
+        </section>
+      )}
+
+      <section className="max-w-3xl mx-auto px-6 py-10">
+        {Object.entries(byCategory).sort().map(([cat, list]) => (
+          <div key={cat} className="mb-12">
+            <h2 className="font-display text-3xl text-ink mb-4 pb-2 border-b border-ink/10">
+              {CATEGORY_LABEL[cat] ?? cat}
+              <span className="ml-3 font-mono text-[11px] uppercase tracking-[0.15em] text-ink/45">
+                {list.length}
+              </span>
+            </h2>
+            <ul className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
+              {list.map(s => (
+                <li key={s.honoree_slug} className="flex items-baseline justify-between gap-3">
+                  <span className="font-display text-lg text-ink truncate">{s.honoree_name}</span>
+                  <span className="font-mono text-[10px] text-ink/45 shrink-0">{s.address?.replace(/ Hollywood Blvd/i, ' Hollywood').replace(/ Vine St/i, ' Vine')}</span>
+                </li>
+              ))}
+            </ul>
+          </div>
+        ))}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/submit/SubmitFormClient.tsx b/src/app/submit/SubmitFormClient.tsx
new file mode 100644
index 0000000..c9ed98f
--- /dev/null
+++ b/src/app/submit/SubmitFormClient.tsx
@@ -0,0 +1,250 @@
+'use client';
+/**
+ * Single-search submit form.
+ *
+ * Replaces the previous 4-field address grid (street/city/state/zip) with
+ * one big search input that hits the live LA County records lookup
+ * (/api/la-records/lookup). The lookup auto-fills every canonical field
+ * from the assessor record AND shows a preview card so the user can see
+ * the value of the product before submitting.
+ *
+ * If LA records doesn't find a match (out of county, unusual address, typo),
+ * the user can still submit — we'll do a manual lookup on our side.
+ */
+import { useState, useEffect, useRef } from 'react';
+import { SubmitMiniMap } from '@/components/SubmitMiniMap';
+
+type LookupHit = {
+  ok: true;
+  found: true;
+  apn: string;
+  situs_address: string | null;
+  city: string | null;
+  zip: string | null;
+  year_built: number | null;
+  sqft_main: number | null;
+  bedrooms: number | null;
+  bathrooms: number | null;
+  use_description: string | null;
+  total_value: number | null;
+  roll_year: number | null;
+  lat: number | null;
+  lng: number | null;
+};
+
+type LookupState =
+  | { kind: 'idle' }
+  | { kind: 'looking' }
+  | { kind: 'hit'; data: LookupHit }
+  | { kind: 'miss' }
+  | { kind: 'err'; msg: string };
+
+export function SubmitFormClient() {
+  const [query, setQuery] = useState('');
+  const [lookup, setLookup] = useState<LookupState>({ kind: 'idle' });
+  const [status, setStatus] = useState<'idle' | 'submitting' | 'ok' | 'error'>('idle');
+  const [error, setError] = useState<string | null>(null);
+  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const reqIdRef = useRef(0);
+
+  // Debounced lookup as the user types
+  useEffect(() => {
+    if (debounceRef.current) clearTimeout(debounceRef.current);
+    if (query.trim().length < 8) {
+      setLookup({ kind: 'idle' });
+      return;
+    }
+    debounceRef.current = setTimeout(async () => {
+      const myReqId = ++reqIdRef.current;
+      setLookup({ kind: 'looking' });
+      try {
+        const r = await fetch(`/api/la-records/lookup?q=${encodeURIComponent(query)}`);
+        if (myReqId !== reqIdRef.current) return; // stale response
+        if (!r.ok) { setLookup({ kind: 'err', msg: `HTTP ${r.status}` }); return; }
+        const j = await r.json();
+        if (j.found) setLookup({ kind: 'hit', data: j });
+        else setLookup({ kind: 'miss' });
+      } catch (e) {
+        if (myReqId !== reqIdRef.current) return;
+        setLookup({ kind: 'err', msg: e instanceof Error ? e.message : String(e) });
+      }
+    }, 450);
+    return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
+  }, [query]);
+
+  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
+    e.preventDefault();
+    setStatus('submitting');
+    setError(null);
+    const fd = new FormData(e.currentTarget);
+    const body: Record<string, unknown> = Object.fromEntries(fd.entries());
+    // If the lookup hit, attach the canonical assessor data so the server
+    // doesn't have to re-resolve. If miss, body just carries the free-form query.
+    // /api/submit requires city + state. Pull them from the LA County hit
+    // (situs_address format: "<street> <CITY> CA <ZIP>") or default to LA/CA.
+    // Use parsed parcel fields directly (city/zip are returned by /api/la-records/lookup
+    // from the SitusCity / SitusZIP attributes) — far more reliable than regexing
+    // the joined situs_address string.
+    if (lookup.kind === 'hit') {
+      body.address_line1 = lookup.data.situs_address;
+      body.apn           = lookup.data.apn;
+      body.year_built    = lookup.data.year_built;
+      body.sqft_main     = lookup.data.sqft_main;
+      body.lat           = lookup.data.lat;
+      body.lng           = lookup.data.lng;
+      body.city          = lookup.data.city ?? 'Los Angeles';
+      body.state         = 'CA';
+      body.postal_code   = lookup.data.zip ?? null;
+    } else {
+      body.address_line1 = query;
+      body.city          = 'Los Angeles';
+      body.state         = 'CA';
+    }
+    const res = await fetch('/api/submit', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    if (!res.ok) {
+      const j = await res.json().catch(() => ({}));
+      setError(j.error ?? `HTTP ${res.status}`);
+      setStatus('error');
+      return;
+    }
+    setStatus('ok');
+  }
+
+  if (status === 'ok') {
+    return (
+      <div className="border-2 border-[#c9292e] bg-[#fdf9ee] p-6">
+        <p className="font-mono uppercase text-[10px] tracking-[0.2em] text-[#c9292e] mb-2">Filed</p>
+        <p className="font-display text-2xl text-ink">Submission queued.</p>
+        <p className="mt-2 text-sm text-ink/70 leading-relaxed">
+          We&rsquo;ll cross-check against assessor data, fetch any available archival material, and notify
+          you when the page is published. Typically 1–3 business days for Beverly Hills addresses; longer
+          for outside the pilot area.
+        </p>
+      </div>
+    );
+  }
+
+  return (
+    <form onSubmit={onSubmit} className="grid gap-6">
+      {/* Single search bar — auto-resolves to LA County parcel record */}
+      <label className="flex flex-col gap-2">
+        <span className="text-[10px] uppercase tracking-[0.18em] text-ink/55 font-mono">
+          Address
+        </span>
+        <input
+          required
+          type="text"
+          value={query}
+          onChange={e => setQuery(e.target.value)}
+          placeholder="Type your address — e.g. 612 N Bedford Drive Beverly Hills"
+          autoComplete="street-address"
+          className="border-2 border-ink/30 px-4 py-3 text-base font-sans focus:outline-none focus:border-[#c9292e] bg-white"
+        />
+        <span className="text-[11px] text-ink/50">
+          One field. We&rsquo;ll resolve the city, ZIP, and assessor record from LA County in real time.
+        </span>
+      </label>
+
+      {/* Live preview card — only renders when lookup has something to say */}
+      {lookup.kind === 'looking' && (
+        <div className="border border-ink/15 bg-[#fdf9ee] px-4 py-3 text-xs font-mono uppercase tracking-[0.15em] text-ink/55">
+          Checking LA County records…
+        </div>
+      )}
+      {lookup.kind === 'hit' && (
+        <div className="border-l-[4px] border-[#c9292e] bg-white px-5 py-4 relative grid grid-cols-[1fr_auto] gap-5">
+          <div>
+            <span className="inline-block bg-[#c9292e] text-white px-2 py-0.5 text-[9px] tracking-[0.2em] font-mono uppercase font-bold mb-2">
+              Found · APN {lookup.data.apn}
+            </span>
+            <p className="font-sans font-bold text-base text-ink">
+              {lookup.data.situs_address}
+            </p>
+            <ul className="mt-2 space-y-0.5 text-[12px] font-mono text-ink/75">
+              {lookup.data.year_built && <li>· built {lookup.data.year_built}</li>}
+              {lookup.data.sqft_main && <li>· {lookup.data.sqft_main.toLocaleString()} sqft</li>}
+              {lookup.data.bedrooms != null && lookup.data.bedrooms > 0 && (
+                <li>· {lookup.data.bedrooms} bed / {lookup.data.bathrooms} bath</li>
+              )}
+              {lookup.data.use_description && <li>· {lookup.data.use_description.toLowerCase()}</li>}
+              {lookup.data.total_value && (
+                <li>· assessed at ${(lookup.data.total_value / 1_000_000).toFixed(2)}M ({lookup.data.roll_year} roll)</li>
+              )}
+            </ul>
+            <p className="mt-3 text-[11px] text-ink/55 italic">
+              Source: LA County Assessor (public record).
+            </p>
+          </div>
+          {lookup.data.lat != null && lookup.data.lng != null && (
+            <SubmitMiniMap
+              lat={lookup.data.lat}
+              lng={lookup.data.lng}
+              label={lookup.data.situs_address ?? ''}
+            />
+          )}
+        </div>
+      )}
+      {lookup.kind === 'miss' && (
+        <div className="border border-dashed border-ink/30 bg-[#fdf9ee] px-4 py-3 text-[12px] text-ink/70">
+          We couldn&rsquo;t auto-resolve that one against LA County. Submit anyway —
+          we&rsquo;ll look it up by hand. Outside-LA addresses always go through the
+          manual queue.
+        </div>
+      )}
+      {lookup.kind === 'err' && (
+        <div className="border border-[#c9292e]/40 bg-[#c9292e]/5 px-4 py-3 text-[12px] text-[#c9292e]">
+          Lookup error: {lookup.msg}. You can still submit.
+        </div>
+      )}
+
+      <label className="flex flex-col gap-2">
+        <span className="text-[10px] uppercase tracking-[0.18em] text-ink/55 font-mono">
+          Your email
+        </span>
+        <input
+          required
+          type="email"
+          name="contact_email"
+          placeholder="you@example.com"
+          autoComplete="email"
+          className="border border-ink/20 px-4 py-3 text-sm font-sans focus:outline-none focus:border-[#c9292e] bg-white"
+        />
+      </label>
+
+      <label className="flex flex-col gap-2">
+        <span className="text-[10px] uppercase tracking-[0.18em] text-ink/55 font-mono">
+          Anything else? (optional)
+        </span>
+        <textarea
+          name="notes"
+          rows={3}
+          placeholder="History you know, architect, prior occupants — helps us prioritize and find the right archive sources."
+          className="border border-ink/20 px-4 py-3 text-sm font-sans focus:outline-none focus:border-[#c9292e] bg-white resize-none"
+        />
+      </label>
+
+      <div className="flex items-center gap-4 flex-wrap">
+        <button
+          type="submit"
+          disabled={status === 'submitting' || query.trim().length < 8}
+          className="bg-[#c9292e] text-white px-6 py-3 text-xs uppercase tracking-[0.2em] font-mono font-bold hover:opacity-90 transition disabled:opacity-40"
+        >
+          {status === 'submitting' ? 'Submitting…' : 'Submit address'}
+        </button>
+        <span className="text-[11px] font-mono uppercase tracking-[0.15em] text-ink/45">
+          Free forever · no card · no signup
+        </span>
+      </div>
+
+      {error && (
+        <p className="text-xs text-[#c9292e]" role="alert">
+          {error}
+        </p>
+      )}
+    </form>
+  );
+}
diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx
new file mode 100644
index 0000000..36e4cde
--- /dev/null
+++ b/src/app/submit/page.tsx
@@ -0,0 +1,87 @@
+import type { Metadata } from 'next';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { SubmitFormClient } from './SubmitFormClient';
+import { FilmsGridServer } from '@/components/FilmsGridServer';
+import { HeroCollage } from '@/components/HeroCollage';
+import { ClaimExamples } from '@/components/ClaimExamples';
+import { StarsCtaRow } from '@/components/StarsCtaRow';
+import { currentSurface } from '@/lib/site';
+
+// Metadata varies by host: on claimmyaddress.com middleware rewrites `/`
+// to /submit, so this page IS that domain's homepage and must be indexable.
+// On wholivedthere/bubbesblock it's a private form — keep noindexed.
+export async function generateMetadata(): Promise<Metadata> {
+  const surface = await currentSurface();
+  const isClaimRoot = surface.key === 'claim';
+  return isClaimRoot
+    ? {
+        title: surface.ogTitle,
+        description: surface.description,
+        alternates: { canonical: '/' },
+      }
+    : { robots: { index: false, follow: false } };
+}
+
+export default async function SubmitPage() {
+  const surface = await currentSurface();
+  return (
+    <>
+      <HeroCollage />
+      <StarsCtaRow surface={surface.key} />
+      <ClaimExamples />
+      <div className="max-w-2xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Submit an address' }]} />
+      </div>
+
+      <header className="max-w-2xl mx-auto px-6 py-12 border-b border-ink/10">
+        <div className="flex items-center gap-3 mb-3 flex-wrap">
+          <span className="text-xs uppercase tracking-[0.15em] text-ink/50">Submit</span>
+          {/* R6 fix: surface the pricing answer up-front. Steve's PLAN.md decision
+              #2 was Free Forever — make that visible before the form. */}
+          <span className="inline-block bg-[#c9292e] text-white px-2 py-0.5 text-[10px] font-mono tracking-[0.2em] uppercase font-bold">
+            Free forever
+          </span>
+        </div>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          Add an address to the archive.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70">
+          We&rsquo;ll verify and ingest. Submissions are queued, not auto-published.
+        </p>
+        <p className="mt-3 font-mono text-[11px] uppercase tracking-[0.15em] text-ink/45">
+          No card · no signup · no fee. Public-record by design.
+        </p>
+      </header>
+
+      <section className="max-w-2xl mx-auto px-6 py-10">
+        <SubmitFormClient />
+      </section>
+
+      {/* R7 fix: connect the films grid to the form so it reads as proof,
+          not decoration. Without this header the grid looked like a stock
+          photo strip below an unrelated form. */}
+      <section className="bg-[#fdf9ee] border-t-2 border-[#c9292e] pt-12 pb-2">
+        <div className="max-w-2xl mx-auto px-6">
+          <p className="text-[10px] font-mono uppercase tracking-[0.2em] text-[#c9292e] mb-3">
+            Sample of the archive
+          </p>
+          <h2 className="font-mono uppercase text-xl md:text-2xl tracking-tight text-[#1a1a1a] font-bold leading-snug">
+            This is the kind of record we&apos;ll build for your address.
+          </h2>
+          <p className="mt-3 font-sans text-sm text-[#1a1a1a]/70">
+            Below is a live sample of films and television catalogued in the public-domain
+            image archive — pulled from Library of Congress, Internet Archive, Wikimedia,
+            the Met, and the Media History Digital Library. Every address you submit gets
+            cross-referenced against the same sources.
+          </p>
+        </div>
+      </section>
+
+      <FilmsGridServer
+        heading="What's been filmed near here"
+        kicker="Filmed here"
+        limit={48}
+      />
+    </>
+  );
+}
diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx
new file mode 100644
index 0000000..de55221
--- /dev/null
+++ b/src/app/timeline/page.tsx
@@ -0,0 +1,221 @@
+import Link from 'next/link';
+import type { Metadata } from 'next';
+import { getArchiveTimeline, listNeighborhoods, yearOf } from '@/lib/db';
+import { BreadcrumbArchive } from '@/components/BreadcrumbArchive';
+import { TierBadge, isPublicTier } from '@/components/TierBadge';
+
+export async function generateMetadata({
+  searchParams,
+}: {
+  searchParams: { kind?: string; decade?: string; city?: string };
+}): Promise<Metadata> {
+  const filtered = !!(searchParams.kind || searchParams.decade || searchParams.city);
+  return {
+    title: 'Timeline',
+    description: 'The archive in chronological order — every event, every address, every source.',
+    alternates: { canonical: '/timeline' },
+    robots: filtered ? { index: false, follow: true } : { index: true, follow: true },
+  };
+}
+
+const KIND_LABEL: Record<string, string> = {
+  permit: 'Permit',
+  sale: 'Sale',
+  remodel: 'Remodel',
+  demolition: 'Demolition',
+  designation: 'Historic designation',
+  media: 'Media',
+  occupancy: 'Occupancy',
+};
+
+const KINDS = ['permit', 'sale', 'remodel', 'media'] as const;
+const DECADES = ['2020s', '2010s', '2000s', '1990s', '1980s', '1970s', '1960s', '1950s', '1940s', '1930s', '1920s'] as const;
+
+function FilterChip({
+  active,
+  href,
+  children,
+}: {
+  active: boolean;
+  href: string;
+  children: React.ReactNode;
+}) {
+  return (
+    <Link
+      href={href}
+      className={`inline-flex items-center px-3 py-1 text-[11px] uppercase tracking-[0.15em] border transition ${
+        active
+          ? 'bg-ink text-sand border-ink'
+          : 'border-ink/20 text-ink/65 hover:border-coral hover:text-coral'
+      }`}
+    >
+      {children}
+    </Link>
+  );
+}
+
+function buildHref(current: { kind?: string; decade?: string; city?: string }, patch: Record<string, string | undefined>) {
+  const next = { ...current, ...patch };
+  const params = new URLSearchParams();
+  for (const [k, v] of Object.entries(next)) {
+    if (v) params.set(k, v);
+  }
+  const qs = params.toString();
+  return qs ? `/timeline?${qs}` : '/timeline';
+}
+
+export default async function TimelinePage({
+  searchParams,
+}: {
+  searchParams: { kind?: string; decade?: string; city?: string };
+}) {
+  const events = await getArchiveTimeline({
+    kind: searchParams.kind,
+    decade: searchParams.decade,
+    city: searchParams.city,
+    limit: 400,
+  });
+  const cities = await listNeighborhoods();
+
+  const byDecade: Record<string, typeof events> = {};
+  for (const e of events) {
+    const dateBasis = e.valid_from ?? e.recorded_at;
+    const yr = yearOf(dateBasis);
+    const decade = yr === '?' ? 'Undated' : `${String(yr).slice(0, 3)}0s`;
+    (byDecade[decade] ??= []).push(e);
+  }
+  const decades = Object.keys(byDecade).sort((a, b) =>
+    a === 'Undated' ? 1 : b === 'Undated' ? -1 : b.localeCompare(a)
+  );
+
+  const filtered = !!(searchParams.kind || searchParams.decade || searchParams.city);
+
+  return (
+    <>
+      <div className="max-w-4xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Timeline' }]} />
+      </div>
+
+      <header className="max-w-4xl mx-auto px-6 py-12 border-b border-ink/10">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">Timeline</p>
+        <h1 className="font-display text-5xl md:text-6xl text-ink tracking-[-0.02em] leading-[1.02]">
+          The archive, in order.
+        </h1>
+        <p className="mt-4 font-display italic text-xl text-ink/70 max-w-prose">
+          Every public event, sorted by year. Filter by kind, decade, or neighborhood.
+        </p>
+        <p className="mt-3 font-mono text-[11px] text-ink/45">
+          {events.length} events · {filtered ? 'filtered' : 'all sources'}
+        </p>
+
+        {/* filter chips */}
+        <div className="mt-8 flex flex-col gap-3">
+          <div className="flex flex-wrap items-center gap-2">
+            <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">Kind</span>
+            <FilterChip active={!searchParams.kind} href={buildHref(searchParams, { kind: undefined })}>
+              All
+            </FilterChip>
+            {KINDS.map(k => (
+              <FilterChip
+                key={k}
+                active={searchParams.kind === k}
+                href={buildHref(searchParams, { kind: k })}
+              >
+                {KIND_LABEL[k]}
+              </FilterChip>
+            ))}
+          </div>
+          <div className="flex flex-wrap items-center gap-2">
+            <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">Decade</span>
+            <FilterChip active={!searchParams.decade} href={buildHref(searchParams, { decade: undefined })}>
+              All
+            </FilterChip>
+            {DECADES.map(d => (
+              <FilterChip
+                key={d}
+                active={searchParams.decade === d}
+                href={buildHref(searchParams, { decade: d })}
+              >
+                {d}
+              </FilterChip>
+            ))}
+          </div>
+          {cities.length > 1 && (
+            <div className="flex flex-wrap items-center gap-2">
+              <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45 mr-2">City</span>
+              <FilterChip active={!searchParams.city} href={buildHref(searchParams, { city: undefined })}>
+                All
+              </FilterChip>
+              {cities.map(c => (
+                <FilterChip
+                  key={c.city}
+                  active={searchParams.city === c.city}
+                  href={buildHref(searchParams, { city: c.city })}
+                >
+                  {c.city}
+                </FilterChip>
+              ))}
+            </div>
+          )}
+        </div>
+      </header>
+
+      <section className="max-w-4xl mx-auto px-6 py-10">
+        {events.length === 0 ? (
+          <p className="text-sm text-ink/55 italic py-10">No events match these filters.</p>
+        ) : (
+          decades.map(decade => (
+            <div key={decade} className="mb-12">
+              <div className="flex items-baseline gap-4 mb-4 pb-2 border-b border-ink/10">
+                <h2 className="font-display text-4xl text-ink tracking-[-0.01em]">{decade}</h2>
+                <span className="font-mono text-[11px] text-ink/45">
+                  {byDecade[decade]!.length} {byDecade[decade]!.length === 1 ? 'event' : 'events'}
+                </span>
+              </div>
+              <ol className="space-y-4">
+                {byDecade[decade]!.map(e => (
+                  <li
+                    key={e.id}
+                    className="grid md:grid-cols-[80px_1fr] gap-2 md:gap-6 py-3 border-b border-ink/5"
+                  >
+                    <time className="font-mono text-xs uppercase tracking-wider text-ink/60">
+                      {yearOf(e.valid_from ?? e.recorded_at)}
+                    </time>
+                    <div>
+                      <div className="flex items-baseline gap-3 flex-wrap">
+                        <span className="text-[10px] uppercase tracking-[0.15em] text-ink/40">
+                          {KIND_LABEL[e.kind] ?? e.kind}
+                        </span>
+                        <Link
+                          href={`/address/${e.listing_slug}`}
+                          className="font-display text-lg text-ink hover:text-coral transition leading-tight"
+                        >
+                          {e.listing_title}
+                        </Link>
+                        {e.listing_city && (
+                          <span className="text-[11px] text-ink/50">{e.listing_city}</span>
+                        )}
+                      </div>
+                      <p className="mt-1 text-sm text-ink/75 leading-relaxed">{e.summary}</p>
+                      {isPublicTier(e.source_tier) && (
+                        <div className="mt-2">
+                          {e.source_url ? (
+                            <a href={e.source_url} target="_blank" rel="noopener noreferrer">
+                              <TierBadge tier={e.source_tier} sourceLabel={e.source_label ?? undefined} />
+                            </a>
+                          ) : (
+                            <TierBadge tier={e.source_tier} sourceLabel={e.source_label ?? undefined} />
+                          )}
+                        </div>
+                      )}
+                    </div>
+                  </li>
+                ))}
+              </ol>
+            </div>
+          ))
+        )}
+      </section>
+    </>
+  );
+}
diff --git a/src/app/timeline/rss.xml/route.ts b/src/app/timeline/rss.xml/route.ts
new file mode 100644
index 0000000..44cf940
--- /dev/null
+++ b/src/app/timeline/rss.xml/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from 'next/server';
+import { getArchiveTimeline } from '@/lib/db';
+import { SITE_DOMAIN, SITE_NAME, SITE_TAGLINE } from '@/lib/site';
+
+/**
+ * RSS 2.0 feed of the archive's most recent events.
+ * For archivists, GIS folks, and history-twitter.
+ */
+export async function GET() {
+  const events = await getArchiveTimeline({ limit: 50 });
+  const base = (process.env.NEXT_PUBLIC_BASE_URL ?? `https://${SITE_DOMAIN}`).replace(/\/$/, '');
+
+  const items = events.map(e => {
+    const link = `${base}/address/${e.listing_slug}`;
+    const date = e.valid_from ?? e.recorded_at;
+    const pubDate = new Date(date).toUTCString();
+    const title = `${e.kind.replace(/_/g, ' ')} · ${e.listing_title}`;
+    return [
+      '    <item>',
+      `      <title>${escapeXml(title)}</title>`,
+      `      <link>${link}</link>`,
+      `      <guid isPermaLink="false">pastdoor:event:${e.id}</guid>`,
+      `      <pubDate>${pubDate}</pubDate>`,
+      `      <category>${escapeXml(e.kind)}</category>`,
+      `      <description>${escapeXml(e.summary)}${e.source_label ? ` — ${escapeXml(e.source_label)}` : ''}</description>`,
+      '    </item>',
+    ].join('\n');
+  });
+
+  const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>${escapeXml(SITE_NAME)} — archive timeline</title>
+    <link>${base}/timeline</link>
+    <description>${escapeXml(SITE_TAGLINE)} New events in the archive — permits, sales, archival media.</description>
+    <language>en-us</language>
+    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
+    <atom:link xmlns:atom="http://www.w3.org/2005/Atom" href="${base}/timeline/rss.xml" rel="self" type="application/rss+xml" />
+${items.join('\n')}
+  </channel>
+</rss>`;
+
+  return new NextResponse(xml, {
+    headers: {
+      'Content-Type': 'application/rss+xml; charset=utf-8',
+      'Cache-Control': 'public, max-age=600, s-maxage=3600',
+    },
+  });
+}
+
+function escapeXml(s: string): string {
+  return String(s ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;');
+}
diff --git a/src/components/AddressMasthead.tsx b/src/components/AddressMasthead.tsx
new file mode 100644
index 0000000..41956e4
--- /dev/null
+++ b/src/components/AddressMasthead.tsx
@@ -0,0 +1,59 @@
+import type { Listing } from '@/lib/db';
+
+/**
+ * AddressMasthead — hero for address pages.
+ * Right column is a LIVE Google Street View iframe of the actual property
+ * when lat/lng are present. Falls back to nothing rather than to a generic
+ * stock photo, so the page never lies about the building.
+ */
+export function AddressMasthead({ listing }: { listing: Listing }) {
+  const yearsPill = listing.bedrooms ? `${listing.bedrooms} bd · ${listing.bathrooms ?? '—'} ba` : null;
+  const origin = listing.property_type ?? 'Property';
+  const hasGeo = listing.latitude != null && listing.longitude != null;
+  const svSrc = hasGeo
+    ? `https://www.google.com/maps?q=&layer=c&cbll=${listing.latitude},${listing.longitude}&cbp=11,0,0,0,0&output=svembed`
+    : null;
+
+  return (
+    <header className="border-b border-ink/10">
+      <div className="max-w-6xl mx-auto px-6 py-10 grid md:grid-cols-[1fr_minmax(auto,560px)] gap-8">
+        <div>
+          <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">{origin}</p>
+          <h1 className="font-display text-5xl md:text-6xl leading-[1.02] text-ink tracking-[-0.02em]">
+            {listing.title}
+          </h1>
+          <address className="not-italic mt-3 text-base text-ink/70">
+            {listing.address_line1}
+            {listing.address_line1 && listing.city ? ' · ' : ''}
+            {listing.city}, {listing.state} {listing.postal_code}
+          </address>
+          {listing.headline && (
+            <p className="mt-5 font-display italic text-xl text-ink/80 max-w-prose">{listing.headline}</p>
+          )}
+          {yearsPill && (
+            <span className="mt-5 inline-flex items-center gap-2 border border-ink/20 px-3 py-1 text-xs uppercase tracking-wider text-ink/60">
+              {yearsPill} · sleeps {listing.sleeps ?? '—'}
+            </span>
+          )}
+        </div>
+        {svSrc && (
+          <div className="relative aspect-[4/3] overflow-hidden border border-ink/10 bg-ink">
+            <iframe
+              title={`Live Google Street View of ${listing.title}`}
+              src={svSrc}
+              width="100%"
+              height="100%"
+              style={{ border: 0, display: 'block', height: '100%' }}
+              loading="lazy"
+              referrerPolicy="no-referrer-when-downgrade"
+              allowFullScreen
+            />
+            <span className="absolute top-2 left-2 bg-ink/85 text-sand text-[10px] uppercase tracking-[0.15em] px-2 py-1 z-10">
+              Live · Google Street View
+            </span>
+          </div>
+        )}
+      </div>
+    </header>
+  );
+}
diff --git a/src/components/AddressNarrative.tsx b/src/components/AddressNarrative.tsx
new file mode 100644
index 0000000..0636dc1
--- /dev/null
+++ b/src/components/AddressNarrative.tsx
@@ -0,0 +1,210 @@
+/**
+ * AddressNarrative — weaves the structured facts about a listing into 2-3
+ * paragraphs of editorial prose. Sits at the top of every address page so
+ * the user reads a story first, then sees the data tables underneath.
+ *
+ * Storytelling pivot — Steve's note: "make it seem like storytelling".
+ * The data doesn't change; the framing does. Address pages used to lead
+ * with property facts in a sidebar; now they lead with a narrative voice
+ * that says what we know AS a story.
+ *
+ * Future: replace the templater with Claude API per /claude-api skill,
+ * cached per listing_id. For now the templater handles the common shapes
+ * (year built / style / film count / permit count) gracefully.
+ */
+import type { Listing, Structure, FilmingLocation, PlaceEvent } from '@/lib/db';
+import type { LAParcel } from '@/lib/la-arcgis';
+import type { LAPermit } from '@/lib/la-permits';
+
+type Productions = (FilmingLocation & {
+  production_slug: string;
+  production_title: string;
+  production_kind: string;
+  production_year: number | null;
+})[];
+
+function ord(n: number): string {
+  const s = ['th', 'st', 'nd', 'rd'];
+  const v = n % 100;
+  return n + (s[(v - 20) % 10] || s[v] || s[0]);
+}
+
+function articleFor(word: string): 'A' | 'An' {
+  return /^[aeiou]/i.test(word) ? 'An' : 'A';
+}
+
+function listAnd(arr: string[]): string {
+  if (arr.length === 0) return '';
+  if (arr.length === 1) return arr[0];
+  if (arr.length === 2) return arr.join(' and ');
+  return arr.slice(0, -1).join(', ') + ', and ' + arr[arr.length - 1];
+}
+
+function buildOpening(listing: Listing, structure: Structure | undefined, parcel: LAParcel | null): string {
+  const parts: string[] = [];
+  // Prefer real assessor data (parcel) over local listing/structure data when available.
+  // Real data has the freshest year_built / sqft / use_description from LA County roll.
+  const built = parcel?.year_built ?? structure?.year_built;
+  const style = structure?.style?.toLowerCase();
+  const type =
+    parcel?.use_description?.toLowerCase() ??
+    structure?.property_type?.toLowerCase().replace(/^residential[,-]?\s*/, '');
+  const city = listing.city ?? parcel?.city ?? null;
+  const sqft = parcel?.sqft_main ?? structure?.building_area_sqft;
+  const lot = structure?.lot_size_sqft;
+
+  if (built && style) {
+    parts.push(`The ${style} at ${listing.title}${city ? ` in ${city}` : ''} was built in ${built}`);
+  } else if (built && type) {
+    parts.push(`The ${type} at ${listing.title}${city ? ` in ${city}` : ''} dates to ${built}`);
+  } else if (built) {
+    parts.push(`${listing.title}${city ? `, in ${city},` : ''} was built in ${built}`);
+  } else if (style || type) {
+    parts.push(`${listing.title}${city ? ` in ${city}` : ''} is ${articleFor(style ?? type ?? '').toLowerCase()} ${style ?? type}`);
+  } else {
+    parts.push(`${listing.title}${city ? ` is in ${city}` : ''}`);
+  }
+
+  // Append size
+  const size: string[] = [];
+  if (sqft) size.push(`${sqft.toLocaleString()} square feet`);
+  if (lot) size.push(`a ${lot.toLocaleString()}-square-foot lot`);
+  if (size.length) {
+    parts[0] += `, ${size.length === 2 ? `set on ${size[1]} of land and ${size[0]} of living space` : `with ${size[0]}`}`;
+  }
+
+  return parts[0] + '.';
+}
+
+function buildOwnership(events: PlaceEvent[]): string | null {
+  const sales = events.filter(e => e.kind === 'sale');
+  if (sales.length === 0) return null;
+  const first = sales[0];
+  const last = sales[sales.length - 1];
+  const firstYear = first?.valid_from ? new Date(first.valid_from).getFullYear() : null;
+  const lastYear = last?.valid_from ? new Date(last.valid_from).getFullYear() : null;
+
+  if (sales.length === 1 && firstYear) {
+    return `The recorded chain of title shows a single transfer, in ${firstYear}.`;
+  }
+  if (firstYear && lastYear && firstYear !== lastYear) {
+    return `Title has changed hands ${sales.length} times in the public record, the earliest entry in ${firstYear} and the most recent in ${lastYear}.`;
+  }
+  if (sales.length > 1) {
+    return `The recorded chain of title includes ${sales.length} transfers.`;
+  }
+  return null;
+}
+
+function buildPermits(events: PlaceEvent[]): string | null {
+  const permits = events.filter(e => e.kind === 'permit' || e.kind === 'remodel');
+  if (permits.length === 0) return null;
+  const years = permits.map(p => p.valid_from ? new Date(p.valid_from).getFullYear() : null).filter((y): y is number => y != null);
+  if (years.length === 0) {
+    return `${permits.length} building permit${permits.length === 1 ? '' : 's'} ${permits.length === 1 ? 'is' : 'are'} on file with the city.`;
+  }
+  const earliest = Math.min(...years);
+  const latest = Math.max(...years);
+  if (earliest === latest) {
+    return `The address pulled ${permits.length} permit${permits.length === 1 ? '' : 's'} in ${earliest}.`;
+  }
+  return `${permits.length} building permit${permits.length === 1 ? '' : 's'} are on file, the earliest from ${earliest} and the latest from ${latest}.`;
+}
+
+function buildLaPermits(permits: LAPermit[] | undefined): string | null {
+  if (!permits || permits.length === 0) return null;
+  const dated = permits.filter(p => p.issue_date);
+  if (dated.length === 0) {
+    return `${permits.length} City of LA permit${permits.length === 1 ? '' : 's'} on file.`;
+  }
+  const years = dated.map(p => Number(p.issue_date!.slice(0, 4))).filter(Number.isFinite);
+  const earliest = Math.min(...years);
+  const latest = Math.max(...years);
+  if (earliest === latest) {
+    return `${permits.length} City of LA building permit${permits.length === 1 ? '' : 's'} pulled in ${earliest}.`;
+  }
+  return `${permits.length} City of LA building permits in the public record, the earliest from ${earliest} and the latest from ${latest}.`;
+}
+
+function buildAssessor(parcel: LAParcel | null): string | null {
+  if (!parcel) return null;
+  const bits: string[] = [];
+  if (parcel.apn) bits.push(`APN ${parcel.apn}`);
+  if (parcel.roll_year && parcel.total_value) {
+    bits.push(`assessed at $${(parcel.total_value / 1000).toFixed(0)}K (${parcel.roll_year} roll)`);
+  }
+  if (parcel.homeowner_exempt) bits.push('homeowner exemption on file');
+  if (bits.length === 0) return null;
+  return `LA County records: ${bits.join('; ')}.`;
+}
+
+function buildFilming(productions: Productions): string | null {
+  if (productions.length === 0) return null;
+  const titles = productions.slice(0, 3).map(p => `${p.production_title}${p.production_year ? ` (${p.production_year})` : ''}`);
+  if (productions.length === 1) {
+    return `Filming records show one production permitted at this address: ${titles[0]}.`;
+  }
+  if (productions.length <= 3) {
+    return `Production permits at this address include ${listAnd(titles)}.`;
+  }
+  return `${productions.length} productions have pulled film permits here, including ${listAnd(titles.slice(0, 3))}, and others.`;
+}
+
+export function AddressNarrative({
+  listing,
+  structures,
+  events,
+  productions,
+  parcel,
+  laPermits,
+}: {
+  listing: Listing;
+  structures: Structure[];
+  events: PlaceEvent[];
+  productions: Productions;
+  parcel?: LAParcel | null;
+  laPermits?: LAPermit[];
+}) {
+  const structure = structures[0];
+  const opening    = buildOpening(listing, structure, parcel ?? null);
+  const ownership  = buildOwnership(events);
+  const permits    = buildPermits(events);
+  const filming    = buildFilming(productions);
+  const assessor   = buildAssessor(parcel ?? null);
+  const ladbs      = buildLaPermits(laPermits);
+
+  // Stitch into 2-3 paragraphs
+  const para1 = opening;
+  const para2Parts = [ownership, permits, ladbs, assessor].filter(Boolean) as string[];
+  const para3Parts = [filming].filter(Boolean) as string[];
+
+  if (para2Parts.length === 0 && para3Parts.length === 0) {
+    // Sparse listing — single sentence fine
+    return (
+      <section className="max-w-3xl mx-auto px-6 py-10 border-b border-ink/10">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-ink/45 mb-3 font-mono">The story</p>
+        <p className="font-display text-2xl md:text-3xl text-ink leading-[1.35] tracking-[-0.005em]">
+          {para1}
+        </p>
+        <p className="mt-4 text-sm font-mono text-ink/50 italic">
+          What we know so far. Records continue to be added.
+        </p>
+      </section>
+    );
+  }
+
+  return (
+    <section className="max-w-3xl mx-auto px-6 py-12 border-b border-ink/10">
+      <p className="text-[10px] uppercase tracking-[0.18em] text-ink/45 mb-4 font-mono">The story</p>
+      <div className="font-display text-xl md:text-2xl text-ink leading-[1.5] tracking-[-0.005em] space-y-5">
+        <p>{para1}</p>
+        {para2Parts.length > 0 && <p>{para2Parts.join(' ')}</p>}
+        {para3Parts.length > 0 && <p>{para3Parts.join(' ')}</p>}
+      </div>
+      <p className="mt-6 text-[11px] font-mono text-ink/45 italic">
+        Drawn from the assessor record, building permits, sale history, and FilmLA permits.
+        Every claim is a public-record citation, not an inference.
+      </p>
+    </section>
+  );
+}
diff --git a/src/components/BreadcrumbArchive.tsx b/src/components/BreadcrumbArchive.tsx
new file mode 100644
index 0000000..ef5b0a4
--- /dev/null
+++ b/src/components/BreadcrumbArchive.tsx
@@ -0,0 +1,24 @@
+import Link from 'next/link';
+
+/**
+ * BreadcrumbArchive — archival trail. e.g. "Beverly Hills › 90210 › 1100 Elden Wy"
+ * Monospace-ish Inter, small caps.
+ */
+export function BreadcrumbArchive({ items }: { items: { label: string; href?: string }[] }) {
+  return (
+    <nav aria-label="archive trail" className="text-[10px] uppercase tracking-[0.15em] text-ink/50">
+      {items.map((item, i) => (
+        <span key={i}>
+          {item.href ? (
+            <Link href={item.href} className="hover:text-ink transition">
+              {item.label}
+            </Link>
+          ) : (
+            <span className="text-ink/70">{item.label}</span>
+          )}
+          {i < items.length - 1 && <span className="mx-2 text-ink/30">›</span>}
+        </span>
+      ))}
+    </nav>
+  );
+}
diff --git a/src/components/ClaimCTA.tsx b/src/components/ClaimCTA.tsx
new file mode 100644
index 0000000..c143446
--- /dev/null
+++ b/src/components/ClaimCTA.tsx
@@ -0,0 +1,28 @@
+import Link from 'next/link';
+import { FREE_FOREVER } from '@/lib/flags';
+
+/**
+ * ClaimCTA — owner-verification entry. Moss, not coral (coral reserved for sponsored).
+ * Free forever per Steve's standing decision — no upgrade/subscription pitch.
+ */
+export function ClaimCTA({ slug }: { slug: string }) {
+  return (
+    <section className="border border-moss/30 bg-moss/5 p-5">
+      <h3 className="font-display text-xl text-ink">Is this your home?</h3>
+      <p className="mt-2 text-sm text-ink/70 leading-relaxed">
+        Claim your address to add private notes, photos, receipts, and home records.
+        Verification requires a utility bill, ID, or deed match.
+      </p>
+      <Link
+        href={`/claim/${slug}`}
+        className="mt-3 inline-flex items-center gap-2 bg-moss text-sand px-4 py-2 text-xs uppercase tracking-wider hover:bg-ink transition"
+      >
+        Claim this address
+        <span aria-hidden>→</span>
+      </Link>
+      <p className="mt-3 text-[10px] text-ink/40">
+        {FREE_FOREVER ? 'Free forever. No subscription, no upgrade tier.' : '$9.99/mo · includes hosting, edits, exports.'}
+      </p>
+    </section>
+  );
+}
diff --git a/src/components/ClaimExamples.tsx b/src/components/ClaimExamples.tsx
new file mode 100644
index 0000000..bb4de3e
--- /dev/null
+++ b/src/components/ClaimExamples.tsx
@@ -0,0 +1,83 @@
+/**
+ * ClaimExamples — proof-of-concept cards shown on /submit (claimmyaddress
+ * homepage). Demonstrates what a claimed address looks like BEFORE the user
+ * is asked to add one. Closes the R5 UX critique that the submit form opened
+ * with an empty/error feel and undersold the product against flagship/bubbe.
+ *
+ * Each card: hero image + address + year built + (films / permits / owners).
+ * Links to /address/[slug] so the user can see the full record.
+ */
+import Link from 'next/link';
+import { getClaimExampleAddresses } from '@/lib/db';
+
+export async function ClaimExamples() {
+  // R6 fix: 2 cards (was 4) — gives each card breathing room so the fact list
+  // and address title are legible at first scroll. Density was hurting the
+  // value-prop demonstration.
+  const examples = await getClaimExampleAddresses(2);
+  if (!examples.length) return null;
+
+  return (
+    <section className="bg-[#fdf9ee] border-y-2 border-[#c9292e] py-10 md:py-12">
+      <div className="max-w-6xl mx-auto px-6">
+        <span className="inline-block bg-[#c9292e] text-white px-3 py-1 text-[10px] font-mono tracking-[0.25em] uppercase mb-4">
+          On file
+        </span>
+        <h2 className="font-mono uppercase text-xl md:text-2xl tracking-tight text-[#1a1a1a] font-bold mb-2 max-w-3xl">
+          Here&apos;s what a claimed address looks like.
+        </h2>
+        <p className="font-sans text-sm text-[#1a1a1a]/70 mb-8 max-w-2xl">
+          Every record below is built from public sources — assessor data,
+          building permits, FilmLA permits, archival photographs. Add your
+          own address below and we&apos;ll do the same for it.
+        </p>
+
+        <div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
+          {examples.map(ex => (
+            <Link
+              key={ex.slug}
+              href={`/address/${ex.slug}`}
+              className="block bg-white relative ce-card transition group grid grid-cols-[120px_1fr] md:grid-cols-[150px_1fr]"
+              title={`${ex.title}${ex.year_built ? ` (built ${ex.year_built})` : ''}`}
+            >
+              {ex.hero_image && (
+                // eslint-disable-next-line @next/next/no-img-element
+                <img
+                  src={ex.hero_image}
+                  alt={ex.title}
+                  loading="eager"
+                  className="block w-full h-full object-cover aspect-[4/5]"
+                />
+              )}
+              <div className="px-5 py-5 flex flex-col">
+                <p className="font-sans font-bold text-base md:text-lg text-[#1a1a1a] group-hover:text-[#c9292e] transition leading-snug">
+                  {ex.title}
+                </p>
+                <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-[#1a1a1a]/55 mt-1">
+                  {ex.city ?? '—'}
+                  {ex.year_built ? ` · built ${ex.year_built}` : ''}
+                </p>
+                <ul className="mt-4 space-y-1.5 text-[12px] md:text-[13px] font-mono text-[#1a1a1a]/80">
+                  {ex.films_count > 0 && (
+                    <li>· {ex.films_count} film{ex.films_count === 1 ? '' : 's'} on record</li>
+                  )}
+                  {ex.permits_count > 0 && (
+                    <li>· {ex.permits_count} permit{ex.permits_count === 1 ? '' : 's'} filed</li>
+                  )}
+                  {ex.owners_count > 0 && (
+                    <li>· {ex.owners_count} association{ex.owners_count === 1 ? '' : 's'}</li>
+                  )}
+                  {ex.property_type && <li>· {ex.property_type.toLowerCase()}</li>}
+                </ul>
+                <p className="mt-auto pt-4 font-mono text-[10px] uppercase tracking-[0.18em] text-[#c9292e] group-hover:translate-x-1 transition">
+                  See full record →
+                </p>
+              </div>
+            </Link>
+          ))}
+        </div>
+      </div>
+      <style>{`.ce-card { box-shadow: 0 1px 0 rgba(0,0,0,0.08); transition: transform 180ms ease, box-shadow 180ms ease; } .ce-card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px -10px rgba(201,41,46,0.45), 0 1px 0 rgba(0,0,0,0.08); }`}</style>
+    </section>
+  );
+}
diff --git a/src/components/ConfidenceDot.tsx b/src/components/ConfidenceDot.tsx
new file mode 100644
index 0000000..0c0d2fa
--- /dev/null
+++ b/src/components/ConfidenceDot.tsx
@@ -0,0 +1,40 @@
+/**
+ * ConfidenceDot — 3-dot scale + anchor-window caption.
+ * Always paired with an anchor window; never renders bare.
+ *
+ *   0.0–0.39  low      (1 dot)
+ *   0.40–0.74 medium   (2 dots)
+ *   0.75–1.00 high     (3 dots)
+ *
+ * Constraint source: docs/rent-multiplier-1978-1984.md — every projected number
+ * carries its anchor window + confidence dot.
+ */
+
+export function ConfidenceDot({
+  confidence,
+  anchorWindow,
+  className = '',
+}: {
+  confidence: number;
+  anchorWindow: string;
+  className?: string;
+}) {
+  const filled = confidence >= 0.75 ? 3 : confidence >= 0.4 ? 2 : 1;
+  const tier = confidence >= 0.75 ? 'High' : confidence >= 0.4 ? 'Medium' : 'Low';
+  return (
+    <span
+      className={`inline-flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-ink/60 ${className}`}
+      title={`Confidence: ${tier} (${confidence.toFixed(2)}) · Anchor: ${anchorWindow}`}
+    >
+      <span className="inline-flex gap-0.5" aria-label={`confidence ${tier.toLowerCase()}`}>
+        {[0, 1, 2].map(i => (
+          <span
+            key={i}
+            className={`h-1.5 w-1.5 rounded-full ${i < filled ? 'bg-moss' : 'bg-ink/15'}`}
+          />
+        ))}
+      </span>
+      <span className="font-mono normal-case tracking-normal">{anchorWindow}</span>
+    </span>
+  );
+}
diff --git a/src/components/CrossPromo.tsx b/src/components/CrossPromo.tsx
new file mode 100644
index 0000000..91aedea
--- /dev/null
+++ b/src/components/CrossPromo.tsx
@@ -0,0 +1,50 @@
+/**
+ * CrossPromo — links from one surface to the other two in the pastdoor ecosystem.
+ *
+ * Rendered as two slim cards. Designed to feel native (editorial) rather than
+ * banner-ad-y. Place at the bottom of high-intent pages: address detail,
+ * post-claim success, neighborhood pages.
+ *
+ * Usage:
+ *   const surface = await currentSurface();
+ *   <CrossPromo current={surface.key} />
+ */
+import Link from 'next/link';
+import { sisterSurfaces, type SurfaceKey } from '@/lib/site';
+
+const ACCENT_CLASSES: Record<string, string> = {
+  green: 'border-emerald-900/30 hover:border-emerald-800',
+  red: 'border-red-900/30 hover:border-red-800',
+  gold: 'border-amber-700/30 hover:border-amber-700',
+};
+
+export function CrossPromo({ current }: { current: SurfaceKey }) {
+  const sisters = sisterSurfaces(current);
+  if (sisters.length === 0) return null;
+
+  return (
+    <section className="max-w-5xl mx-auto px-6 py-10 mt-16 border-t border-ink/10">
+      <p className="text-[11px] uppercase tracking-[0.18em] text-ink/40 mb-4">
+        Also part of the archive
+      </p>
+      <div className="grid sm:grid-cols-2 gap-4">
+        {sisters.map((s) => (
+          <Link
+            key={s.domain}
+            href={`https://${s.domain}${s.primaryCta.href}`}
+            className={`block border ${ACCENT_CLASSES[s.themeAccent]} bg-white/40 px-5 py-4 transition group`}
+          >
+            <p className="text-[10px] uppercase tracking-[0.2em] text-ink/45">{s.domain}</p>
+            <h3 className="font-display text-xl text-ink tracking-[-0.005em] mt-1 group-hover:text-coral transition">
+              {s.brandName}
+            </h3>
+            <p className="text-sm text-ink/70 mt-1">{s.tagline}</p>
+            <span className="inline-block text-xs uppercase tracking-[0.15em] text-ink/55 mt-3 group-hover:text-coral transition">
+              {s.primaryCta.label} →
+            </span>
+          </Link>
+        ))}
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/CurveSparkline.tsx b/src/components/CurveSparkline.tsx
new file mode 100644
index 0000000..4dea189
--- /dev/null
+++ b/src/components/CurveSparkline.tsx
@@ -0,0 +1,87 @@
+/**
+ * CurveSparkline — server-rendered SVG of the LA 1978-1984 cycle.
+ * Reads the same data file the projection uses so the anchor and the
+ * picture cannot drift apart.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+type Point = { year: number; value: number; yoy_pct: number | null };
+
+let cached: Point[] | null = null;
+function loadPriceYoy(): Point[] | null {
+  if (cached) return cached;
+  const p = path.resolve(process.cwd(), 'data/multiplier_1978_1984.json');
+  if (!fs.existsSync(p)) return null;
+  const j = JSON.parse(fs.readFileSync(p, 'utf8'));
+  cached = j.price_yoy as Point[];
+  return cached;
+}
+
+export function CurveSparkline({ height = 64, className = '' }: { height?: number; className?: string }) {
+  const series = loadPriceYoy();
+  if (!series || series.length < 2) return null;
+
+  const w = 320;
+  const h = height;
+  const pad = 6;
+  const xs = series.map(p => p.year);
+  const ys = series.map(p => p.value);
+  const minX = Math.min(...xs);
+  const maxX = Math.max(...xs);
+  const minY = Math.min(...ys);
+  const maxY = Math.max(...ys);
+  const range = Math.max(1, maxY - minY);
+
+  const x = (year: number) => pad + ((year - minX) / (maxX - minX)) * (w - 2 * pad);
+  const y = (val: number) => h - pad - ((val - minY) / range) * (h - 2 * pad);
+
+  const path = series.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(p.year).toFixed(1)} ${y(p.value).toFixed(1)}`).join(' ');
+
+  return (
+    <figure className={`mt-3 ${className}`}>
+      <svg
+        viewBox={`0 0 ${w} ${h}`}
+        width="100%"
+        height={h}
+        role="img"
+        aria-label="LA County home-price index, 1978–1984"
+        className="overflow-visible"
+      >
+        {/* Y baseline */}
+        <line
+          x1={pad}
+          x2={w - pad}
+          y1={h - pad}
+          y2={h - pad}
+          stroke="#1a141015"
+          strokeWidth={1}
+        />
+        {/* curve */}
+        <path d={path} fill="none" stroke="#4a5d45" strokeWidth={1.5} />
+        {/* points */}
+        {series.map(p => (
+          <circle key={p.year} cx={x(p.year)} cy={y(p.value)} r={2.2} fill="#4a5d45" />
+        ))}
+        {/* year labels */}
+        {series.map(p => (
+          <text
+            key={`l-${p.year}`}
+            x={x(p.year)}
+            y={h - 2}
+            fontSize={9}
+            textAnchor="middle"
+            fill="#1a141060"
+            fontFamily="IBM Plex Mono, monospace"
+            style={{ letterSpacing: 0.5 }}
+          >
+            {String(p.year).slice(2)}
+          </text>
+        ))}
+      </svg>
+      <figcaption className="font-mono text-[10px] text-ink/50 mt-1">
+        LA County HPI · {minX}–{maxX} · FRED ATNHPIUS06037A
+      </figcaption>
+    </figure>
+  );
+}
diff --git a/src/components/FactRow.tsx b/src/components/FactRow.tsx
new file mode 100644
index 0000000..2f053b5
--- /dev/null
+++ b/src/components/FactRow.tsx
@@ -0,0 +1,37 @@
+import { TierBadge, type SourceTier, isPublicTier } from './TierBadge';
+
+/**
+ * FactRow — label · value · TierBadge · optional source link.
+ * Enforces the TierBadge slot — no "untiered" mode.
+ * If tier is D (or invalid), the row returns null — D never renders public.
+ */
+export function FactRow({
+  label,
+  value,
+  tier,
+  sourceLabel,
+  sourceUrl,
+}: {
+  label: string;
+  value: React.ReactNode;
+  tier: SourceTier;
+  sourceLabel?: string;
+  sourceUrl?: string;
+}) {
+  if (!isPublicTier(tier)) return null;
+  return (
+    <div className="flex flex-col md:flex-row md:items-baseline gap-1 md:gap-4 py-3 border-b border-ink/5">
+      <dt className="md:w-48 text-xs uppercase tracking-wider text-ink/50">{label}</dt>
+      <dd className="flex-1 text-base text-ink flex flex-wrap items-baseline gap-3">
+        <span>{value}</span>
+        {sourceUrl ? (
+          <a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:underline">
+            <TierBadge tier={tier} sourceLabel={sourceLabel} />
+          </a>
+        ) : (
+          <TierBadge tier={tier} sourceLabel={sourceLabel} />
+        )}
+      </dd>
+    </div>
+  );
+}
diff --git a/src/components/FilmsGrid.tsx b/src/components/FilmsGrid.tsx
new file mode 100644
index 0000000..7bbdb97
--- /dev/null
+++ b/src/components/FilmsGrid.tsx
@@ -0,0 +1,288 @@
+'use client';
+/**
+ * FilmsGrid — public-domain film image grid with adjustable column count.
+ *
+ * Three surfaces, three real visual personalities (per Four Horsemen design spec):
+ *
+ *   flagship  (wholivedthere) — Museum wall.
+ *     4 cols default · 3:4 portrait · hard-cropped · no card borders ·
+ *     staggered first row (col 1 drops 32px) · thin hairline slider ·
+ *     hover desaturates the image (close inspection of a photograph).
+ *
+ *   claim     (claimmyaddress) — Evidence board.
+ *     5 cols default · 16:9 landscape · 4px red left border on every card ·
+ *     pill-shaped red slider · sans-serif heading (no serif on this surface) ·
+ *     hover lifts card 4px and brightens the red flag · ghost-grid empty state.
+ *
+ *   community (bubbesblock) — Scrapbook.
+ *     4 cols default · mixed aspect ratios · slight rotation per nth-child
+ *     (cards look pinned to a board) · soft gold shadow (no hard border) ·
+ *     slider labelled "Show more / fewer" (community language, not "Density") ·
+ *     hover straightens the card to 0deg.
+ *
+ * Each card always links to its source page (Wikimedia / IA / LoC / NARA / MHDL /
+ * Smithsonian / Openverse / Met / DPLA) so provenance is one click away.
+ */
+import { useState, useMemo, useCallback } from 'react';
+import type { SurfaceKey } from '@/lib/site';
+
+export type FilmsGridImage = {
+  id: string;
+  source: 'wikimedia' | 'archive_org' | 'loc' | 'nara' | 'mhdl' | 'smithsonian' | 'nypl' | 'flickr_commons' | 'openverse' | 'met' | 'dpla';
+  title: string;
+  year: number | null;
+  image_url: string;
+  thumb_url: string | null;
+  source_url: string;
+};
+
+const SOURCE_LABEL: Record<FilmsGridImage['source'], string> = {
+  wikimedia: 'Wikimedia',
+  archive_org: 'Internet Archive',
+  loc: 'Library of Congress',
+  nara: 'National Archives',
+  mhdl: 'Media History DL',
+  smithsonian: 'Smithsonian',
+  nypl: 'NYPL',
+  flickr_commons: 'Flickr Commons',
+  openverse: 'Openverse',
+  met: 'Met Museum',
+  dpla: 'DPLA',
+};
+
+const MIN_COLS = 3;
+const MAX_COLS = 12;
+
+type SurfaceTreatment = {
+  defaultCols: number;
+  gap: string;                 // CSS gap value
+  cardClass: string;           // outer card classes
+  cardStyle?: (idx: number) => React.CSSProperties | undefined;
+  thumbClass: string;          // image classes
+  captionClass: string;        // text-block under image
+  headerKickerClass: string;
+  headerH2Class: string;
+  supportingCopy?: string;     // sentence under heading
+  sliderLabel: string;         // "Density" vs "Show more / fewer"
+  sliderTrackClass: string;
+  sliderFillClass: string;
+  hoverOverlayCss: string;     // additional hover behavior in <style>
+  emptyStateRender: () => React.ReactNode;
+  surfaceWrapperClass: string;
+};
+
+const SURFACE: Record<SurfaceKey, SurfaceTreatment> = {
+  // ───────── flagship: museum wall ─────────
+  flagship: {
+    defaultCols: 4,
+    gap: '1.5rem 1.25rem',
+    cardClass: 'group block bg-transparent transition fg-card',
+    cardStyle: (idx) => idx === 0 ? { transform: 'translateY(32px)' } : undefined,
+    thumbClass: 'w-full block aspect-[3/4] object-cover',
+    captionClass: 'pt-3 pb-5 text-[#1a1a1a]',
+    headerKickerClass: 'text-[10px] uppercase tracking-[0.18em] text-[#1f4d40]',
+    headerH2Class: 'font-display text-4xl md:text-5xl tracking-[-0.02em] text-[#1a1a1a] font-light leading-[1.05]',
+    supportingCopy: 'Public-domain films and television, drawn from Wikimedia, the Internet Archive, the Library of Congress, the Smithsonian, the Met, and the Media History Digital Library.',
+    sliderLabel: 'Density',
+    sliderTrackClass: 'bg-[#1f4d40]/15',
+    sliderFillClass: 'bg-[#1f4d40]',
+    hoverOverlayCss: '.fg-card { position: relative; } .fg-card img { transition: filter 320ms ease; } .fg-card::after { content: ""; position: absolute; inset: 0; pointer-events: none; box-shadow: inset 0 0 80px rgba(31,77,64,0); transition: box-shadow 320ms ease; } .fg-card:hover img { filter: saturate(0.30) brightness(0.96); } .fg-card:hover::after { box-shadow: inset 0 0 80px rgba(31,77,64,0.35); } .fg-card:hover .fg-title { text-decoration: underline; text-decoration-color: #1f4d40; text-underline-offset: 4px; }',
+    emptyStateRender: () => (
+      <div className="grid grid-cols-3 gap-6 max-w-2xl mx-auto">
+        {[0,1,2].map(i => (
+          <div key={i} className="aspect-[3/4] border border-dashed border-[#1f4d40]/40 bg-[#f3ebd9]" />
+        ))}
+        <p className="col-span-3 mt-6 text-center font-display italic text-lg text-[#6b6157]">
+          Images loading from the public archive.
+        </p>
+      </div>
+    ),
+    surfaceWrapperClass: 'max-w-6xl mx-auto px-6 py-16',
+  },
+  // ───────── claim: evidence board ─────────
+  // v2: structural redo per UX critique — drop the thin red border (read as
+  // "admin dashboard"), shift to a case-file/index-card metaphor:
+  //   * card on warm off-white over a deeper bone field
+  //   * faint horizontal ruled-paper lines via repeating-linear-gradient
+  //   * red corner stamp ("FILED") instead of left border
+  //   * 4:5 case-photo crop instead of 16:9 video-thumbnail
+  //   * cards rotate slightly on hover ("dropping into the file")
+  claim: {
+    defaultCols: 5,
+    gap: '1.25rem 1rem',
+    cardClass: 'group block bg-[#fdf9ee] transition cl-card relative',
+    thumbClass: 'w-full block aspect-[4/5] object-cover',
+    captionClass: 'px-3 pt-2 pb-3 text-[#1a1a1a] cl-caption',
+    headerKickerClass: 'inline-block text-[10px] uppercase tracking-[0.25em] text-white bg-[#c9292e] px-2 py-1 font-mono font-bold',
+    // Typewriter-adjacent institutional H2 — UX critique called out type uniformity
+    headerH2Class: 'mt-3 font-mono text-xl md:text-2xl tracking-tight text-[#1a1a1a] font-bold uppercase',
+    sliderLabel: 'Density',
+    sliderTrackClass: 'bg-[#1a1a1a]/15',
+    sliderFillClass: 'bg-[#c9292e]',
+    hoverOverlayCss: [
+      // Card baseline — case-file paper texture via faint horizontal lines on the caption strip
+      '.cl-card { box-shadow: 0 1px 0 rgba(0,0,0,0.08); transition: transform 180ms ease, box-shadow 180ms ease; will-change: transform; }',
+      // Subtle case-file ruled lines on the caption block
+      '.cl-caption { background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 12px, rgba(201,41,46,0.08) 12px, rgba(201,41,46,0.08) 13px); }',
+      // Red corner triangle "stamp" — top-right corner of every card
+      '.cl-card::before { content: ""; position: absolute; top: 0; right: 0; width: 28px; height: 28px; background: linear-gradient(225deg, #c9292e 0 50%, transparent 50% 100%); pointer-events: none; z-index: 1; }',
+      // FILED label — small red chevron mark inside the corner
+      '.cl-card::after { content: "F"; position: absolute; top: 1px; right: 4px; font: 700 9px/1 monospace; color: white; z-index: 2; pointer-events: none; }',
+      // Hover — slight rotation + lift like dropping into the file
+      '.cl-card:hover { transform: rotate(-0.6deg) translateY(-3px); box-shadow: 0 10px 24px -10px rgba(201,41,46,0.45), 0 1px 0 rgba(0,0,0,0.08); }',
+      '.cl-card:hover .fg-title { color: #c9292e; }',
+    ].join(' '),
+    emptyStateRender: () => (
+      <div className="border-2 border-dashed border-[#c9292e]/50 p-10 text-center bg-[#fdf9ee]">
+        <p className="font-mono text-xs uppercase tracking-[0.2em] text-[#c9292e] mb-3">— Empty file —</p>
+        <p className="font-sans text-sm text-[#1a1a1a]">
+          No productions found at this address yet. Results populate as you search.
+        </p>
+      </div>
+    ),
+    // Cooler/grayer bone (Manila-paper, institutional) — distinguishes from flagship's warm parchment
+    surfaceWrapperClass: 'max-w-7xl mx-auto px-6 py-12 bg-[#ddd5c2]',
+  },
+  // ───────── community: scrapbook ─────────
+  community: {
+    defaultCols: 4,
+    gap: '1.25rem 1.5rem',
+    cardClass: 'group block bg-[#f0e3c9] rounded-md transition co-card',
+    cardStyle: (idx) => {
+      const rotations = [-1.5, 1, -0.5, 0.8, -1.2, 0.5];
+      const r = rotations[idx % rotations.length];
+      const offset = idx % 2 === 1 ? 16 : 0;
+      return { transform: `rotate(${r}deg) translateX(${offset}px)`, transformOrigin: 'center' };
+    },
+    // mixed aspects via nth-child in the hover style block
+    thumbClass: 'w-full block object-cover rounded-sm co-thumb',
+    captionClass: 'px-3 py-2 text-[#6b6157] font-display italic',
+    headerKickerClass: 'text-[13px] tracking-[0.05em] text-[#c5a572] font-display italic',
+    // Warmer/heavier per UX critique — italic + heavier display weight, larger feel
+    headerH2Class: 'font-display italic text-4xl md:text-5xl tracking-[-0.01em] text-[#1a1a1a] leading-[1.1]',
+    supportingCopy: 'Films and shows that brought these streets to life — preserved in the public record.',
+    sliderLabel: 'Show fewer / more',
+    sliderTrackClass: 'bg-[#c5a572]/30',
+    sliderFillClass: 'bg-[#c5a572]',
+    // R4 fix: landscape-dominant aspects (4:3 / 16:9 / 3:2 / 1:1) so bubbe stops
+    // looking like flagship's portrait grid. Scrapbook = horizontal clippings.
+    hoverOverlayCss: '.co-card { transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 280ms ease; box-shadow: 0 4px 12px -6px rgba(197,165,114,0.35); } .co-card:hover { transform: rotate(0deg) translateX(0) !important; box-shadow: 0 12px 28px -10px rgba(197,165,114,0.55); z-index: 2; } .co-thumb { aspect-ratio: 4 / 3; } .co-card:nth-child(3n) .co-thumb { aspect-ratio: 16 / 9; } .co-card:nth-child(4n) .co-thumb { aspect-ratio: 3 / 2; } .co-card:nth-child(5n) .co-thumb { aspect-ratio: 1 / 1; }',
+    emptyStateRender: () => (
+      <p className="text-center font-display italic text-3xl md:text-4xl text-[#c5a572] py-16">
+        The neighborhood hasn&rsquo;t made its debut yet.
+      </p>
+    ),
+    // Warmer field bg than flagship's parchment — distinguishes scrapbook from museum
+    surfaceWrapperClass: 'max-w-6xl mx-auto px-8 py-16 bg-[#ede0c4]',
+  },
+};
+
+export function FilmsGrid({
+  images,
+  surfaceKey,
+  heading,
+  kicker,
+  defaultCols,
+}: {
+  images: FilmsGridImage[];
+  surfaceKey: SurfaceKey;
+  heading: string;
+  kicker: string;
+  defaultCols?: number;
+}) {
+  const T = SURFACE[surfaceKey];
+  const initialCols = Math.max(MIN_COLS, Math.min(MAX_COLS, defaultCols ?? T.defaultCols));
+  const [cols, setCols] = useState<number>(initialCols);
+
+  const onSlide = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    setCols(Number(e.target.value));
+  }, []);
+
+  const gridStyle = useMemo<React.CSSProperties>(
+    () => ({ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gap: T.gap }),
+    [cols, T.gap]
+  );
+
+  const fillPct = ((cols - MIN_COLS) / (MAX_COLS - MIN_COLS)) * 100;
+
+  if (!images.length) {
+    return (
+      <section className={T.surfaceWrapperClass}>
+        <p className={`mb-3 ${T.headerKickerClass}`}>{kicker}</p>
+        <h2 className={`mb-8 ${T.headerH2Class}`}>{heading}</h2>
+        {T.emptyStateRender()}
+      </section>
+    );
+  }
+
+  return (
+    <section className={T.surfaceWrapperClass}>
+      <style>{T.hoverOverlayCss}</style>
+
+      <div className="flex items-end justify-between gap-6 mb-10 flex-wrap">
+        <div className="max-w-2xl">
+          <p className={`mb-3 ${T.headerKickerClass}`}>{kicker}</p>
+          <h2 className={T.headerH2Class}>{heading}</h2>
+          {T.supportingCopy && (
+            <p className="mt-4 text-sm md:text-base text-[#6b6157] leading-relaxed max-w-prose">
+              {T.supportingCopy}
+            </p>
+          )}
+        </div>
+
+        <label className="flex items-center gap-3 select-none shrink-0">
+          <span className="text-[10px] uppercase tracking-[0.15em] text-[#6b6157] hidden sm:inline">
+            {T.sliderLabel}
+          </span>
+          <span className="font-mono text-[11px] text-[#6b6157] tabular-nums w-6 text-right">{cols}</span>
+          <span className="relative h-1 w-44 sm:w-56">
+            <span className={`absolute inset-0 ${T.sliderTrackClass}`} aria-hidden />
+            <span
+              className={`absolute inset-y-0 left-0 ${T.sliderFillClass}`}
+              style={{ width: `${fillPct}%` }}
+              aria-hidden
+            />
+            <input
+              type="range"
+              min={MIN_COLS}
+              max={MAX_COLS}
+              step={1}
+              value={cols}
+              onChange={onSlide}
+              aria-label={`Grid columns: ${cols} of ${MAX_COLS}`}
+              className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+            />
+          </span>
+        </label>
+      </div>
+
+      <div className="grid" style={gridStyle}>
+        {images.map((img, idx) => (
+          <a
+            key={img.id}
+            href={img.source_url}
+            target="_blank"
+            rel="noopener noreferrer"
+            className={T.cardClass}
+            style={T.cardStyle?.(idx)}
+            title={`${img.title}${img.year ? ` (${img.year})` : ''} — ${SOURCE_LABEL[img.source]}`}
+          >
+            {/* eslint-disable-next-line @next/next/no-img-element */}
+            <img
+              src={img.thumb_url ?? img.image_url}
+              alt={img.title}
+              loading="lazy"
+              className={T.thumbClass}
+            />
+            <div className={T.captionClass}>
+              <span className="block truncate fg-title text-sm" title={img.title}>{img.title}</span>
+              <span className="block opacity-60 text-[10px] mt-1">
+                {img.year ?? '—'} · {SOURCE_LABEL[img.source]}
+              </span>
+            </div>
+          </a>
+        ))}
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/FilmsGridServer.tsx b/src/components/FilmsGridServer.tsx
new file mode 100644
index 0000000..6bcc993
--- /dev/null
+++ b/src/components/FilmsGridServer.tsx
@@ -0,0 +1,65 @@
+import { listPdFilmImages, type PdFilmImage } from '@/lib/db';
+import { currentSurface, type SurfaceKey } from '@/lib/site';
+import { FilmsGrid, type FilmsGridImage } from './FilmsGrid';
+
+// Per-surface source affinity — same map as HeroCollage so hero + grid stay
+// thematically consistent within a surface, and visually distinct across them.
+const GRID_SOURCE_PREFERENCE: Record<SurfaceKey, PdFilmImage['source'][]> = {
+  flagship:  ['met', 'loc', 'wikimedia'],
+  claim:     ['archive_org', 'mhdl'],
+  community: ['mhdl'],   // R4 fix: MHDL only — magazine pages, newspaper clippings, scrapbook texture
+};
+
+/**
+ * Server wrapper around FilmsGrid. Reads PD film images from Postgres
+ * and resolves the visible surface (wholivedthere / claimmyaddress / bubbesblock)
+ * from the request host so the grid auto-themes itself per domain.
+ *
+ * Pass a productionId to scope the grid to a single film/TV title;
+ * otherwise it shows the latest cached PD images globally.
+ */
+export async function FilmsGridServer({
+  heading,
+  kicker,
+  limit = 60,
+  productionId,
+  productionIds,
+  defaultCols,
+}: {
+  heading: string;
+  kicker: string;
+  limit?: number;
+  productionId?: string;
+  productionIds?: string[];
+  defaultCols?: number;
+}) {
+  const site = await currentSurface();
+  // Apply per-surface source preference UNLESS scoped to a specific production
+  // (in which case all sources are valid — production_id is the dominant filter).
+  const sources = (productionId || productionIds?.length) ? undefined : GRID_SOURCE_PREFERENCE[site.key];
+  const preferred = await listPdFilmImages({ limit, productionId, productionIds, sources });
+  // Fallback if surface-preferred pool is too thin
+  const rows = preferred.length >= Math.min(limit, 12)
+    ? preferred
+    : await listPdFilmImages({ limit, productionId, productionIds });
+
+  const images: FilmsGridImage[] = rows.map((r: PdFilmImage) => ({
+    id: r.id,
+    source: r.source,
+    title: r.title,
+    year: r.year,
+    image_url: r.image_url,
+    thumb_url: r.thumb_url,
+    source_url: r.source_url,
+  }));
+
+  return (
+    <FilmsGrid
+      images={images}
+      surfaceKey={site.key}
+      heading={heading}
+      kicker={kicker}
+      defaultCols={defaultCols}
+    />
+  );
+}
diff --git a/src/components/GucciStripe.tsx b/src/components/GucciStripe.tsx
new file mode 100644
index 0000000..e0dd230
--- /dev/null
+++ b/src/components/GucciStripe.tsx
@@ -0,0 +1,21 @@
+/**
+ * GucciStripe — the green/red/green ribbon that's the only shared visual
+ * element across all three surfaces (per PLAN.md).
+ *
+ * 4px high. Three equal bands. Sits under the global header on every page.
+ * Don't repeat. Don't background. It's a heritage-luxury cue, not a pattern.
+ */
+export function GucciStripe() {
+  return (
+    <div
+      role="presentation"
+      aria-hidden="true"
+      className="w-full flex"
+      style={{ height: '4px' }}
+    >
+      <div className="flex-1" style={{ background: '#1f4d40' }} />
+      <div className="flex-1" style={{ background: '#c9292e' }} />
+      <div className="flex-1" style={{ background: '#1f4d40' }} />
+    </div>
+  );
+}
diff --git a/src/components/HeaderSearch.tsx b/src/components/HeaderSearch.tsx
new file mode 100644
index 0000000..854751c
--- /dev/null
+++ b/src/components/HeaderSearch.tsx
@@ -0,0 +1,193 @@
+'use client';
+import Link from 'next/link';
+import { useEffect, useRef, useState } from 'react';
+
+type ListingHit = {
+  slug: string;
+  title: string;
+  city: string | null;
+  state: string | null;
+  address_line1: string | null;
+  hero_image: string | null;
+};
+
+type EntityHit = {
+  slug: string;
+  display_name: string;
+  kind: string;
+  birth_year: number | null;
+  death_year: number | null;
+};
+
+type LaHit = {
+  apn: string;
+  situs_address: string | null;
+  year_built: number | null;
+};
+
+// Heuristic: does the query look like an address (number + a street word)?
+// Avoids hitting LA records on every two-letter type.
+function looksLikeAddress(q: string): boolean {
+  return /\b\d{2,5}\b/.test(q) && /\b(st|ave|blvd|dr|rd|pl|ln|ct|ter|way|drive|avenue|street|boulevard|road|place|lane|court|terrace)\b/i.test(q);
+}
+
+export function HeaderSearch() {
+  const [q, setQ] = useState('');
+  const [open, setOpen] = useState(false);
+  const [listings, setListings] = useState<ListingHit[]>([]);
+  const [entities, setEntities] = useState<EntityHit[]>([]);
+  const [laHit, setLaHit] = useState<LaHit | null>(null);
+  const [loading, setLoading] = useState(false);
+  const wrapperRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (q.trim().length < 2) {
+      setListings([]);
+      setEntities([]);
+      setLaHit(null);
+      return;
+    }
+    let cancelled = false;
+    setLoading(true);
+    const t = setTimeout(async () => {
+      try {
+        const queryStr = q.trim();
+        // Always: local archive search
+        const archivePromise = fetch(`/api/search?q=${encodeURIComponent(queryStr)}`).then(r => r.ok ? r.json() : null);
+        // If it looks like an address, also hit LA records in parallel
+        const laPromise = looksLikeAddress(queryStr)
+          ? fetch(`/api/la-records/lookup?q=${encodeURIComponent(queryStr)}`).then(r => r.ok ? r.json() : null)
+          : Promise.resolve(null);
+        const [archive, la] = await Promise.all([archivePromise, laPromise]);
+        if (cancelled) return;
+        setListings(archive?.listings ?? []);
+        setEntities(archive?.entities ?? []);
+        setLaHit(la?.found ? { apn: la.apn, situs_address: la.situs_address, year_built: la.year_built } : null);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }, 220);
+    return () => {
+      cancelled = true;
+      clearTimeout(t);
+    };
+  }, [q]);
+
+  useEffect(() => {
+    function onClick(e: MouseEvent) {
+      if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
+    }
+    document.addEventListener('mousedown', onClick);
+    return () => document.removeEventListener('mousedown', onClick);
+  }, []);
+
+  const hasResults = listings.length > 0 || entities.length > 0;
+  const showLaCta = laHit && listings.length === 0;  // only surface if archive miss
+
+  return (
+    <div ref={wrapperRef} className="relative w-72">
+      <input
+        type="search"
+        placeholder="Search the archive…"
+        value={q}
+        onChange={e => {
+          setQ(e.target.value);
+          setOpen(true);
+        }}
+        onFocus={() => setOpen(true)}
+        className="w-full bg-sand border border-ink/15 px-3 py-1.5 text-sm font-sans text-ink placeholder:text-ink/40 focus:outline-none focus:border-moss"
+      />
+      {open && q.trim().length >= 2 && (
+        <div className="absolute right-0 top-full mt-2 w-[420px] max-w-[calc(100vw-32px)] bg-sand border border-ink/15 shadow-lg z-30 max-h-[70vh] overflow-y-auto">
+          {loading && (
+            <p className="px-4 py-3 text-xs uppercase tracking-[0.15em] text-ink/40">
+              Searching…
+            </p>
+          )}
+          {!loading && !hasResults && !showLaCta && (
+            <p className="px-4 py-6 text-sm italic text-ink/50">
+              No matches in the archive yet. Try the address, the architect, or the era.
+            </p>
+          )}
+          {!loading && showLaCta && (
+            <div className="px-4 py-4 border-b border-ink/10 bg-[#fdf9ee]">
+              <p className="text-[10px] uppercase tracking-[0.18em] text-[#c9292e] font-mono font-bold mb-2">
+                Not in our archive — but it&rsquo;s on file with LA County
+              </p>
+              <p className="font-sans font-bold text-sm text-ink leading-snug">
+                {laHit?.situs_address}
+              </p>
+              <p className="font-mono text-[11px] text-ink/55 mt-1">
+                APN {laHit?.apn}{laHit?.year_built ? ` · built ${laHit.year_built}` : ''}
+              </p>
+              <Link
+                href={`/submit?q=${encodeURIComponent(q.trim())}`}
+                onClick={() => setOpen(false)}
+                className="mt-3 inline-block bg-[#c9292e] text-white px-3 py-1.5 text-[10px] uppercase tracking-[0.18em] font-mono font-bold hover:opacity-90 transition"
+              >
+                Start a record →
+              </Link>
+            </div>
+          )}
+          {entities.length > 0 && (
+            <div className="border-b border-ink/10">
+              <p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-[0.15em] text-ink/40">
+                People
+              </p>
+              <ul>
+                {entities.map(e => (
+                  <li key={e.slug}>
+                    <Link
+                      href={`/entity/${e.slug}`}
+                      className="block px-4 py-2 hover:bg-ink/5 transition"
+                      onClick={() => setOpen(false)}
+                    >
+                      <div className="font-display text-lg text-ink leading-tight">{e.display_name}</div>
+                      <div className="font-mono text-[10px] text-ink/50 mt-0.5">
+                        {e.kind} · {e.birth_year ?? '?'}–{e.death_year ?? '?'}
+                      </div>
+                    </Link>
+                  </li>
+                ))}
+              </ul>
+            </div>
+          )}
+          {listings.length > 0 && (
+            <div>
+              <p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-[0.15em] text-ink/40">
+                Addresses
+              </p>
+              <ul>
+                {listings.map(l => (
+                  <li key={l.slug}>
+                    <Link
+                      href={`/address/${l.slug}`}
+                      className="block px-4 py-2 hover:bg-ink/5 transition"
+                      onClick={() => setOpen(false)}
+                    >
+                      <div className="font-display text-lg text-ink leading-tight">{l.title}</div>
+                      <div className="text-[11px] text-ink/55 mt-0.5">
+                        {l.address_line1 ?? l.city}
+                        {l.city && l.address_line1 ? ` · ${l.city}` : ''}
+                        {l.state ? `, ${l.state}` : ''}
+                      </div>
+                    </Link>
+                  </li>
+                ))}
+              </ul>
+            </div>
+          )}
+          {hasResults && (
+            <Link
+              href={`/browse?q=${encodeURIComponent(q.trim())}`}
+              className="block px-4 py-2.5 border-t border-ink/10 text-[11px] uppercase tracking-[0.15em] text-ink/60 hover:text-coral transition"
+              onClick={() => setOpen(false)}
+            >
+              See all results in the archive →
+            </Link>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/src/components/HeroCollage.tsx b/src/components/HeroCollage.tsx
new file mode 100644
index 0000000..811b52b
--- /dev/null
+++ b/src/components/HeroCollage.tsx
@@ -0,0 +1,194 @@
+/**
+ * HeroCollage — full-bleed collage of public-domain images shown above the
+ * fold on each homepage. Visual entry-point that gives the surface immediate
+ * personality before the user sees anything else.
+ *
+ * Per-surface metaphor (matches FilmsGrid spec):
+ *   flagship  → museum wall: 4 portraits in a row, hairline framed, staggered
+ *   claim     → evidence board: 5 images with red corner stamps, tight grid
+ *   community → scrapbook: 4 images mixed-aspect, rotated, gold soft shadow
+ *
+ * Reads from pd_film_image (excluding ORB-blocked sources). Server component.
+ */
+import { listPdFilmImages, type PdFilmImage } from '@/lib/db';
+import { currentSurface, type SurfaceKey } from '@/lib/site';
+
+const FLAGSHIP_COUNT  = 4;
+const CLAIM_COUNT     = 5;
+const COMMUNITY_COUNT = 6;
+
+// Per-surface source affinity — breaks the homogenized image pool that the
+// UX agent flagged in R3 as the highest-leverage fix. Each surface pulls
+// from a distinct mix so the underlying image character matches the metaphor:
+//   flagship  → Met Museum + LoC (architectural, photographic, archival)
+//   claim     → Internet Archive + MHDL (movie posters, lobby cards, stills)
+//   community → Openverse + Wikimedia (community-uploaded, documentary)
+// Per-surface source affinity — each surface pulls from a visually distinct
+// PD pool so the metaphor (museum / case-file / scrapbook) is reinforced by
+// the underlying image character, not just the chrome around it.
+const SOURCE_PREFERENCE: Record<SurfaceKey, PdFilmImage['source'][]> = {
+  flagship:  ['met', 'loc'],          // architectural, photographic, museum-archival
+  claim:     ['archive_org', 'mhdl'], // movie posters, lobby cards, dense pulpy stills
+  community: ['mhdl'],                // R4 fix: MHDL only = magazine pages + newspapers + flyers
+                                      // (horizontal, text-heavy, scrapbook character)
+};
+
+export async function HeroCollage() {
+  const site = await currentSurface();
+  const count = site.key === 'flagship' ? FLAGSHIP_COUNT : site.key === 'claim' ? CLAIM_COUNT : COMMUNITY_COUNT;
+  // Try preferred sources first; fall back to global pool if too few.
+  const preferred = await listPdFilmImages({ limit: count * 3, sources: SOURCE_PREFERENCE[site.key] });
+  const pool = preferred.length >= count ? preferred : await listPdFilmImages({ limit: count * 3 });
+  if (!pool.length) return null;
+
+  // Pick deterministically per source mix (interleave already gives source variety).
+  const images = pool.slice(0, count);
+
+  if (site.key === 'flagship') return <FlagshipHero images={images} />;
+  if (site.key === 'claim')    return <ClaimHero    images={images} />;
+  return <CommunityHero images={images} />;
+}
+
+function FlagshipHero({ images }: { images: PdFilmImage[] }) {
+  // Museum wall — 4 portraits in a row, hairline frame, first drops 24px
+  return (
+    <section className="bg-[#f3ebd9] py-10 md:py-14 border-b border-[#1f4d40]/10">
+      <div className="max-w-6xl mx-auto px-6">
+        <p className="text-[10px] uppercase tracking-[0.18em] text-[#1f4d40] mb-2">Today on the wall</p>
+        <h1 className="font-display text-3xl md:text-5xl text-[#1a1a1a] tracking-[-0.02em] leading-[1.05] mb-4 max-w-2xl">
+          Every address has a story. We&rsquo;re writing them down.
+        </h1>
+        <p className="font-display italic text-base md:text-lg text-[#6b6157] mb-8 max-w-2xl">
+          From the public archive — films, photographs, deeds, and the records that survive a place.
+        </p>
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
+          {images.map((img, i) => (
+            <a
+              key={img.id}
+              href={img.source_url}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="block group"
+              style={i === 0 ? { transform: 'translateY(24px)' } : undefined}
+              title={`${img.title}${img.year ? ` (${img.year})` : ''}`}
+            >
+              <div className="border border-[#1f4d40]/20 p-1 bg-white">
+                {/* eslint-disable-next-line @next/next/no-img-element */}
+                <img
+                  src={img.thumb_url ?? img.image_url}
+                  alt={img.title}
+                  loading="eager"
+                  className="w-full block aspect-[3/4] object-cover transition group-hover:opacity-85"
+                />
+              </div>
+              <p className="font-display italic text-sm text-[#6b6157] mt-2 truncate">{img.title}</p>
+            </a>
+          ))}
+        </div>
+      </div>
+    </section>
+  );
+}
+
+function ClaimHero({ images }: { images: PdFilmImage[] }) {
+  // Evidence board — 5 case-photo cards with red FILED corner stamps
+  return (
+    <section className="bg-[#ddd5c2] py-10 md:py-14 border-b-2 border-[#c9292e]">
+      <div className="max-w-7xl mx-auto px-6">
+        <span className="inline-block bg-[#c9292e] text-white px-3 py-1 text-[10px] font-mono tracking-[0.25em] uppercase mb-3">
+          On file
+        </span>
+        <h1 className="font-mono text-2xl md:text-4xl text-[#1a1a1a] uppercase font-bold tracking-tight mb-4 max-w-3xl leading-tight">
+          Your address has a record. We&rsquo;ll write it down for you.
+        </h1>
+        <p className="font-sans text-sm md:text-base text-[#1a1a1a]/70 mb-8 max-w-2xl">
+          Permits. Sales. The films that were shot at your door. A free, permanent file of your home&rsquo;s public history.
+        </p>
+        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
+          {images.map(img => (
+            <a
+              key={img.id}
+              href={img.source_url}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="block bg-[#fdf9ee] relative cl-hero-card"
+              title={`${img.title}${img.year ? ` (${img.year})` : ''}`}
+            >
+              {/* eslint-disable-next-line @next/next/no-img-element */}
+              <img
+                src={img.thumb_url ?? img.image_url}
+                alt={img.title}
+                loading="eager"
+                className="w-full block aspect-[4/5] object-cover"
+              />
+              <div className="px-2 py-2 font-mono text-[10px] uppercase tracking-[0.1em] text-[#1a1a1a] truncate">
+                {img.title}
+              </div>
+            </a>
+          ))}
+        </div>
+      </div>
+      <style>{`.cl-hero-card { box-shadow: 0 1px 0 rgba(0,0,0,0.08); transition: transform 180ms ease, box-shadow 180ms ease; } .cl-hero-card::before { content: ""; position: absolute; top: 0; right: 0; width: 28px; height: 28px; background: linear-gradient(225deg, #c9292e 0 50%, transparent 50% 100%); pointer-events: none; z-index: 1; } .cl-hero-card::after { content: "F"; position: absolute; top: 1px; right: 4px; font: 700 9px/1 monospace; color: white; z-index: 2; } .cl-hero-card:hover { transform: rotate(-0.6deg) translateY(-3px); box-shadow: 0 10px 24px -10px rgba(201,41,46,0.45), 0 1px 0 rgba(0,0,0,0.08); }`}</style>
+    </section>
+  );
+}
+
+function CommunityHero({ images }: { images: PdFilmImage[] }) {
+  // Scrapbook v2 — R4 fix: landscape/horizontal aspects so it stops looking
+  // like a vertical portrait grid identical to flagship. MHDL pool = magazine
+  // pages + newspaper clippings + flyers, naturally text-heavy.
+  // Layout: hand-pinned bulletin board with overlapping cards, varied scale.
+  const rotations = [-3, 1.8, -1.5, 2.2, -2, 1, -1.2];
+  // Mix landscape + square; NO portraits. Different sizes — some span 2 cols.
+  const layout = [
+    { aspect: '4/3',  span: 'md:col-span-2' },  // first card big landscape
+    { aspect: '3/2',  span: 'md:col-span-1' },
+    { aspect: '16/9', span: 'md:col-span-1' },  // wide newspaper-strip
+    { aspect: '1/1',  span: 'md:col-span-1' },
+    { aspect: '3/2',  span: 'md:col-span-2' },  // big landscape again
+    { aspect: '4/3',  span: 'md:col-span-1' },
+  ];
+  return (
+    <section className="bg-[#ede0c4] py-12 md:py-16">
+      <div className="max-w-6xl mx-auto px-6">
+        <p className="font-display italic text-[#c5a572] text-sm md:text-base mb-2">Pinned on the corkboard</p>
+        <h1 className="font-display italic text-4xl md:text-6xl text-[#1a1a1a] tracking-[-0.01em] leading-[1.1] mb-4 max-w-3xl">
+          The block has stories. We&rsquo;re saving the pages.
+        </h1>
+        <p className="font-display italic text-base md:text-lg text-[#6b6157] mb-10 max-w-2xl">
+          Old clippings, neighborhood pages, family things. The history a block keeps in its drawers.
+        </p>
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-5 md:gap-7 items-start">
+          {images.map((img, i) => {
+            const r = rotations[i % rotations.length];
+            const { aspect, span } = layout[i % layout.length];
+            return (
+              <a
+                key={img.id}
+                href={img.source_url}
+                target="_blank"
+                rel="noopener noreferrer"
+                className={`block bg-[#f0e3c9] rounded-md p-2 co-hero-card ${span}`}
+                style={{ transform: `rotate(${r}deg)` }}
+                title={`${img.title}${img.year ? ` (${img.year})` : ''}`}
+              >
+                {/* eslint-disable-next-line @next/next/no-img-element */}
+                <img
+                  src={img.thumb_url ?? img.image_url}
+                  alt={img.title}
+                  loading="eager"
+                  className="w-full block object-cover rounded-sm"
+                  style={{ aspectRatio: aspect }}
+                />
+                <p className="font-display italic text-sm text-[#6b6157] mt-2 px-1 truncate">{img.title}</p>
+                {/* Push-pin dot in upper-left for the bulletin-board cue */}
+                <span aria-hidden className="absolute top-1 left-1 w-2 h-2 rounded-full bg-[#c9292e] shadow-sm" />
+              </a>
+            );
+          })}
+        </div>
+      </div>
+      <style>{`.co-hero-card { position: relative; transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 280ms ease; box-shadow: 0 6px 14px -6px rgba(197,165,114,0.5); } .co-hero-card:hover { transform: rotate(0deg) !important; box-shadow: 0 14px 32px -10px rgba(197,165,114,0.6); z-index: 2; }`}</style>
+    </section>
+  );
+}
diff --git a/src/components/InteractiveMap.tsx b/src/components/InteractiveMap.tsx
new file mode 100644
index 0000000..b80e402
--- /dev/null
+++ b/src/components/InteractiveMap.tsx
@@ -0,0 +1,147 @@
+'use client';
+/**
+ * InteractiveMap — Leaflet wrapper, client-only.
+ *
+ * Uses Carto Voyager basemaps (no API key) so the visual lineage matches
+ * the rest of the editorial system. Markers are coral-tinted SVG pins.
+ * Each pin's popup links to its address page.
+ */
+import { useEffect, useMemo } from 'react';
+import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
+import L, { type LatLngBoundsExpression } from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import Link from 'next/link';
+
+export type MapPin = {
+  lat: number;
+  lng: number;
+  slug?: string;
+  title: string;
+  subtitle?: string;
+  featured?: boolean;
+  /**
+   * Explicit href for the popup link. If omitted and `slug` is set, falls back
+   * to `/address/{slug}` for backwards compatibility. Set to `null` to render
+   * the title as plain text (no link) — used by Walk of Fame pins where there
+   * is no per-honoree detail page yet.
+   */
+  href?: string | null;
+};
+
+function makeIcon(color: string, size = 22) {
+  // Hand-rolled SVG so we don't ship Leaflet's default markers (which need
+  // image hosts) and so the pin matches our editorial palette.
+  const html = `<span style="display:block;width:${size}px;height:${size}px;">
+    <svg viewBox="0 0 24 24" fill="${color}" stroke="#f6f1e8" stroke-width="1.5"
+         xmlns="http://www.w3.org/2000/svg" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.35));">
+      <path d="M12 2C7.6 2 4 5.6 4 10c0 5.5 8 12 8 12s8-6.5 8-12c0-4.4-3.6-8-8-8z"/>
+      <circle cx="12" cy="10" r="3" fill="#f6f1e8"/>
+    </svg>
+  </span>`;
+  return L.divIcon({
+    className: 'pastdoor-pin',
+    html,
+    iconSize: [size, size],
+    iconAnchor: [size / 2, size],
+    popupAnchor: [0, -size + 2],
+  });
+}
+
+const ICON_DEFAULT = typeof window !== 'undefined' ? makeIcon('#4a5d45') : null;
+const ICON_FEATURED = typeof window !== 'undefined' ? makeIcon('#d4664a', 26) : null;
+
+function FitBounds({ pins }: { pins: MapPin[] }) {
+  const map = useMap();
+  useEffect(() => {
+    if (pins.length === 0) return;
+    if (pins.length === 1) {
+      map.setView([pins[0]!.lat, pins[0]!.lng], 16, { animate: false });
+      return;
+    }
+    const bounds: LatLngBoundsExpression = pins.map(p => [p.lat, p.lng]);
+    map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16, animate: false });
+  }, [pins, map]);
+  return null;
+}
+
+export function InteractiveMap({
+  pins,
+  height = 380,
+  className = '',
+  variant = 'voyager',
+}: {
+  pins: MapPin[];
+  height?: number;
+  className?: string;
+  variant?: 'voyager' | 'positron' | 'dark';
+}) {
+  const tile = useMemo(() => {
+    if (variant === 'dark') return {
+      url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> · &copy; <a href="https://carto.com/attributions">CARTO</a>',
+    };
+    if (variant === 'positron') return {
+      url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
+      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> · &copy; <a href="https://carto.com/attributions">CARTO</a>',
+    };
+    return {
+      url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
+      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> · &copy; <a href="https://carto.com/attributions">CARTO</a>',
+    };
+  }, [variant]);
+
+  // Sane LA default in case no pins yet have coords.
+  const fallbackCenter: [number, number] = [34.0736, -118.4004];
+
+  return (
+    <div className={className} style={{ height, width: '100%' }}>
+      <MapContainer
+        center={pins[0] ? [pins[0].lat, pins[0].lng] : fallbackCenter}
+        zoom={12}
+        scrollWheelZoom={true}
+        style={{ height: '100%', width: '100%', borderRadius: 0, background: '#1a1410' }}
+      >
+        <TileLayer url={tile.url} attribution={tile.attribution} />
+        <FitBounds pins={pins} />
+        {pins.map((p, i) => (
+          <Marker
+            key={`${p.slug ?? p.title}-${i}`}
+            position={[p.lat, p.lng]}
+            icon={(p.featured ? ICON_FEATURED : ICON_DEFAULT) ?? undefined}
+          >
+            <Popup>
+              <div style={{ minWidth: 180 }}>
+                <p style={{ fontSize: 10, letterSpacing: 2, textTransform: 'uppercase', color: '#1a141090', margin: 0 }}>
+                  {p.subtitle ?? 'Address'}
+                </p>
+                {(() => {
+                  const href = p.href === undefined && p.slug ? `/address/${p.slug}` : p.href;
+                  return href ? (
+                    <>
+                      <Link
+                        href={href}
+                        style={{ display: 'block', fontSize: 17, fontFamily: 'Instrument Serif, serif', color: '#1a1410', marginTop: 4, lineHeight: 1.15 }}
+                      >
+                        {p.title}
+                      </Link>
+                      <Link
+                        href={href}
+                        style={{ display: 'inline-block', marginTop: 8, fontSize: 10, letterSpacing: 1.5, textTransform: 'uppercase', color: '#d4664a' }}
+                      >
+                        Open the record →
+                      </Link>
+                    </>
+                  ) : (
+                    <span style={{ display: 'block', fontSize: 17, fontFamily: 'Instrument Serif, serif', color: '#1a1410', marginTop: 4, lineHeight: 1.15 }}>
+                      {p.title}
+                    </span>
+                  );
+                })()}
+              </div>
+            </Popup>
+          </Marker>
+        ))}
+      </MapContainer>
+    </div>
+  );
+}
diff --git a/src/components/InteractiveMapMount.tsx b/src/components/InteractiveMapMount.tsx
new file mode 100644
index 0000000..6b1e56a
--- /dev/null
+++ b/src/components/InteractiveMapMount.tsx
@@ -0,0 +1,27 @@
+'use client';
+import dynamic from 'next/dynamic';
+import type { MapPin } from './InteractiveMap';
+
+/**
+ * Dynamic-only mount for the Leaflet map (it needs `window`).
+ * Use this from server components.
+ */
+const InteractiveMap = dynamic(() => import('./InteractiveMap').then(m => m.InteractiveMap), {
+  ssr: false,
+  loading: () => (
+    <div className="bg-ink/10 flex items-center justify-center text-[10px] uppercase tracking-[0.2em] text-ink/45" style={{ minHeight: 200 }}>
+      Loading map…
+    </div>
+  ),
+});
+
+export function InteractiveMapMount(props: {
+  pins: MapPin[];
+  height?: number;
+  className?: string;
+  variant?: 'voyager' | 'positron' | 'dark';
+}) {
+  return <InteractiveMap {...props} />;
+}
+
+export type { MapPin };
diff --git a/src/components/JsonLd.tsx b/src/components/JsonLd.tsx
new file mode 100644
index 0000000..654ba66
--- /dev/null
+++ b/src/components/JsonLd.tsx
@@ -0,0 +1,15 @@
+/**
+ * JsonLd — emits a single application/ld+json script.
+ *
+ * Rule: only emit fields the visible page actually shows. Misleading
+ * structured data is a quality risk per Google's guidelines.
+ */
+export function JsonLd({ data }: { data: Record<string, unknown> }) {
+  return (
+    <script
+      type="application/ld+json"
+      // Pre-stringified for SSR safety; do not rely on JSON.stringify in the runtime.
+      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
+    />
+  );
+}
diff --git a/src/components/ListingGrid.tsx b/src/components/ListingGrid.tsx
new file mode 100644
index 0000000..32de5c6
--- /dev/null
+++ b/src/components/ListingGrid.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import { useEffect, useState, type ReactNode } from 'react';
+
+const STORAGE_KEY = 'wlt_grid_cols';
+const EVENT = 'wlt-grid-cols-change';
+const MIN_COLS = 3;
+const MAX_COLS = 12;
+const DEFAULT_COLS = 3;
+
+/**
+ * Shared col count across all grids on a page. Persists to localStorage; a
+ * custom DOM event keeps multiple GridSliders / ListingGrids in sync without
+ * needing a context provider — pages can drop them in arbitrarily.
+ */
+function useSharedCols(): [number, (n: number) => void] {
+  const [cols, setColsState] = useState<number>(DEFAULT_COLS);
+
+  useEffect(() => {
+    if (typeof window === 'undefined') return;
+    const saved = window.localStorage.getItem(STORAGE_KEY);
+    if (saved) {
+      const n = parseInt(saved, 10);
+      if (n >= MIN_COLS && n <= MAX_COLS) setColsState(n);
+    }
+    const handler = (e: Event) => {
+      const n = (e as CustomEvent<number>).detail;
+      if (n >= MIN_COLS && n <= MAX_COLS) setColsState(n);
+    };
+    window.addEventListener(EVENT, handler);
+    return () => window.removeEventListener(EVENT, handler);
+  }, []);
+
+  const setCols = (n: number) => {
+    const clamped = Math.max(MIN_COLS, Math.min(MAX_COLS, n));
+    setColsState(clamped);
+    if (typeof window !== 'undefined') {
+      window.localStorage.setItem(STORAGE_KEY, String(clamped));
+      window.dispatchEvent(new CustomEvent<number>(EVENT, { detail: clamped }));
+    }
+  };
+
+  return [cols, setCols];
+}
+
+/** Render once near the top of a page; broadcasts to every ListingGrid below. */
+export function GridSlider({ label = 'Grid density', className = '' }: { label?: string; className?: string }) {
+  const [cols, setCols] = useSharedCols();
+  return (
+    <div className={`flex items-center gap-3 ${className}`}>
+      <label
+        htmlFor="grid-cols-slider"
+        className="text-[10px] uppercase tracking-[0.15em] text-ink/50 whitespace-nowrap"
+      >
+        {label}
+      </label>
+      <input
+        id="grid-cols-slider"
+        type="range"
+        min={MIN_COLS}
+        max={MAX_COLS}
+        step={1}
+        value={cols}
+        onChange={(e) => setCols(parseInt(e.target.value, 10))}
+        className="flex-1 max-w-xs accent-moss h-1 cursor-pointer"
+        aria-label={`Grid columns (${cols})`}
+      />
+      <span className="font-mono text-xs text-ink/70 tabular-nums w-12 text-right">
+        {cols} {cols === 1 ? 'col' : 'cols'}
+      </span>
+      <div className="hidden sm:flex gap-1 ml-2">
+        {[3, 4, 6, 8, 12].map((preset) => (
+          <button
+            key={preset}
+            type="button"
+            onClick={() => setCols(preset)}
+            className={`text-[10px] uppercase tracking-wider px-2 py-1 border transition ${
+              cols === preset
+                ? 'border-moss bg-moss text-sand'
+                : 'border-ink/20 text-ink/60 hover:border-ink/50'
+            }`}
+          >
+            {preset}
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+/** Wrap a list of cards. Reads the shared col count and applies it via CSS. */
+export function ListingGrid({
+  children,
+  className = '',
+  as: Tag = 'div',
+}: {
+  children: ReactNode;
+  className?: string;
+  as?: 'div' | 'ul';
+}) {
+  const [cols] = useSharedCols();
+  // Mobile / tablet caps prevent absurd narrow cards on phones.
+  const tabletCap = Math.min(cols, 2);
+  const desktopCap = Math.min(cols, 4);
+  const fullCap = cols;
+  return (
+    <>
+      <style>{`
+        .wlt-grid-${cols} {
+          display: grid;
+          gap: 1rem;
+          grid-template-columns: repeat(1, minmax(0, 1fr));
+        }
+        @media (min-width: 640px) { .wlt-grid-${cols} { grid-template-columns: repeat(${tabletCap}, minmax(0, 1fr)); } }
+        @media (min-width: 1024px) { .wlt-grid-${cols} { grid-template-columns: repeat(${desktopCap}, minmax(0, 1fr)); } }
+        @media (min-width: 1280px) { .wlt-grid-${cols} { grid-template-columns: repeat(${fullCap}, minmax(0, 1fr)); } }
+      `}</style>
+      <Tag className={`wlt-grid-${cols} ${className}`}>{children}</Tag>
+    </>
+  );
+}
diff --git a/src/components/MapHero.tsx b/src/components/MapHero.tsx
new file mode 100644
index 0000000..7c3bd20
--- /dev/null
+++ b/src/components/MapHero.tsx
@@ -0,0 +1,41 @@
+import { InteractiveMapMount, type MapPin } from './InteractiveMapMount';
+
+/**
+ * MapHero — homepage hero with a real Leaflet map of the archive.
+ * Editorial overlay (title, search) sits on top of the map.
+ */
+export function MapHero({ pins }: { pins: MapPin[] }) {
+  return (
+    <section className="relative bg-ink text-sand" aria-label="Archive map">
+      <div className="relative h-[64vh] min-h-[480px] overflow-hidden">
+        <div className="absolute inset-0">
+          <InteractiveMapMount pins={pins} variant="dark" height={9999} />
+        </div>
+        {/* Editorial overlay — readable scrim */}
+        <div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-ink/90 via-ink/60 to-transparent" />
+        <div className="relative z-10 max-w-6xl mx-auto px-6 py-14 h-full flex flex-col justify-end">
+          <p className="text-[10px] uppercase tracking-[0.2em] text-sand/65 mb-2">
+            Beverly Hills · pilot geography
+          </p>
+          <h1 className="font-display text-5xl md:text-7xl leading-[0.95] tracking-[-0.02em]">
+            Nextdoor,<br />but for then.
+          </h1>
+          <p className="mt-4 text-base md:text-lg text-sand/75 max-w-xl">
+            Every address has a story. Look up the history of a house — who lived there, what was built and when, what the archival maps show. Every fact is tiered by source.
+          </p>
+          <form action="/browse" className="mt-6 flex items-center gap-2 max-w-md pointer-events-auto">
+            <input
+              type="search"
+              name="q"
+              placeholder="Type a Beverly Hills address…"
+              className="flex-1 bg-sand/15 border border-sand/25 text-sand placeholder:text-sand/45 px-4 py-3 font-sans text-sm focus:outline-none focus:border-coral backdrop-blur"
+            />
+            <button type="submit" className="bg-coral text-ink px-5 py-3 text-xs uppercase tracking-wider hover:bg-sand transition">
+              Search
+            </button>
+          </form>
+        </div>
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/MediaWall.tsx b/src/components/MediaWall.tsx
new file mode 100644
index 0000000..9e373e1
--- /dev/null
+++ b/src/components/MediaWall.tsx
@@ -0,0 +1,81 @@
+import { TierBadge } from './TierBadge';
+import type { MediaAsset } from '@/lib/db';
+
+const KIND_LABEL: Record<MediaAsset['kind'], string> = {
+  archival_photo: 'Archival photo',
+  historic_map: 'Historic map',
+  postcard: 'Postcard',
+  drone: 'Aerial photograph',
+  streetview_thumb: 'Street View',
+  newspaper_clip: 'Newspaper clipping',
+};
+
+export function MediaWall({ assets }: { assets: MediaAsset[] }) {
+  if (assets.length === 0) return null;
+
+  return (
+    <section className="my-10">
+      <div className="flex items-baseline justify-between mb-4 flex-wrap gap-2">
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Media in the archive</h2>
+        <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
+          {assets.length} {assets.length === 1 ? 'asset' : 'assets'}
+        </span>
+      </div>
+      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
+        {assets.map(a => (
+          <figure key={a.id} className="border border-ink/10 bg-sand">
+            <div className="relative aspect-[4/3] overflow-hidden bg-ink/10">
+              {/* eslint-disable-next-line @next/next/no-img-element */}
+              <img
+                src={a.storage_url}
+                alt={a.caption ?? KIND_LABEL[a.kind]}
+                loading="lazy"
+                className="w-full h-full object-cover"
+              />
+              {a.year && (
+                <span className="absolute bottom-2 left-2 bg-ink/85 text-sand font-mono text-[10px] uppercase tracking-[0.15em] px-2 py-1">
+                  {a.year}
+                </span>
+              )}
+            </div>
+            <figcaption className="p-4">
+              <p className="text-[10px] uppercase tracking-[0.15em] text-ink/50 mb-1">
+                {KIND_LABEL[a.kind]}
+              </p>
+              {a.caption && (
+                <p className="text-sm text-ink/80 leading-relaxed">{a.caption}</p>
+              )}
+              <div className="mt-3 flex items-center justify-between gap-2">
+                {a.source_tier === 'A' || a.source_tier === 'B' || a.source_tier === 'C' ? (
+                  a.source_url ? (
+                    <a
+                      href={a.source_url}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="hover:opacity-80 transition"
+                    >
+                      <TierBadge
+                        tier={a.source_tier}
+                        sourceLabel={a.source_label ?? undefined}
+                      />
+                    </a>
+                  ) : (
+                    <TierBadge
+                      tier={a.source_tier}
+                      sourceLabel={a.source_label ?? undefined}
+                    />
+                  )
+                ) : null}
+                {a.rights && (
+                  <span className="text-[10px] text-ink/40 truncate" title={a.rights}>
+                    {a.rights}
+                  </span>
+                )}
+              </div>
+            </figcaption>
+          </figure>
+        ))}
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/MiniMap.tsx b/src/components/MiniMap.tsx
new file mode 100644
index 0000000..88ce28e
--- /dev/null
+++ b/src/components/MiniMap.tsx
@@ -0,0 +1,99 @@
+/**
+ * MiniMap — server-rendered SVG map.
+ * Auto-fits a bounding box around the pins with a touch of padding.
+ * Same ink/sand/moss/coral palette as MapHero so the visual lineage is consistent.
+ *
+ * For multi-city pins (e.g. architect across LA), uses a wider bbox.
+ * For a single pin, falls back to a fixed-zoom Beverly Hills frame.
+ */
+
+export type MiniPin = {
+  lat: number;
+  lng: number;
+  label?: string;
+  featured?: boolean;
+};
+
+export function MiniMap({
+  pins,
+  height = 180,
+  className = '',
+  caption,
+}: {
+  pins: MiniPin[];
+  height?: number;
+  className?: string;
+  caption?: string;
+}) {
+  if (pins.length === 0) return null;
+
+  // bbox with min span so a single pin doesn't render a degenerate map
+  const lats = pins.map(p => p.lat);
+  const lngs = pins.map(p => p.lng);
+  let minLat = Math.min(...lats);
+  let maxLat = Math.max(...lats);
+  let minLng = Math.min(...lngs);
+  let maxLng = Math.max(...lngs);
+  const minSpan = 0.04;
+  if (maxLat - minLat < minSpan) {
+    const c = (maxLat + minLat) / 2;
+    minLat = c - minSpan / 2;
+    maxLat = c + minSpan / 2;
+  }
+  if (maxLng - minLng < minSpan) {
+    const c = (maxLng + minLng) / 2;
+    minLng = c - minSpan / 2;
+    maxLng = c + minSpan / 2;
+  }
+  // 8% padding on each axis
+  const padLat = (maxLat - minLat) * 0.08;
+  const padLng = (maxLng - minLng) * 0.08;
+  minLat -= padLat;
+  maxLat += padLat;
+  minLng -= padLng;
+  maxLng += padLng;
+
+  const w = 420;
+  const h = height;
+  const project = (p: MiniPin) => ({
+    x: ((p.lng - minLng) / (maxLng - minLng)) * w,
+    y: ((maxLat - p.lat) / (maxLat - minLat)) * h,
+  });
+
+  return (
+    <figure className={`block ${className}`}>
+      <svg
+        viewBox={`0 0 ${w} ${h}`}
+        preserveAspectRatio="xMidYMid slice"
+        width="100%"
+        height={h}
+        role="img"
+        aria-label={`Map with ${pins.length} ${pins.length === 1 ? 'location' : 'locations'}`}
+        className="bg-ink"
+      >
+        <defs>
+          <pattern id="mini-grid" width="20" height="20" patternUnits="userSpaceOnUse">
+            <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f6f1e818" strokeWidth="0.5" />
+          </pattern>
+        </defs>
+        <rect width={w} height={h} fill="url(#mini-grid)" />
+        {pins.map((p, i) => {
+          const { x, y } = project(p);
+          const r = p.featured ? 5 : 4;
+          const fill = p.featured ? '#d4664a' : '#4a5d45';
+          return (
+            <g key={i}>
+              <circle cx={x} cy={y} r={r + 2} fill={fill} fillOpacity={0.25} />
+              <circle cx={x} cy={y} r={r} fill={fill} stroke="#f6f1e8" strokeWidth={1} />
+            </g>
+          );
+        })}
+      </svg>
+      {caption && (
+        <figcaption className="mt-2 font-mono text-[10px] uppercase tracking-[0.15em] text-ink/50">
+          {caption}
+        </figcaption>
+      )}
+    </figure>
+  );
+}
diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx
new file mode 100644
index 0000000..876fbf8
--- /dev/null
+++ b/src/components/NodeCard.tsx
@@ -0,0 +1,55 @@
+import Link from 'next/link';
+import Image from 'next/image';
+
+/**
+ * NodeCard — card for Address / Person / Neighborhood / Architect nodes.
+ * Discriminated by `kind`. Any surfaced fact must carry its source implicitly
+ * (card hero + byline); detail-level tiering lives on the destination page.
+ */
+
+export type NodeCardKind = 'address' | 'person' | 'neighborhood' | 'architect';
+
+export function NodeCard({
+  kind,
+  href,
+  title,
+  subtitle,
+  imageUrl,
+  eyebrow,
+}: {
+  kind: NodeCardKind;
+  href: string;
+  title: string;
+  subtitle?: string;
+  imageUrl?: string;
+  eyebrow?: string;
+}) {
+  const kindLabel: Record<NodeCardKind, string> = {
+    address: 'Address',
+    person: 'Person',
+    neighborhood: 'Neighborhood',
+    architect: 'Architect',
+  };
+
+  return (
+    <Link
+      href={href}
+      className="group flex flex-col border border-ink/10 bg-sand hover:border-ink/40 transition overflow-hidden"
+    >
+      {imageUrl && (
+        <div className="relative aspect-[4/3] bg-ink/5">
+          <Image src={imageUrl} alt="" fill sizes="(max-width:768px) 100vw, 400px" className="object-cover" />
+        </div>
+      )}
+      <div className="p-4 flex-1 flex flex-col">
+        <p className="text-[10px] uppercase tracking-[0.15em] text-ink/50 mb-1">
+          {eyebrow ?? kindLabel[kind]}
+        </p>
+        <h3 className="font-display text-xl text-ink leading-tight group-hover:text-coral transition">
+          {title}
+        </h3>
+        {subtitle && <p className="mt-1 text-sm text-ink/60">{subtitle}</p>}
+      </div>
+    </Link>
+  );
+}
diff --git a/src/components/PermitTable.tsx b/src/components/PermitTable.tsx
new file mode 100644
index 0000000..c0e486c
--- /dev/null
+++ b/src/components/PermitTable.tsx
@@ -0,0 +1,133 @@
+import { SourceLinkInline } from './SourceLink';
+import type { RealPermit, CodeEnforcementCase, NewsAddressMention } from '@/lib/db';
+
+/** Permits attached to a listing — LADBS / BH / Pasadena / SM. Real records, real links. */
+export function PermitTable({ permits }: { permits: RealPermit[] }) {
+  if (permits.length === 0) return null;
+  return (
+    <section className="max-w-6xl mx-auto px-6 py-12 border-t border-ink/10">
+      <div className="flex items-baseline justify-between gap-4 mb-5 flex-wrap">
+        <div>
+          <p className="text-xs uppercase tracking-[0.15em] text-ink/50">Building permits</p>
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">
+            {permits.length} {permits.length === 1 ? 'permit' : 'permits'} on file
+          </h2>
+        </div>
+        <p className="text-[10px] uppercase tracking-[0.15em] text-ink/40 font-mono">
+          tier A · government primary record
+        </p>
+      </div>
+      <div className="bg-white border border-ink/10 overflow-x-auto">
+        <table className="w-full text-xs">
+          <thead className="bg-ink/[0.04] text-[10px] uppercase tracking-[0.15em] text-ink/55">
+            <tr>
+              <th className="text-left p-2.5">issued</th>
+              <th className="text-left p-2.5">type</th>
+              <th className="text-left p-2.5">use</th>
+              <th className="text-left p-2.5">work described</th>
+              <th className="text-right p-2.5">value</th>
+              <th className="text-left p-2.5">status</th>
+              <th className="text-right p-2.5">source</th>
+            </tr>
+          </thead>
+          <tbody>
+            {permits.map(p => (
+              <tr key={p.id} className="border-t border-ink/5 align-top">
+                <td className="p-2.5 font-mono text-ink/80">{p.issue_date ?? '—'}</td>
+                <td className="p-2.5">
+                  <span className="font-medium text-ink">{p.permit_type ?? '—'}</span>
+                  {p.permit_sub_type && <div className="text-ink/55 text-[11px]">{p.permit_sub_type}</div>}
+                </td>
+                <td className="p-2.5 text-ink/65">{p.use_desc ?? '—'}</td>
+                <td className="p-2.5 text-ink/65 max-w-md">{p.work_desc ?? '—'}</td>
+                <td className="p-2.5 text-right font-mono tabular-nums text-ink/80">
+                  {p.valuation && parseFloat(p.valuation) > 0 ? `$${parseFloat(p.valuation).toLocaleString()}` : '—'}
+                </td>
+                <td className="p-2.5 text-ink/65">{p.status_desc ?? '—'}</td>
+                <td className="p-2.5 text-right">
+                  <SourceLinkInline label={`#${p.permit_number}`} url={p.source_url} />
+                  <div className="text-[10px] text-ink/40 mt-0.5">{p.source_label ?? p.source}</div>
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </section>
+  );
+}
+
+export function CodeEnforcementTable({ cases }: { cases: CodeEnforcementCase[] }) {
+  if (cases.length === 0) return null;
+  return (
+    <section className="max-w-6xl mx-auto px-6 py-12 border-t border-ink/10">
+      <div className="mb-5">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50">Code enforcement</p>
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">
+          {cases.length} {cases.length === 1 ? 'case' : 'cases'} on file
+        </h2>
+      </div>
+      <div className="bg-white border border-ink/10 overflow-x-auto">
+        <table className="w-full text-xs">
+          <thead className="bg-ink/[0.04] text-[10px] uppercase tracking-[0.15em] text-ink/55">
+            <tr>
+              <th className="text-left p-2.5">opened</th>
+              <th className="text-left p-2.5">type</th>
+              <th className="text-left p-2.5">status</th>
+              <th className="text-right p-2.5">source</th>
+            </tr>
+          </thead>
+          <tbody>
+            {cases.map(c => (
+              <tr key={c.id} className="border-t border-ink/5 align-top">
+                <td className="p-2.5 font-mono text-ink/80">{c.opened_at ?? '—'}</td>
+                <td className="p-2.5 text-ink/80">{c.case_type ?? '—'}</td>
+                <td className="p-2.5 text-ink/65">{c.status ?? '—'}</td>
+                <td className="p-2.5 text-right">
+                  <SourceLinkInline label={`#${c.case_number}`} url={c.source_url} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </section>
+  );
+}
+
+export function NewsMentionsList({ mentions }: { mentions: NewsAddressMention[] }) {
+  if (mentions.length === 0) return null;
+  return (
+    <section className="max-w-6xl mx-auto px-6 py-12 border-t border-ink/10">
+      <div className="mb-5">
+        <p className="text-xs uppercase tracking-[0.15em] text-ink/50">In the newspaper</p>
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">
+          {mentions.length} historical {mentions.length === 1 ? 'mention' : 'mentions'}
+        </h2>
+        <p className="text-xs text-ink/55 mt-1">
+          Click any source to view the actual scanned newspaper page on Internet Archive.
+        </p>
+      </div>
+      <ul className="divide-y divide-ink/10">
+        {mentions.map(m => (
+          <li key={m.id} className="py-4 flex flex-col gap-1">
+            <div className="flex items-baseline justify-between gap-3 flex-wrap">
+              <span className="font-display text-lg text-ink">{m.raw_address}</span>
+              <span className="font-mono text-[11px] text-ink/55">
+                {m.paper_name} · {m.issue_date ?? 'undated'}
+              </span>
+            </div>
+            {m.context && (
+              <p className="text-sm text-ink/70 leading-relaxed italic">
+                &ldquo;{m.context.slice(0, 280)}{m.context.length > 280 ? '…' : ''}&rdquo;
+              </p>
+            )}
+            <div>
+              <SourceLinkInline label={`view scanned page on Internet Archive`} url={m.ia_url} />
+            </div>
+          </li>
+        ))}
+      </ul>
+    </section>
+  );
+}
diff --git a/src/components/PosterPlaceholder.tsx b/src/components/PosterPlaceholder.tsx
new file mode 100644
index 0000000..9006601
--- /dev/null
+++ b/src/components/PosterPlaceholder.tsx
@@ -0,0 +1,159 @@
+/**
+ * PosterPlaceholder — deterministic SVG poster used when a production has no
+ * poster_url. Title, year, and kind drive the look; same input always yields
+ * the same poster so cards stay stable across renders.
+ *
+ * Aesthetic: editorial, in-lineage with the rest of the design system.
+ */
+
+const KIND_PALETTE: Record<string, { bg: string; ink: string; accent: string; label: string }> = {
+  film:        { bg: '#1a1410', ink: '#f6f1e8', accent: '#d4664a', label: 'Feature' },
+  tv:          { bg: '#0e2a26', ink: '#f6f1e8', accent: '#d4664a', label: 'Television' },
+  commercial:  { bg: '#3a2515', ink: '#f6f1e8', accent: '#e8a574', label: 'Commercial' },
+  music_video: { bg: '#2a1a3a', ink: '#f6f1e8', accent: '#e8a574', label: 'Music video' },
+  documentary: { bg: '#1d2a1a', ink: '#f6f1e8', accent: '#d4664a', label: 'Documentary' },
+  short:       { bg: '#1a1410', ink: '#f6f1e8', accent: '#d4664a', label: 'Short' },
+  student:     { bg: '#222', ink: '#f6f1e8', accent: '#d4664a', label: 'Student' },
+};
+
+function fnv(s: string): number {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) {
+    h ^= s.charCodeAt(i);
+    h = Math.imul(h, 16777619);
+  }
+  return h >>> 0;
+}
+
+export function PosterPlaceholder({
+  title,
+  year,
+  kind,
+  className = '',
+  width = 320,
+  height = 480,
+}: {
+  title: string;
+  year: number | null;
+  kind: string;
+  className?: string;
+  width?: number;
+  height?: number;
+}) {
+  const palette = KIND_PALETTE[kind] ?? KIND_PALETTE.film!;
+  const seed = fnv(`${title}-${year ?? ''}`);
+  // Deterministic decorative angle 8°-22° tilt
+  const angle = 8 + (seed % 14);
+  const stripe1Y = 32 + (seed % 28);
+  const stripe2Y = stripe1Y + 14 + ((seed >> 4) & 0x0f);
+  const initials = title
+    .split(/\s+/)
+    .filter(w => w.length > 0)
+    .map(w => w[0])
+    .join('')
+    .slice(0, 3)
+    .toUpperCase();
+
+  // Title on multiple lines — break by words, max 4 lines, max 22 chars/line.
+  const words = title.split(/\s+/);
+  const lines: string[] = [];
+  let cur = '';
+  for (const w of words) {
+    if ((cur + ' ' + w).trim().length <= 22) cur = (cur + ' ' + w).trim();
+    else {
+      if (cur) lines.push(cur);
+      cur = w;
+      if (lines.length >= 3) break;
+    }
+  }
+  if (cur && lines.length < 4) lines.push(cur);
+
+  const W = 320;
+  const H = 480;
+
+  return (
+    <svg
+      viewBox={`0 0 ${W} ${H}`}
+      width={width}
+      height={height}
+      className={className}
+      role="img"
+      aria-label={`Poster for ${title}${year ? ` (${year})` : ''}`}
+      preserveAspectRatio="xMidYMid slice"
+    >
+      <rect width={W} height={H} fill={palette.bg} />
+      {/* Decorative tilted stripes */}
+      <g transform={`rotate(${angle} ${W / 2} ${H / 2})`}>
+        <rect x={-50} y={stripe1Y} width={W + 100} height={2} fill={palette.accent} opacity={0.85} />
+        <rect x={-50} y={stripe2Y} width={W + 100} height={1} fill={palette.ink} opacity={0.35} />
+        <rect x={-50} y={H - 90} width={W + 100} height={1} fill={palette.ink} opacity={0.25} />
+      </g>
+      {/* Top-left mark */}
+      <text
+        x={20}
+        y={36}
+        fill={palette.accent}
+        fontFamily="Inter, system-ui, sans-serif"
+        fontSize={11}
+        fontWeight={500}
+        letterSpacing={2}
+        style={{ textTransform: 'uppercase' as const }}
+      >
+        {palette.label}
+      </text>
+      {/* Big initials */}
+      <text
+        x={W / 2}
+        y={H / 2 - 30}
+        fill={palette.ink}
+        fontFamily="Instrument Serif, Georgia, serif"
+        fontSize={120}
+        textAnchor="middle"
+        opacity={0.9}
+      >
+        {initials}
+      </text>
+      {/* Title block */}
+      <g transform={`translate(20, ${H - 110})`}>
+        {lines.map((line, i) => (
+          <text
+            key={i}
+            x={0}
+            y={i * 30}
+            fill={palette.ink}
+            fontFamily="Instrument Serif, Georgia, serif"
+            fontSize={26}
+            letterSpacing={-0.4}
+          >
+            {line}
+          </text>
+        ))}
+      </g>
+      {year && (
+        <text
+          x={20}
+          y={H - 18}
+          fill={palette.ink}
+          opacity={0.55}
+          fontFamily="IBM Plex Mono, monospace"
+          fontSize={11}
+          letterSpacing={1.5}
+        >
+          {year}
+        </text>
+      )}
+      <text
+        x={W - 20}
+        y={H - 18}
+        fill={palette.ink}
+        opacity={0.4}
+        fontFamily="IBM Plex Mono, monospace"
+        fontSize={9}
+        letterSpacing={1.5}
+        textAnchor="end"
+      >
+        the archive
+      </text>
+    </svg>
+  );
+}
diff --git a/src/components/Projection.tsx b/src/components/Projection.tsx
new file mode 100644
index 0000000..66da2cc
--- /dev/null
+++ b/src/components/Projection.tsx
@@ -0,0 +1,39 @@
+import { ConfidenceDot } from './ConfidenceDot';
+
+/**
+ * Projection — numeric + confidence + anchor window.
+ * Wraps ConfidenceDot. Never displays a projection without the anchor.
+ *
+ * Anchor example: "LA 1978–1984 price trajectory (CAR + FRED)"
+ */
+export function Projection({
+  label,
+  value,
+  unit,
+  confidence,
+  anchorWindow,
+  note,
+}: {
+  label: string;
+  value: string | number;
+  unit?: string;
+  confidence: number;
+  anchorWindow: string;
+  note?: string;
+}) {
+  return (
+    <div className="py-4 border-b border-ink/5">
+      <div className="text-xs uppercase tracking-wider text-ink/50 mb-1">{label}</div>
+      <div className="flex items-baseline gap-2">
+        <span className="font-display text-3xl text-ink">
+          {typeof value === 'number' ? value.toLocaleString() : value}
+        </span>
+        {unit && <span className="text-sm text-ink/60">{unit}</span>}
+      </div>
+      <div className="mt-2">
+        <ConfidenceDot confidence={confidence} anchorWindow={anchorWindow} />
+      </div>
+      {note && <p className="mt-2 text-xs text-ink/50 leading-relaxed">{note}</p>}
+    </div>
+  );
+}
diff --git a/src/components/ProvenanceFooter.tsx b/src/components/ProvenanceFooter.tsx
new file mode 100644
index 0000000..7453351
--- /dev/null
+++ b/src/components/ProvenanceFooter.tsx
@@ -0,0 +1,62 @@
+/**
+ * ProvenanceFooter — "why is this page here" transparency block.
+ *
+ * Per the audit's transparency mandate: every public address page tells
+ * the reader exactly how many sources back it, what tiers they came from,
+ * when the record was last updated, and where to read the source rules.
+ */
+
+type Counts = { A: number; B: number; C: number };
+
+export function ProvenanceFooter({
+  counts,
+  lastUpdated,
+}: {
+  counts: Counts;
+  lastUpdated?: Date | string | null;
+}) {
+  const total = counts.A + counts.B + counts.C;
+  const updated =
+    lastUpdated instanceof Date
+      ? lastUpdated
+      : lastUpdated
+        ? new Date(lastUpdated)
+        : null;
+
+  return (
+    <section className="border-t border-ink/10 bg-ink/[0.02]">
+      <div className="max-w-6xl mx-auto px-6 py-8 grid md:grid-cols-[1fr_auto] gap-6 items-baseline">
+        <div>
+          <p className="text-xs uppercase tracking-[0.15em] text-ink/50 mb-2">
+            Why this page is in the archive
+          </p>
+          <p className="text-sm text-ink/75 leading-relaxed max-w-prose">
+            This page is published because we have <strong>{total}</strong>{' '}
+            {total === 1 ? 'public source' : 'public sources'} backing it —
+            <span className="font-mono text-[12px] mx-1">
+              {counts.A} Tier-A · {counts.B} Tier-B · {counts.C} Tier-C
+            </span>
+            . Tier-A is government / institutional record. Tier-B is library
+            or archival collection. Tier-C is verified secondary. Discovery
+            leads (Tier-D) never appear on public pages.
+          </p>
+        </div>
+        <div className="text-[10px] uppercase tracking-[0.15em] text-ink/45 md:text-right">
+          {updated && !Number.isNaN(updated.getTime()) && (
+            <p>
+              Record updated{' '}
+              <time dateTime={updated.toISOString()} className="font-mono">
+                {updated.toISOString().slice(0, 10)}
+              </time>
+            </p>
+          )}
+          <p>
+            <a href="/about" className="hover:text-coral transition">
+              Source rules →
+            </a>
+          </p>
+        </div>
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/Rail.tsx b/src/components/Rail.tsx
new file mode 100644
index 0000000..600b402
--- /dev/null
+++ b/src/components/Rail.tsx
@@ -0,0 +1,22 @@
+/**
+ * Rail — horizontal editorial carousel.
+ * Max 6 visible items. Serif section label.
+ */
+export function Rail({
+  label,
+  children,
+}: {
+  label: string;
+  children: React.ReactNode;
+}) {
+  return (
+    <section className="py-10">
+      <div className="max-w-6xl mx-auto px-6 mb-5">
+        <h2 className="font-display text-2xl md:text-3xl text-ink tracking-[-0.01em]">{label}</h2>
+      </div>
+      <div className="max-w-6xl mx-auto px-6">
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">{children}</div>
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/RetiredFeatureNotice.tsx b/src/components/RetiredFeatureNotice.tsx
new file mode 100644
index 0000000..9647ff6
--- /dev/null
+++ b/src/components/RetiredFeatureNotice.tsx
@@ -0,0 +1,50 @@
+/**
+ * RetiredFeatureNotice — soft-disable stub for paid features when FREE_FOREVER=true.
+ * Used by /promote, /hosts, etc. Cohesive with the editorial archive aesthetic.
+ * Per-host brand name is read from the surface registry so each domain shows
+ * its own name in the body copy.
+ */
+import Link from 'next/link';
+import { headers } from 'next/headers';
+import { BreadcrumbArchive } from './BreadcrumbArchive';
+import { siteForHost } from '@/lib/site';
+
+export async function RetiredFeatureNotice({
+  title,
+  message,
+}: {
+  title: string;
+  message?: string;
+}) {
+  const h = await headers();
+  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+  return (
+    <>
+      <div className="max-w-2xl mx-auto px-6 pt-6">
+        <BreadcrumbArchive items={[{ label: 'Archive', href: '/' }, { label: 'Retired' }]} />
+      </div>
+      <main className="max-w-2xl mx-auto px-6 py-20 text-ink">
+        <p className="text-[11px] uppercase tracking-[0.2em] text-ink/50 mb-3">archive note</p>
+        <h1 className="font-display text-4xl tracking-[-0.01em] mb-4">{title}</h1>
+        <p className="text-ink/75 leading-relaxed mb-6">
+          {message ??
+            `This feature has been retired. ${surface.brandName} is free forever — no paid tiers, no promoted placements, no host upgrades. Every address page is generated equally from the public archive.`}
+        </p>
+        <div className="flex gap-3 mt-8">
+          <Link
+            href="/"
+            className="inline-block border border-ink/30 px-5 py-2 text-sm uppercase tracking-[0.15em] hover:bg-ink hover:text-cream transition"
+          >
+            Back to the archive
+          </Link>
+          <Link
+            href="/submit"
+            className="inline-block border border-ink/30 px-5 py-2 text-sm uppercase tracking-[0.15em] hover:bg-ink hover:text-cream transition"
+          >
+            Add an address
+          </Link>
+        </div>
+      </main>
+    </>
+  );
+}
diff --git a/src/components/SourceLink.tsx b/src/components/SourceLink.tsx
new file mode 100644
index 0000000..50acbd2
--- /dev/null
+++ b/src/components/SourceLink.tsx
@@ -0,0 +1,65 @@
+import Link from 'next/link';
+
+/**
+ * SourceLink — every fact in the archive must show its source.
+ *
+ * Renders a small "source: NAME ↗" link to the actual source document
+ * (Socrata permit row, IA scanned page, Assessor portal, etc.). Steve's
+ * editorial rule (2026-04-29): "real links back to the original source
+ * showing the actual ocr piece of paper etc."
+ */
+export function SourceLink({
+  label,
+  url,
+  tier,
+  date,
+  className = '',
+}: {
+  label: string;
+  url?: string | null;
+  tier?: string | null;          // 'A' | 'B' | 'C' | 'D'
+  date?: string | null;
+  className?: string;
+}) {
+  const tierLabel = tier ? { A: 'gov', B: 'verified', C: 'community', D: 'inferred' }[tier] ?? tier : null;
+  const tierColor = tier ? {
+    A: 'text-moss',
+    B: 'text-blue-700',
+    C: 'text-amber-700',
+    D: 'text-ink/40',
+  }[tier] ?? 'text-ink/40' : '';
+
+  return (
+    <span className={`font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45 ${className}`}>
+      source:{' '}
+      {url ? (
+        <a
+          href={url}
+          target="_blank"
+          rel="noreferrer noopener"
+          className="hover:text-coral underline-offset-2 hover:underline transition"
+        >
+          {label} ↗
+        </a>
+      ) : (
+        <span className="text-ink/65">{label}</span>
+      )}
+      {tierLabel && (
+        <span className={`ml-2 ${tierColor}`}>tier {tier} · {tierLabel}</span>
+      )}
+      {date && <span className="ml-2">· {date.slice(0, 10)}</span>}
+    </span>
+  );
+}
+
+/** Compact sourcing block for tabular contexts (permit/event rows). */
+export function SourceLinkInline({ label, url }: { label: string; url?: string | null }) {
+  return url ? (
+    <a href={url} target="_blank" rel="noreferrer noopener"
+       className="font-mono text-[10px] uppercase tracking-[0.1em] text-coral hover:underline">
+      {label} ↗
+    </a>
+  ) : (
+    <span className="font-mono text-[10px] uppercase tracking-[0.1em] text-ink/40">{label}</span>
+  );
+}
diff --git a/src/components/StarsCtaRow.tsx b/src/components/StarsCtaRow.tsx
new file mode 100644
index 0000000..00fe8c6
--- /dev/null
+++ b/src/components/StarsCtaRow.tsx
@@ -0,0 +1,101 @@
+/**
+ * StarsCtaRow — homepage CTA strip that surfaces the four address-adjacent
+ * destinations launched 2026-04-30:
+ *   - /stars         — Map of historical celebrity residences
+ *   - /restaurants   — Where they ate (sourced sightings)
+ *   - /stars/walk    — Hollywood Walk of Fame
+ *   - /stars/nearby  — Mobile GPS "stars near me"
+ *
+ * Per-surface labels and accent colors per SurfaceKey:
+ *   flagship  → "Open archive" voice, #1f4d40 accent (moss)
+ *   claim     → "Filmed here" voice,  #c9292e accent (red)
+ *   community → "From the block" voice, #7a6a3f accent (gold)
+ */
+import Link from 'next/link';
+import type { SurfaceKey } from '@/lib/site';
+
+type Card = {
+  kicker: string;
+  title: string;
+  blurb: string;
+  href: string;
+};
+
+const CARDS: Record<SurfaceKey, Card[]> = {
+  flagship: [
+    { kicker: 'Map of the stars',  title: 'Where they really lived',  blurb: 'Historical residences, sourced and dated', href: '/stars' },
+    { kicker: 'Where they ate',    title: 'Celebrity restaurants',    blurb: 'Musso & Frank to The Ivy — sightings sourced', href: '/restaurants' },
+    { kicker: 'Walk of Fame',      title: 'Every star’s address', blurb: 'Hollywood Blvd, Vine St — pin by pin',     href: '/stars/walk' },
+    { kicker: 'Mobile · GPS',      title: 'Stars near me',            blurb: 'Walking-distance lookup from your phone',  href: '/stars/nearby' },
+  ],
+  claim: [
+    { kicker: 'Filmed here',       title: 'Map of the stars',         blurb: 'Was a name you know once at this address?', href: '/stars' },
+    { kicker: 'Where they ate',    title: 'Celebrity restaurants',    blurb: 'Sourced patron lists, by neighborhood',     href: '/restaurants' },
+    { kicker: 'Walk of Fame',      title: 'Hollywood, mapped',        blurb: 'Every honoree, every sidewalk star',        href: '/stars/walk' },
+    { kicker: 'On the move',       title: 'Stars near me',            blurb: 'Pull up the closest star from your phone',  href: '/stars/nearby' },
+  ],
+  community: [
+    { kicker: 'From the block',    title: 'Map of the stars',         blurb: 'The famous neighbors, then',                href: '/stars' },
+    { kicker: 'Where the block ate', title: 'Celebrity restaurants',  blurb: 'The rooms our neighbors went to',           href: '/restaurants' },
+    { kicker: 'Pages of fame',     title: 'Walk of Fame',             blurb: 'Hollywood Blvd, the long version',          href: '/stars/walk' },
+    { kicker: 'On a walk',         title: 'Stars near me',            blurb: 'What’s star-shaped within a block',    href: '/stars/nearby' },
+  ],
+};
+
+const ACCENT: Record<SurfaceKey, string> = {
+  flagship:  '#1f4d40',
+  claim:     '#c9292e',
+  community: '#7a6a3f',
+};
+
+const SECTION_KICKER: Record<SurfaceKey, string> = {
+  flagship:  'Around the archive',
+  claim:     'Beyond your address',
+  community: 'Off the block',
+};
+
+export function StarsCtaRow({ surface }: { surface: SurfaceKey }) {
+  const cards = CARDS[surface];
+  const accent = ACCENT[surface];
+  const kicker = SECTION_KICKER[surface];
+
+  return (
+    <section className="border-b border-ink/10 bg-[#fbf6e8]">
+      <div className="max-w-6xl mx-auto px-6 py-10 md:py-14">
+        <p
+          className="font-mono text-[10px] uppercase tracking-[0.22em] mb-6"
+          style={{ color: accent }}
+        >
+          {kicker}
+        </p>
+        <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
+          {cards.map(c => (
+            <li key={c.href}>
+              <Link
+                href={c.href}
+                className="group block bg-white border border-ink/15 hover:border-ink/40 transition px-5 py-5 h-full"
+              >
+                <p
+                  className="font-mono text-[9px] uppercase tracking-[0.18em] mb-2"
+                  style={{ color: accent }}
+                >
+                  {c.kicker}
+                </p>
+                <h3 className="font-display text-2xl text-ink leading-[1.05] tracking-[-0.01em] group-hover:text-coral transition">
+                  {c.title}
+                </h3>
+                <p className="mt-2 text-sm text-ink/65 leading-snug">{c.blurb}</p>
+                <p
+                  className="mt-3 font-mono text-[10px] uppercase tracking-[0.18em] inline-flex items-center gap-1"
+                  style={{ color: accent }}
+                >
+                  Open <span aria-hidden>&rarr;</span>
+                </p>
+              </Link>
+            </li>
+          ))}
+        </ul>
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/StreetView.tsx b/src/components/StreetView.tsx
new file mode 100644
index 0000000..8674085
--- /dev/null
+++ b/src/components/StreetView.tsx
@@ -0,0 +1,71 @@
+/**
+ * StreetView — companion to the live Street View hero in AddressMasthead.
+ * Renders the aerial map iframe and out-links to Google Maps for both the
+ * Street View time-machine and the full aerial view.
+ *
+ * No Google API key required — uses the no-key embed URL pattern.
+ * Year-by-year captures live in <StreetViewHistory /> and need a key.
+ */
+
+type Props = {
+  lat: number;
+  lng: number;
+  addressLabel?: string;
+  height?: number;
+};
+
+export function StreetView({ lat, lng, addressLabel, height = 380 }: Props) {
+  const satelliteSrc = `https://www.google.com/maps?q=${lat},${lng}&t=k&z=19&ie=UTF8&iwloc=&output=embed`;
+  const openSv = `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`;
+  const openSat = `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
+
+  return (
+    <section className="my-10">
+      <div className="flex items-baseline justify-between mb-4 flex-wrap gap-2">
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">From above</h2>
+        <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
+          live · google maps
+        </span>
+      </div>
+
+      <div className="relative overflow-hidden border border-ink/10 bg-ink">
+        <iframe
+          title={`Aerial view of ${addressLabel ?? 'property'}`}
+          src={satelliteSrc}
+          width="100%"
+          height={height}
+          style={{ border: 0, display: 'block' }}
+          loading="lazy"
+          referrerPolicy="no-referrer-when-downgrade"
+          allowFullScreen
+        />
+        <span className="absolute top-2 left-2 bg-ink/85 text-sand text-[10px] uppercase tracking-[0.15em] px-2 py-1">
+          Aerial
+        </span>
+      </div>
+
+      <div className="mt-4 flex flex-wrap items-center gap-3">
+        <a
+          href={openSv}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="inline-flex items-center gap-2 bg-ink text-sand px-4 py-2 text-xs uppercase tracking-wider hover:bg-moss transition"
+        >
+          Open Street View →
+        </a>
+        <a
+          href={openSat}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="inline-flex items-center gap-2 border border-ink/30 text-ink px-4 py-2 text-xs uppercase tracking-wider hover:border-coral hover:text-coral transition"
+        >
+          Open aerial map →
+        </a>
+        <p className="text-[11px] text-ink/55 leading-relaxed max-w-md">
+          Inside Street View, click the <strong>clock icon</strong> (top-left) to scrub the
+          property&rsquo;s historical captures — typically back to 2007.
+        </p>
+      </div>
+    </section>
+  );
+}
diff --git a/src/components/StreetViewHistory.tsx b/src/components/StreetViewHistory.tsx
new file mode 100644
index 0000000..5ed538d
--- /dev/null
+++ b/src/components/StreetViewHistory.tsx
@@ -0,0 +1,193 @@
+'use client';
+import { useEffect, useRef, useState } from 'react';
+
+/**
+ * StreetViewHistory — year-by-year Google Street View captures of an address.
+ *
+ * Activates the moment NEXT_PUBLIC_GOOGLE_MAPS_API_KEY is set in env.
+ * Without the key, renders an editorial "history pending" card that
+ * explains what's needed and links out to the live Street View where
+ * the user can use Google's built-in time-machine icon.
+ *
+ * Why we need a key: the JS Maps API exposes
+ * StreetViewService.getPanorama() and pano.time/imageDate fields, which
+ * are the only way to enumerate historical captures. The Static API
+ * endpoint then serves a thumbnail per pano ID. Both endpoints are
+ * key-required (typical cost: $7/1k SV requests; year-by-year strip
+ * for one address is ~6-12 requests).
+ */
+
+type GoogleNs = typeof window & {
+  google?: {
+    maps?: {
+      StreetViewService?: new () => StreetViewService;
+      StreetViewPreference?: { NEAREST: string };
+      StreetViewSource?: { OUTDOOR: string };
+      LatLng?: new (lat: number, lng: number) => unknown;
+    };
+  };
+};
+
+type StreetViewService = {
+  getPanorama(req: unknown, cb: (data: PanoData | null, status: string) => void): void;
+};
+
+type PanoData = {
+  location?: { pano: string; latLng?: { lat: () => number; lng: () => number } };
+  imageDate?: string;
+  time?: { pano: string; imageDate: string }[];
+};
+
+type Capture = { pano: string; year: number; month?: number; date: string };
+
+const KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';
+
+function loadGoogleMaps(apiKey: string): Promise<void> {
+  if (typeof window === 'undefined') return Promise.resolve();
+  const w = window as GoogleNs;
+  if (w.google?.maps?.StreetViewService) return Promise.resolve();
+  return new Promise((resolve, reject) => {
+    const existing = document.querySelector<HTMLScriptElement>('script[data-stayclaim-gmaps]');
+    if (existing) {
+      existing.addEventListener('load', () => resolve());
+      existing.addEventListener('error', reject);
+      return;
+    }
+    const s = document.createElement('script');
+    s.dataset.stayclaimGmaps = 'true';
+    s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&v=weekly&libraries=streetView`;
+    s.async = true;
+    s.onload = () => resolve();
+    s.onerror = () => reject(new Error('Failed to load Google Maps JS'));
+    document.head.appendChild(s);
+  });
+}
+
+export function StreetViewHistory({ lat, lng }: { lat: number; lng: number }) {
+  const [captures, setCaptures] = useState<Capture[] | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+  const ranRef = useRef(false);
+
+  useEffect(() => {
+    if (!KEY || ranRef.current) return;
+    ranRef.current = true;
+    setLoading(true);
+    (async () => {
+      try {
+        await loadGoogleMaps(KEY);
+        const w = window as GoogleNs;
+        if (!w.google?.maps?.StreetViewService || !w.google.maps.LatLng) {
+          throw new Error('Google Maps StreetViewService not available');
+        }
+        const svc = new w.google.maps.StreetViewService();
+        const loc = new w.google.maps.LatLng(lat, lng);
+        const data: PanoData | null = await new Promise((resolve, reject) => {
+          svc.getPanorama(
+            { location: loc, radius: 50, source: w.google!.maps!.StreetViewSource?.OUTDOOR ?? 'outdoor' },
+            (d: PanoData | null, status: string) => {
+              if (status === 'OK' && d) resolve(d);
+              else reject(new Error(`SV status: ${status}`));
+            }
+          );
+        });
+        const list: Capture[] = [];
+        if (data?.time?.length) {
+          for (const t of data.time) {
+            if (!t.pano || !t.imageDate) continue;
+            const [y, m] = t.imageDate.split('-').map(Number);
+            list.push({ pano: t.pano, year: y!, month: m, date: t.imageDate });
+          }
+        } else if (data?.location?.pano && data.imageDate) {
+          const [y, m] = data.imageDate.split('-').map(Number);
+          list.push({ pano: data.location.pano, year: y!, month: m, date: data.imageDate });
+        }
+        list.sort((a, b) => b.date.localeCompare(a.date));
+        setCaptures(list);
+      } catch (e) {
+        setError((e as Error).message);
+      } finally {
+        setLoading(false);
+      }
+    })();
+  }, [lat, lng]);
+
+  // No API key — editorial placeholder.
+  if (!KEY) {
+    return (
+      <section className="my-10">
+        <div className="flex items-baseline justify-between mb-4 flex-wrap gap-2">
+          <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Through the years</h2>
+          <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
+            history feature · key pending
+          </span>
+        </div>
+        <div className="border border-ink/10 bg-sand p-6">
+          <p className="text-base text-ink/80 leading-relaxed max-w-prose">
+            Google Street View has captured this property roughly every two years since 2007. Once we
+            wire up the Maps API key, this section becomes a year-by-year strip you can scrub — every
+            capture in one place, with deep links to the live Street View at that moment.
+          </p>
+          <p className="mt-3 text-[11px] text-ink/55 leading-relaxed">
+            For now, open Street View above and use the clock icon (top-left) to scrub the timeline yourself.
+          </p>
+        </div>
+      </section>
+    );
+  }
+
+  // With key — render the year strip.
+  return (
+    <section className="my-10">
+      <div className="flex items-baseline justify-between mb-4 flex-wrap gap-2">
+        <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Through the years</h2>
+        {captures && (
+          <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
+            {captures.length} {captures.length === 1 ? 'capture' : 'captures'} on record
+          </span>
+        )}
+      </div>
+      {loading && (
+        <p className="text-sm italic text-ink/55">Loading historical captures…</p>
+      )}
+      {error && (
+        <p className="text-sm italic text-coral">Could not load history: {error}</p>
+      )}
+      {captures && captures.length === 0 && (
+        <p className="text-sm italic text-ink/55">No Street View captures available at this address.</p>
+      )}
+      {captures && captures.length > 0 && (
+        <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
+          {captures.map(c => {
+            const thumb = `https://maps.googleapis.com/maps/api/streetview?size=400x300&pano=${encodeURIComponent(c.pano)}&fov=90&pitch=0&key=${encodeURIComponent(KEY)}`;
+            const open = `https://www.google.com/maps/@?api=1&map_action=pano&pano=${encodeURIComponent(c.pano)}`;
+            return (
+              <a
+                key={c.pano}
+                href={open}
+                target="_blank"
+                rel="noopener noreferrer"
+                className="group block border border-ink/10 bg-ink overflow-hidden hover:border-coral transition"
+              >
+                <div className="relative aspect-[4/3] bg-ink/40">
+                  <img
+                    src={thumb}
+                    alt={`Street View capture from ${c.date}`}
+                    loading="lazy"
+                    className="absolute inset-0 w-full h-full object-cover"
+                  />
+                </div>
+                <div className="px-3 py-2 flex items-baseline justify-between">
+                  <span className="font-display text-lg text-sand">{c.year}</span>
+                  {c.month && (
+                    <span className="font-mono text-[10px] uppercase text-sand/55">{c.date}</span>
+                  )}
+                </div>
+              </a>
+            );
+          })}
+        </div>
+      )}
+    </section>
+  );
+}
diff --git a/src/components/SubmitMiniMap.tsx b/src/components/SubmitMiniMap.tsx
new file mode 100644
index 0000000..145a7cd
--- /dev/null
+++ b/src/components/SubmitMiniMap.tsx
@@ -0,0 +1,38 @@
+'use client';
+/**
+ * Mini map shown next to the LA-records preview card on /submit.
+ * Uses react-leaflet (already in package.json from the legacy parcel pages).
+ * Tiny — 280px wide, single pin, zoom locked to 17 for a "yes that's the
+ * right house" visual confirmation.
+ */
+import dynamic from 'next/dynamic';
+
+const MapContainer = dynamic(() => import('react-leaflet').then(m => m.MapContainer), { ssr: false });
+const TileLayer    = dynamic(() => import('react-leaflet').then(m => m.TileLayer),    { ssr: false });
+const CircleMarker = dynamic(() => import('react-leaflet').then(m => m.CircleMarker), { ssr: false });
+
+export function SubmitMiniMap({ lat, lng, label }: { lat: number; lng: number; label: string }) {
+  return (
+    <div className="border border-ink/15" style={{ width: 220, height: 160 }}>
+      <MapContainer
+        center={[lat, lng]}
+        zoom={17}
+        scrollWheelZoom={false}
+        zoomControl={false}
+        attributionControl={false}
+        style={{ width: '100%', height: '100%' }}
+      >
+        <TileLayer url="https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}.png" />
+        <CircleMarker
+          center={[lat, lng]}
+          radius={8}
+          pathOptions={{ color: '#c9292e', weight: 3, fillColor: '#c9292e', fillOpacity: 0.85 }}
+        >
+        </CircleMarker>
+      </MapContainer>
+      <p className="text-[9px] font-mono uppercase tracking-[0.15em] text-ink/45 mt-1 truncate" title={label}>
+        {label}
+      </p>
+    </div>
+  );
+}
diff --git a/src/components/TierBadge.tsx b/src/components/TierBadge.tsx
new file mode 100644
index 0000000..d8ba3cf
--- /dev/null
+++ b/src/components/TierBadge.tsx
@@ -0,0 +1,53 @@
+/**
+ * TierBadge — source-tier chip required on every A/B/C fact on public pages.
+ *
+ *   A = government record (assessor, recorder, city planning)
+ *   B = archive (Sanborn, HABS, library, newspaper)
+ *   C = verified secondary source
+ *   D = social / lead — NEVER renders on public pages (compile-time guard)
+ *
+ * Constraint source: docs/deep-research-report.md:60-65
+ */
+
+export type SourceTier = 'A' | 'B' | 'C' | 'D';
+
+type PublicTier = Exclude<SourceTier, 'D'>;
+
+const TIER_LABEL: Record<PublicTier, string> = {
+  A: 'Government record',
+  B: 'Archive',
+  C: 'Verified secondary',
+};
+
+const TIER_SHORT: Record<PublicTier, string> = { A: 'A', B: 'B', C: 'C' };
+
+/**
+ * Accepts only A/B/C at the type level — rendering a D tier on a public page
+ * is a TypeScript error, not a runtime check. If D leaks through runtime data,
+ * `isPublicTier` filters it.
+ */
+export function TierBadge({
+  tier,
+  sourceLabel,
+  className = '',
+}: {
+  tier: PublicTier;
+  sourceLabel?: string;
+  className?: string;
+}) {
+  return (
+    <span
+      className={`inline-flex items-center gap-1 font-mono text-[10px] tracking-wider uppercase text-ink/70 ${className}`}
+      title={sourceLabel ? `${TIER_LABEL[tier]} — ${sourceLabel}` : TIER_LABEL[tier]}
+    >
+      <span className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-moss/50 text-moss">
+        {TIER_SHORT[tier]}
+      </span>
+      {sourceLabel && <span className="text-ink/50 normal-case tracking-normal">{sourceLabel}</span>}
+    </span>
+  );
+}
+
+export function isPublicTier(t: SourceTier | null | undefined): t is PublicTier {
+  return t === 'A' || t === 'B' || t === 'C';
+}
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx
new file mode 100644
index 0000000..0a2713b
--- /dev/null
+++ b/src/components/Timeline.tsx
@@ -0,0 +1,48 @@
+import { TierBadge, type SourceTier, isPublicTier } from './TierBadge';
+
+/**
+ * Timeline — vertical ownership/transaction/permit history.
+ * Every event carries TierBadge. D-tier events are filtered out at render.
+ */
+export type TimelineEvent = {
+  date: string;        // ISO or "1954-06-12" / "1978" / "1940s"
+  type: string;        // permit / sale / remodel / occupancy / demolition / historic-designation
+  summary: string;
+  tier: SourceTier;
+  sourceLabel?: string;
+  sourceUrl?: string;
+};
+
+export function Timeline({ events }: { events: TimelineEvent[] }) {
+  const visible = events.filter(e => isPublicTier(e.tier));
+  if (visible.length === 0) {
+    return (
+      <p className="text-sm text-ink/50 italic py-6">
+        No publicly-verifiable events recorded for this address yet.
+      </p>
+    );
+  }
+  return (
+    <ol className="relative border-l border-ink/15 ml-2">
+      {visible.map((e, i) => (
+        <li key={i} className="pl-6 py-4 relative">
+          <span className="absolute -left-[5px] top-6 h-2 w-2 rounded-full bg-moss" aria-hidden />
+          <div className="flex items-baseline justify-between gap-3 flex-wrap">
+            <time className="font-mono text-xs uppercase tracking-wider text-ink/60">{e.date}</time>
+            <span className="text-[10px] uppercase tracking-[0.15em] text-ink/40">{e.type}</span>
+          </div>
+          <p className="mt-1 text-base text-ink leading-relaxed">{e.summary}</p>
+          <div className="mt-2">
+            {e.sourceUrl ? (
+              <a href={e.sourceUrl} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:underline">
+                <TierBadge tier={e.tier as 'A' | 'B' | 'C'} sourceLabel={e.sourceLabel} />
+              </a>
+            ) : (
+              <TierBadge tier={e.tier as 'A' | 'B' | 'C'} sourceLabel={e.sourceLabel} />
+            )}
+          </div>
+        </li>
+      ))}
+    </ol>
+  );
+}
diff --git a/src/components/Wordmark.tsx b/src/components/Wordmark.tsx
new file mode 100644
index 0000000..df625e2
--- /dev/null
+++ b/src/components/Wordmark.tsx
@@ -0,0 +1,44 @@
+/**
+ * Wordmark — text-based brand mark, rename-safe.
+ *
+ * Handles two shapes:
+ *   - "pastdoor"     → single token, with an accent-colored period after
+ *   - "parcel.la"    → split-on-dot, TLD styled as locational suffix
+ *
+ * `accent` defaults to coral (legacy) but each surface passes its
+ * themeAccent color so the wordmark is a per-host signature, not a
+ * shared coral dot across all three sister sites.
+ */
+import { SITE_NAME } from '@/lib/site';
+
+export function Wordmark({
+  name = SITE_NAME,
+  className = '',
+  accent,
+}: {
+  name?: string;
+  className?: string;
+  accent?: string;
+}) {
+  const accentStyle = accent ? { color: accent } : undefined;
+
+  if (name.includes('.')) {
+    const [head, tld] = name.split('.');
+    return (
+      <span className={`font-display text-2xl tracking-[-0.01em] lowercase text-ink ${className}`}>
+        {head}
+        <span className={accent ? 'mx-[0.05em]' : 'mx-[0.05em] text-coral'} style={accentStyle} aria-hidden>·</span>
+        <span className="font-sans font-medium text-sm uppercase tracking-[0.15em] text-ink/70 ml-0.5 align-[0.15em]">
+          {tld}
+        </span>
+      </span>
+    );
+  }
+
+  return (
+    <span className={`font-display text-2xl tracking-[-0.01em] lowercase text-ink ${className}`}>
+      {name}
+      <span className={accent ? 'ml-0.5' : 'ml-0.5 text-coral'} style={accentStyle} aria-hidden>.</span>
+    </span>
+  );
+}
diff --git a/src/components/home/ClaimHome.tsx b/src/components/home/ClaimHome.tsx
new file mode 100644
index 0000000..9e9f9f4
--- /dev/null
+++ b/src/components/home/ClaimHome.tsx
@@ -0,0 +1,199 @@
+/**
+ * ClaimMyAddress — onboarding home. Conversion-focused.
+ * Hero: BIG search + claim CTA. Show "we already have this much on your house"
+ * to motivate the claim. Red accent (Gucci palette).
+ */
+import Link from 'next/link';
+import { pool } from '@/lib/db';
+import { sisterSurfaces } from '@/lib/site';
+
+export const dynamic = 'force-dynamic';
+
+async function getCounts() {
+  const { rows } = await pool.query<any>(`
+    SELECT
+      (SELECT count(*) FROM listing) as listings,
+      (SELECT count(*) FROM permit) as permits,
+      (SELECT count(*) FROM parcel) as parcels,
+      (SELECT count(*) FROM business_license) as businesses,
+      (SELECT count(*) FROM place_event) as events
+  `);
+  return rows[0];
+}
+
+async function getRecentClaims() {
+  const { rows } = await pool.query<any>(`
+    SELECT slug, title, address_line1, city, claimed_at
+    FROM listing
+    WHERE claimed_by IS NOT NULL AND is_public = true
+    ORDER BY claimed_at DESC LIMIT 6
+  `);
+  return rows;
+}
+
+export default async function ClaimHome() {
+  const [counts, claims] = await Promise.all([getCounts(), getRecentClaims()]);
+
+  return (
+    <>
+      {/* HERO — bold red, claim-first */}
+      <section className="bg-coral text-ink">
+        <div className="max-w-7xl mx-auto px-6 py-20 md:py-32">
+          <p className="text-[10px] uppercase tracking-[0.3em] text-ink/70 font-mono mb-4">
+            ClaimMyAddress · est. 2026
+          </p>
+          <h1 className="font-display text-[14vw] md:text-[11rem] leading-[0.82] tracking-[-0.04em]">
+            Your home.<br/>
+            Your file.<br/>
+            <span className="italic text-ink/85">Forever.</span>
+          </h1>
+          <p className="mt-10 font-display italic text-2xl md:text-3xl max-w-3xl leading-tight text-ink/80">
+            We already have <span className="font-display not-italic font-medium">{parseInt(counts.events).toLocaleString()}</span> documented events
+            tied to LA-area addresses — permits, sales, evictions, code cases, historical residents.
+            Claim yours and own the file.
+          </p>
+
+          <form action="/search" className="mt-14 flex flex-col gap-3 max-w-2xl">
+            <input
+              type="search"
+              name="q"
+              placeholder="Type your street address…"
+              autoFocus
+              className="border-2 border-ink bg-sand px-6 py-5 text-xl font-sans focus:outline-none focus:border-ink"
+            />
+            <button type="submit" className="bg-ink text-coral px-8 py-5 text-sm uppercase tracking-[0.25em] hover:bg-sand hover:text-ink transition font-mono font-medium">
+              See what we have →
+            </button>
+          </form>
+
+          <div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6 text-ink/85">
+            <Stat label="addresses indexed" value={parseInt(counts.listings).toLocaleString()} />
+            <Stat label="building permits" value={parseInt(counts.permits).toLocaleString()} />
+            <Stat label="parcel records" value={parseInt(counts.parcels).toLocaleString()} />
+            <Stat label="historical events" value={parseInt(counts.events).toLocaleString()} />
+          </div>
+        </div>
+      </section>
+
+      {/* WHY CLAIM — three-step explainer */}
+      <section className="bg-sand text-ink py-24 border-y border-ink/15">
+        <div className="max-w-6xl mx-auto px-6">
+          <p className="text-[10px] uppercase tracking-[0.25em] text-coral font-mono mb-4">How it works</p>
+          <h2 className="font-display text-5xl md:text-7xl tracking-[-0.02em] leading-[0.95] mb-16">
+            Three steps.<br/>
+            <span className="italic text-ink/60">No real-estate-agent voice.</span>
+          </h2>
+          <ol className="space-y-10 md:space-y-0 md:grid md:grid-cols-3 md:gap-12">
+            <Step n={1} title="Find your address" body="Type it in. We probably already have a free record built from public permits, sales, parcels, and historical archives." />
+            <Step n={2} title="Verify it's yours" body="Match against deed records, utility bills, or upload the docs. We never resell — claim is one-way." />
+            <Step n={3} title="Own the file" body="Add photos, copy, contact info, booking link, family history. The archive stays independent — you cannot rewrite the past, only add to it." />
+          </ol>
+        </div>
+      </section>
+
+      {/* CITED EVERYWHERE */}
+      <section className="bg-ink text-sand py-20">
+        <div className="max-w-6xl mx-auto px-6">
+          <p className="text-[10px] uppercase tracking-[0.25em] text-coral font-mono mb-4">Why this matters</p>
+          <h2 className="font-display text-4xl md:text-6xl tracking-[-0.02em] leading-[0.95] mb-10">
+            Every fact carries<br/>
+            <span className="italic text-coral">its own source.</span>
+          </h2>
+          <ul className="grid md:grid-cols-2 gap-x-10 gap-y-6 text-sm leading-relaxed">
+            <li>
+              <p className="font-display text-xl text-sand mb-1">Permits → data.lacity.org row</p>
+              <p className="text-sand/60 font-mono text-xs">Every LADBS permit links back to its canonical Socrata record.</p>
+            </li>
+            <li>
+              <p className="font-display text-xl text-sand mb-1">Parcels → portal.assessor.lacounty.gov</p>
+              <p className="text-sand/60 font-mono text-xs">APN deep-link to LA County Assessor PAIS detail page.</p>
+            </li>
+            <li>
+              <p className="font-display text-xl text-sand mb-1">Newspapers → archive.org scan</p>
+              <p className="text-sand/60 font-mono text-xs">Click any historical mention to see the actual scanned page.</p>
+            </li>
+            <li>
+              <p className="font-display text-xl text-sand mb-1">Archives → finding aids</p>
+              <p className="text-sand/60 font-mono text-xs">UCLA Special Collections etc. — never a generic library URL, always the actual collection.</p>
+            </li>
+          </ul>
+        </div>
+      </section>
+
+      {/* RECENT CLAIMS */}
+      {claims.length > 0 && (
+        <section className="bg-sand text-ink py-20 border-t border-ink/15">
+          <div className="max-w-6xl mx-auto px-6">
+            <p className="text-[10px] uppercase tracking-[0.25em] text-coral font-mono mb-4">Recently claimed</p>
+            <h2 className="font-display text-4xl md:text-5xl tracking-[-0.02em] leading-tight mb-8">
+              Houses now owned by their owners.
+            </h2>
+            <ul className="divide-y divide-ink/15">
+              {claims.map(c => (
+                <li key={c.slug} className="py-4 flex items-baseline justify-between gap-4 flex-wrap">
+                  <Link href={`/address/${c.slug}`} className="font-display text-2xl hover:text-coral transition">
+                    {c.title}
+                  </Link>
+                  <span className="font-mono text-xs text-ink/45">{c.address_line1 ?? c.city}</span>
+                </li>
+              ))}
+            </ul>
+          </div>
+        </section>
+      )}
+
+      <section className="bg-coral text-ink py-12 border-t-2 border-ink">
+        <div className="max-w-4xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
+          <p className="font-display italic text-2xl md:text-3xl">Free forever. No upsell.</p>
+          <Link href="/search" className="bg-ink text-coral px-8 py-4 text-xs uppercase tracking-[0.25em] hover:bg-sand hover:text-ink transition font-mono">
+            Claim your address →
+          </Link>
+        </div>
+      </section>
+
+      {/* SISTER SURFACES */}
+      <section className="bg-ink text-sand py-16">
+        <div className="max-w-7xl mx-auto px-6">
+          <p className="text-[10px] uppercase tracking-[0.25em] text-coral font-mono mb-6">Sister sites</p>
+          <div className="grid md:grid-cols-2 gap-6">
+            {sisterSurfaces('claim').map(s => (
+              <a
+                key={s.domain}
+                href={`https://${s.domain}${s.primaryCta.href}`}
+                className="block border-2 border-sand/15 hover:border-coral p-8 group transition"
+              >
+                <p className="font-mono text-[10px] uppercase tracking-[0.25em] text-sand/45">{s.domain}</p>
+                <h3 className="font-display text-3xl text-sand mt-2 tracking-[-0.01em] group-hover:text-coral transition">
+                  {s.brandName}
+                </h3>
+                <p className="font-display italic text-lg text-sand/65 mt-1">{s.tagline}</p>
+                <p className="mt-4 font-mono text-[11px] uppercase tracking-[0.15em] text-coral">
+                  {s.primaryCta.label} →
+                </p>
+              </a>
+            ))}
+          </div>
+        </div>
+      </section>
+    </>
+  );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="border-t border-ink/30 pt-3">
+      <p className="font-display text-3xl md:text-4xl text-ink tabular-nums leading-tight">{value}</p>
+      <p className="text-[10px] uppercase tracking-[0.2em] text-ink/55 mt-1 font-mono">{label}</p>
+    </div>
+  );
+}
+
+function Step({ n, title, body }: { n: number; title: string; body: string }) {
+  return (
+    <li className="border-t-2 border-coral pt-5">
+      <span className="font-display text-7xl text-coral leading-[0.7] tracking-tight">{n}</span>
+      <h3 className="font-display text-2xl text-ink mt-4 tracking-tight">{title}</h3>
+      <p className="text-sm text-ink/65 mt-3 leading-relaxed max-w-sm">{body}</p>
+    </li>
+  );
+}
diff --git a/src/components/home/CommunityHome.tsx b/src/components/home/CommunityHome.tsx
new file mode 100644
index 0000000..8faf589
--- /dev/null
+++ b/src/components/home/CommunityHome.tsx
@@ -0,0 +1,189 @@
+/**
+ * Bubbe's Block — community home. Neighborhood-focused.
+ * Hero: warm gold, neighborhood-first navigation.
+ * Show neighborhoods as the unit of community, not individual addresses.
+ */
+import Link from 'next/link';
+import { pool } from '@/lib/db';
+import { sisterSurfaces } from '@/lib/site';
+
+export const dynamic = 'force-dynamic';
+
+async function getNeighborhoods() {
+  // Pull the most-active neighborhoods by listing count
+  const { rows } = await pool.query<any>(`
+    SELECT city,
+           count(*) as listing_count,
+           (SELECT count(*) FROM permit p JOIN listing l2 ON l2.id=p.listing_id WHERE l2.city = listing.city AND p.issue_date > '2024-01-01') as recent_permits
+    FROM listing
+    WHERE is_public = true AND city IS NOT NULL
+    GROUP BY city
+    HAVING count(*) > 50
+    ORDER BY count(*) DESC
+    LIMIT 16
+  `);
+  return rows;
+}
+
+async function getRecentActivity() {
+  // Mix of recent permits + LAHD events as a "what's happening on the block" feed
+  const { rows } = await pool.query<any>(`
+    SELECT 'permit' as kind, p.permit_type as label, p.primary_address as where, p.issue_date::text as when_, l.slug, l.city
+    FROM permit p JOIN listing l ON l.id=p.listing_id
+    WHERE p.issue_date > '2024-12-01' AND l.is_public AND p.permit_type IS NOT NULL
+    ORDER BY p.issue_date DESC LIMIT 10
+  `);
+  return rows;
+}
+
+async function getCounts() {
+  const { rows } = await pool.query<any>(`
+    SELECT
+      (SELECT count(*) FROM listing WHERE is_public) as listings,
+      (SELECT count(DISTINCT city) FROM listing WHERE is_public AND city IS NOT NULL) as cities,
+      (SELECT count(*) FROM place_event WHERE valid_from > '2024-01-01') as recent_events
+  `);
+  return rows[0];
+}
+
+export default async function CommunityHome() {
+  const [hoods, activity, counts] = await Promise.all([
+    getNeighborhoods(),
+    getRecentActivity(),
+    getCounts(),
+  ]);
+
+  return (
+    <>
+      {/* HERO — warm gold, hand-letter mood */}
+      <section className="bg-amber-50">
+        <div className="max-w-7xl mx-auto px-6 py-20 md:py-28">
+          <p className="text-[10px] uppercase tracking-[0.3em] text-amber-800 font-mono mb-3">
+            ◆ Bubbe&apos;s Block
+          </p>
+          <h1 className="font-display text-[14vw] md:text-[10rem] leading-[0.82] tracking-[-0.04em] text-ink">
+            The block,<br/>
+            <span className="italic text-amber-700">then and now.</span>
+          </h1>
+          <p className="mt-10 font-display italic text-2xl md:text-3xl text-ink/70 max-w-3xl leading-tight">
+            Who lives here today and who lived here a hundred years ago. Neighborhood by neighborhood,
+            grounded in <span className="not-italic font-medium">{parseInt(counts.listings).toLocaleString()}</span> houses across <span className="not-italic font-medium">{parseInt(counts.cities).toLocaleString()}</span> LA-area cities.
+          </p>
+
+          <form action="/search" className="mt-12 flex flex-col sm:flex-row gap-3 max-w-2xl">
+            <input
+              type="search"
+              name="q"
+              placeholder="Your neighborhood, street, or address…"
+              className="flex-1 border-2 border-amber-800 bg-white px-5 py-4 text-base font-sans focus:outline-none focus:border-amber-900 tracking-tight"
+            />
+            <button type="submit" className="bg-amber-800 text-amber-50 px-8 py-4 text-xs uppercase tracking-[0.2em] hover:bg-ink transition font-mono">
+              Find your block
+            </button>
+          </form>
+        </div>
+      </section>
+
+      {/* NEIGHBORHOOD GRID */}
+      {hoods.length > 0 && (
+        <section className="bg-sand py-20 border-y border-amber-900/15">
+          <div className="max-w-7xl mx-auto px-6">
+            <p className="text-[10px] uppercase tracking-[0.25em] text-amber-800 font-mono mb-3">Neighborhoods</p>
+            <h2 className="font-display text-5xl md:text-6xl tracking-[-0.02em] mb-10 leading-[0.95]">
+              Pick a block.
+            </h2>
+            <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
+              {hoods.map(h => (
+                <Link
+                  key={h.city}
+                  href={`/browse?city=${encodeURIComponent(h.city)}`}
+                  className="block border-2 border-amber-900/15 bg-amber-50 hover:border-amber-800 hover:bg-white transition p-6 group"
+                >
+                  <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-amber-700/70">Los Angeles area</p>
+                  <h3 className="font-display text-2xl text-ink mt-2 leading-tight tracking-tight group-hover:text-amber-800 transition">
+                    {h.city}
+                  </h3>
+                  <p className="mt-4 font-display text-4xl text-ink tabular-nums leading-tight">
+                    {parseInt(h.listing_count).toLocaleString()}
+                  </p>
+                  <p className="font-mono text-[10px] uppercase tracking-wider text-ink/45 mt-1">addresses</p>
+                  {parseInt(h.recent_permits) > 0 && (
+                    <p className="mt-3 font-mono text-[10px] uppercase tracking-wider text-amber-800">
+                      ↗ {h.recent_permits} permit{h.recent_permits === '1' ? '' : 's'} this year
+                    </p>
+                  )}
+                </Link>
+              ))}
+            </div>
+          </div>
+        </section>
+      )}
+
+      {/* WHAT'S HAPPENING — recent activity feed */}
+      {activity.length > 0 && (
+        <section className="bg-amber-100/50 py-20">
+          <div className="max-w-5xl mx-auto px-6">
+            <p className="text-[10px] uppercase tracking-[0.25em] text-amber-800 font-mono mb-3">What&apos;s happening</p>
+            <h2 className="font-display text-5xl md:text-6xl tracking-[-0.02em] mb-10 leading-[0.95]">
+              On the block, <span className="italic text-amber-700">this month.</span>
+            </h2>
+            <ul className="divide-y divide-amber-900/15">
+              {activity.map((a, i) => (
+                <li key={i} className="py-5 flex flex-col md:flex-row md:items-baseline gap-2 md:gap-6">
+                  <span className="font-mono text-xs text-amber-800 tabular-nums shrink-0 w-24">
+                    {a.when_}
+                  </span>
+                  <Link href={`/address/${a.slug}`} className="font-display text-xl text-ink hover:text-amber-800 transition flex-1">
+                    {a.label} <span className="text-ink/50">·</span> {a.where}
+                  </Link>
+                  <span className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45 shrink-0">
+                    {a.city}
+                  </span>
+                </li>
+              ))}
+            </ul>
+          </div>
+        </section>
+      )}
+
+      {/* THE NAME */}
+      <section className="bg-ink text-amber-50 py-24">
+        <div className="max-w-3xl mx-auto px-6 text-center">
+          <p className="text-[10px] uppercase tracking-[0.3em] text-amber-300 font-mono mb-6">Why &ldquo;Bubbe&rsquo;s Block&rdquo;</p>
+          <p className="font-display italic text-3xl md:text-4xl leading-tight text-amber-50/90">
+            &ldquo;Bubbe knew everything about this block. Who moved in, who fixed the porch,
+            whose son made it big, and whose great-grandfather ran the corner store. We&rsquo;re trying to write that down.&rdquo;
+          </p>
+          <p className="mt-8 font-mono text-xs uppercase tracking-[0.2em] text-amber-200/60">
+            — the founders, basically
+          </p>
+        </div>
+      </section>
+
+      {/* SISTER SURFACES */}
+      <section className="bg-amber-50 py-16 border-t-2 border-amber-900/15">
+        <div className="max-w-7xl mx-auto px-6">
+          <p className="text-[10px] uppercase tracking-[0.25em] text-amber-800 font-mono mb-6">Sister sites</p>
+          <div className="grid md:grid-cols-2 gap-6">
+            {sisterSurfaces('community').map(s => (
+              <a
+                key={s.domain}
+                href={`https://${s.domain}${s.primaryCta.href}`}
+                className="block border-2 border-amber-900/15 hover:border-amber-800 bg-white p-8 group transition"
+              >
+                <p className="font-mono text-[10px] uppercase tracking-[0.25em] text-amber-800/60">{s.domain}</p>
+                <h3 className="font-display text-3xl text-ink mt-2 tracking-[-0.01em] group-hover:text-amber-800 transition">
+                  {s.brandName}
+                </h3>
+                <p className="font-display italic text-lg text-ink/65 mt-1">{s.tagline}</p>
+                <p className="mt-4 font-mono text-[11px] uppercase tracking-[0.15em] text-amber-800">
+                  {s.primaryCta.label} →
+                </p>
+              </a>
+            ))}
+          </div>
+        </div>
+      </section>
+    </>
+  );
+}
diff --git a/src/components/home/FlagshipHome.tsx b/src/components/home/FlagshipHome.tsx
new file mode 100644
index 0000000..e4d3ff9
--- /dev/null
+++ b/src/components/home/FlagshipHome.tsx
@@ -0,0 +1,230 @@
+/**
+ * WhoLivedThere — flagship home. Editorial, archive-first.
+ * Hero: featured named historic houses with coordinates from Wikipedia.
+ * Editorial rails: notable estates, recent newspaper mentions, latest permits.
+ */
+import Link from 'next/link';
+import { pool } from '@/lib/db';
+import { sisterSurfaces } from '@/lib/site';
+
+export const dynamic = 'force-dynamic';
+
+async function getFeaturedHouses(): Promise<Array<{ id: string; slug: string; title: string; description: string | null; lat: number; lon: number; source_url: string | null }>> {
+  const { rows } = await pool.query(`
+    SELECT l.id, l.slug, l.title, l.description, l.latitude::float as lat, l.longitude::float as lon, pe.source_url
+    FROM listing l
+    LEFT JOIN place_event pe ON pe.listing_id = l.id AND pe.kind = 'named_estate'
+    WHERE l.source = 'wikipedia_house' AND l.is_public = true AND l.latitude IS NOT NULL
+    ORDER BY l.title
+    LIMIT 12
+  `);
+  return rows;
+}
+
+async function getCounts() {
+  const { rows } = await pool.query<any>(`
+    SELECT
+      (SELECT count(*) FROM listing) as listings,
+      (SELECT count(*) FROM permit) as permits,
+      (SELECT count(*) FROM parcel) as parcels,
+      (SELECT count(*) FROM place_event) as events,
+      (SELECT count(*) FROM news_address_mention) as mentions
+  `);
+  return rows[0];
+}
+
+async function getRecentNewspaperMentions() {
+  const { rows } = await pool.query(`
+    SELECT m.raw_address, m.context, n.paper_name, n.issue_date::text as issue_date, n.ia_url
+    FROM news_address_mention m
+    JOIN news_issue n ON n.id = m.issue_id
+    WHERE m.context IS NOT NULL
+    ORDER BY n.issue_date DESC NULLS LAST
+    LIMIT 6
+  `);
+  return rows;
+}
+
+export default async function FlagshipHome() {
+  const [houses, counts, mentions] = await Promise.all([
+    getFeaturedHouses(),
+    getCounts(),
+    getRecentNewspaperMentions(),
+  ]);
+
+  return (
+    <>
+      {/* MASTHEAD — magazine-style typography */}
+      <section className="border-b border-ink/15 bg-sand">
+        <div className="max-w-7xl mx-auto px-6 py-16 md:py-24">
+          <div className="flex flex-col md:flex-row items-baseline justify-between gap-6 mb-8">
+            <p className="text-[10px] uppercase tracking-[0.25em] text-moss font-mono">
+              ◢ The Archive · Vol. I · {new Date().getFullYear()}
+            </p>
+            <p className="text-[10px] uppercase tracking-[0.15em] text-ink/40 font-mono tabular-nums">
+              {parseInt(counts.listings).toLocaleString()} addresses · {parseInt(counts.permits).toLocaleString()} permits · {parseInt(counts.parcels).toLocaleString()} parcels
+            </p>
+          </div>
+          <h1 className="font-display text-[14vw] md:text-[10rem] leading-[0.85] tracking-[-0.04em] text-ink">
+            Every<br/>
+            <span className="italic text-moss">address</span><br/>
+            has a story.
+          </h1>
+          <p className="mt-10 font-display italic text-2xl md:text-3xl text-ink/65 max-w-3xl leading-tight">
+            Look up the full history of any house — who lived there, what was built and when, what the archives say.
+          </p>
+
+          <form action="/search" className="mt-12 flex flex-col sm:flex-row gap-3 max-w-3xl">
+            <input
+              type="search"
+              name="q"
+              placeholder="600 N Bedford Drive · Pickfair · APN 4328-018-001 · Mary Pickford"
+              className="flex-1 border-2 border-ink/20 bg-white px-5 py-4 text-base font-sans focus:outline-none focus:border-moss tracking-tight"
+            />
+            <button type="submit" className="bg-ink text-sand px-8 py-4 text-xs uppercase tracking-[0.2em] hover:bg-moss transition font-mono">
+              Search the archive
+            </button>
+          </form>
+        </div>
+      </section>
+
+      {/* FEATURED ESTATES — editorial gallery */}
+      {houses.length > 0 && (
+        <section className="bg-ink text-sand py-20">
+          <div className="max-w-7xl mx-auto px-6">
+            <div className="flex items-baseline justify-between mb-12 flex-wrap gap-4">
+              <div>
+                <p className="text-[10px] uppercase tracking-[0.25em] text-coral font-mono mb-2">Featured estates</p>
+                <h2 className="font-display text-5xl md:text-7xl tracking-[-0.02em] leading-[0.95]">
+                  The named houses<br/>
+                  <span className="italic text-coral/80">of Beverly Hills.</span>
+                </h2>
+              </div>
+              <p className="text-sm text-sand/55 max-w-md leading-relaxed font-display italic">
+                Pickfair. Greystone. Falcon Lair. Each one mapped, with the original Wikipedia article a click away.
+              </p>
+            </div>
+            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
+              {houses.map(h => (
+                <Link
+                  key={h.id}
+                  href={`/address/${h.slug}`}
+                  className="block border border-sand/15 bg-ink/50 hover:border-coral hover:bg-ink/80 transition group p-7"
+                >
+                  <p className="font-mono text-[10px] uppercase tracking-[0.2em] text-coral/80 mb-3">Named estate</p>
+                  <h3 className="font-display text-3xl text-sand tracking-[-0.005em] leading-tight group-hover:text-coral transition">
+                    {h.title}
+                  </h3>
+                  {h.description && (
+                    <p className="mt-3 text-sm text-sand/65 leading-relaxed">{h.description.slice(0, 140)}</p>
+                  )}
+                  <p className="mt-5 font-mono text-[10px] tabular-nums text-sand/40">
+                    {h.lat.toFixed(4)}, {h.lon.toFixed(4)}
+                  </p>
+                  {h.source_url && (
+                    <p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-coral/70">
+                      via Wikipedia ↗
+                    </p>
+                  )}
+                </Link>
+              ))}
+            </div>
+          </div>
+        </section>
+      )}
+
+      {/* IN THE NEWSPAPER — full-OCR-text mentions */}
+      {mentions.length > 0 && (
+        <section className="bg-sand py-20 border-t border-ink/10">
+          <div className="max-w-7xl mx-auto px-6">
+            <div className="mb-10">
+              <p className="text-[10px] uppercase tracking-[0.25em] text-moss font-mono mb-2">In the newspapers</p>
+              <h2 className="font-display text-5xl md:text-6xl tracking-[-0.02em] leading-[0.95] text-ink">
+                {parseInt(counts.mentions).toLocaleString()} address mentions<br/>
+                <span className="italic text-ink/60">from 1873 onward.</span>
+              </h2>
+            </div>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
+              {mentions.map((m, i) => (
+                <a
+                  key={i}
+                  href={m.ia_url}
+                  target="_blank"
+                  rel="noreferrer noopener"
+                  className="border-l-4 border-moss pl-5 hover:border-coral transition group"
+                >
+                  <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45">
+                    {m.paper_name} · {m.issue_date}
+                  </p>
+                  <p className="font-display text-2xl text-ink mt-1 group-hover:text-coral transition">
+                    {m.raw_address}
+                  </p>
+                  <p className="mt-2 text-sm italic text-ink/65 leading-relaxed">
+                    &ldquo;{m.context.slice(0, 200)}{m.context.length > 200 ? '…' : ''}&rdquo;
+                  </p>
+                  <p className="mt-2 font-mono text-[10px] uppercase tracking-wider text-moss group-hover:text-coral">
+                    view scanned page on Internet Archive ↗
+                  </p>
+                </a>
+              ))}
+            </div>
+          </div>
+        </section>
+      )}
+
+      {/* INDEX OF SOURCES — minimalist list */}
+      <section className="bg-ink/95 text-sand py-16 border-t border-sand/10">
+        <div className="max-w-7xl mx-auto px-6">
+          <p className="text-[10px] uppercase tracking-[0.25em] text-coral font-mono mb-2">Backed by</p>
+          <h2 className="font-display text-4xl md:text-5xl tracking-[-0.02em] mb-10">Eleven primary sources.</h2>
+          <ul className="grid grid-cols-2 md:grid-cols-3 gap-x-10 gap-y-3 font-mono text-xs text-sand/70 uppercase tracking-wider">
+            <li>LA County Assessor (9.6M parcels)</li>
+            <li>LADBS Building Permits (1.5M)</li>
+            <li>LADBS Mechanical / Electrical</li>
+            <li>LA Bureau of Engineering (626K)</li>
+            <li>LA Code Enforcement (839K cases)</li>
+            <li>LAHD Eviction / Foreclosure</li>
+            <li>Beverly Hills + Pasadena + SM</li>
+            <li>WeHo Film Permits</li>
+            <li>LA Active Businesses (619K)</li>
+            <li>LA Historic-Cultural Monuments</li>
+            <li>Internet Archive newspapers</li>
+            <li>Wikipedia + UCLA archives</li>
+          </ul>
+          <p className="mt-12 max-w-3xl text-sm text-sand/55 leading-relaxed">
+            Every fact in the archive carries its own citation. Click any &ldquo;source ↗&rdquo; link
+            on any address page to view the actual government record, scanned newspaper page, or finding aid.
+          </p>
+          <Link href="/admin/sources" className="inline-block mt-8 text-xs uppercase tracking-[0.2em] font-mono text-coral hover:underline">
+            Full source registry →
+          </Link>
+        </div>
+      </section>
+
+      {/* SISTER SURFACES */}
+      <section className="bg-sand py-16 border-t border-ink/15">
+        <div className="max-w-7xl mx-auto px-6">
+          <p className="text-[10px] uppercase tracking-[0.25em] text-ink/45 font-mono mb-6">Also part of the archive</p>
+          <div className="grid md:grid-cols-2 gap-6">
+            {sisterSurfaces('flagship').map(s => (
+              <a
+                key={s.domain}
+                href={`https://${s.domain}${s.primaryCta.href}`}
+                className="block border-2 border-ink/15 hover:border-ink p-8 group transition bg-white/50"
+              >
+                <p className="font-mono text-[10px] uppercase tracking-[0.25em] text-ink/45">{s.domain}</p>
+                <h3 className="font-display text-3xl text-ink mt-2 tracking-[-0.01em] group-hover:text-moss transition">
+                  {s.brandName}
+                </h3>
+                <p className="font-display italic text-lg text-ink/65 mt-1">{s.tagline}</p>
+                <p className="mt-4 font-mono text-[11px] uppercase tracking-[0.15em] text-moss">
+                  {s.primaryCta.label} →
+                </p>
+              </a>
+            ))}
+          </div>
+        </div>
+      </section>
+    </>
+  );
+}
diff --git a/src/components/sponsored/SponsoredCard.tsx b/src/components/sponsored/SponsoredCard.tsx
new file mode 100644
index 0000000..80a705a
--- /dev/null
+++ b/src/components/sponsored/SponsoredCard.tsx
@@ -0,0 +1,78 @@
+/**
+ * SponsoredCard — paid Promote-a-Listing unit.
+ *
+ * FIREWALL RULES (see plan §1 "Promote-a-Listing editorial firewall"):
+ *   - The ONLY component allowed to use coral as a fill color.
+ *   - Has NO TierBadge slot — A/B/C/D is reserved for historical facts.
+ *   - Never writes to event, entity_place_association, or any provenance table.
+ *   - Lives in src/components/sponsored/* so a lint boundary can enforce
+ *     that editorial components don't import from here (and vice versa).
+ *   - Always carries "Sponsored · Current listing" eyebrow + source logo.
+ */
+
+type SponsoredSource = 'zillow' | 'redfin' | 'airbnb' | 'other';
+
+const SOURCE_LABEL: Record<SponsoredSource, string> = {
+  zillow: 'Zillow',
+  redfin: 'Redfin',
+  airbnb: 'Airbnb',
+  other: 'Listing',
+};
+
+export function SponsoredCard({
+  source,
+  title,
+  priceLabel,
+  url,
+  imageUrl,
+}: {
+  source: SponsoredSource;
+  title: string;
+  priceLabel?: string;
+  url: string;
+  imageUrl?: string;
+}) {
+  // Soft-disabled when FREE_FOREVER=true — pastdoor doesn't accept paid placements.
+  if (process.env.FREE_FOREVER !== 'false') return null;
+
+  return (
+    <aside
+      className="relative bg-ink text-sand border border-coral/30 rounded-none p-5 font-sans"
+      data-sponsored="true"
+      aria-label="Sponsored current listing"
+    >
+      <div className="mb-3 flex items-center justify-between text-[10px] uppercase tracking-[0.15em] text-sand/60">
+        <span className="inline-flex items-center gap-2">
+          <span className="inline-block h-2 w-2 rounded-full bg-coral" />
+          Sponsored · Current listing
+        </span>
+        <span className="text-sand/50">{SOURCE_LABEL[source]}</span>
+      </div>
+
+      {imageUrl && (
+        <div className="mb-4 aspect-[16/9] overflow-hidden">
+          <img src={imageUrl} alt="" className="h-full w-full object-cover" />
+        </div>
+      )}
+
+      <div className="mb-4">
+        <h3 className="font-sans text-base font-medium text-sand leading-snug">{title}</h3>
+        {priceLabel && <p className="mt-1 text-sm text-sand/70">{priceLabel}</p>}
+      </div>
+
+      <a
+        href={url}
+        target="_blank"
+        rel="noopener noreferrer"
+        className="inline-flex items-center gap-2 border border-coral px-4 py-1.5 text-xs uppercase tracking-wider text-coral hover:bg-coral hover:text-ink transition"
+      >
+        View on {SOURCE_LABEL[source]}
+        <span aria-hidden>→</span>
+      </a>
+
+      <p className="mt-3 text-[10px] text-sand/40 leading-relaxed">
+        This is paid placement. The historical record below is independent.
+      </p>
+    </aside>
+  );
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..6bac4ba
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,679 @@
+import { Pool, type PoolConfig } from 'pg';
+
+/**
+ * Local Homebrew Postgres uses peer/trust auth over the unix socket (/tmp).
+ * Defaulting to that avoids password prompts in dev. Override with DATABASE_URL
+ * for staging/prod (Kamatera etc).
+ */
+const config: PoolConfig = process.env.DATABASE_URL
+  ? { connectionString: process.env.DATABASE_URL, max: 5 }
+  : {
+      host: process.env.PGHOST ?? '/tmp',
+      database: process.env.PGDATABASE ?? 'stayclaim',
+      user: process.env.PGUSER ?? process.env.USER,
+      max: 5,
+    };
+
+declare global {
+  var __pg_pool: Pool | undefined;
+}
+export const pool: Pool = global.__pg_pool ?? new Pool(config);
+if (process.env.NODE_ENV !== 'production') global.__pg_pool = pool;
+
+export type Listing = {
+  id: string;
+  slug: string;
+  source: string;
+  source_id: string | null;
+  title: string;
+  headline: string | null;
+  description: string | null;
+  address_line1: string | null;
+  city: string | null;
+  state: string | null;
+  postal_code: string | null;
+  country: string;
+  latitude: number | null;
+  longitude: number | null;
+  bedrooms: number | null;
+  bathrooms: number | null;
+  sleeps: number | null;
+  property_type: string | null;
+  amenities: string[];
+  hero_image: string | null;
+  images: string[];
+  nightly_price: number | null;
+  currency: string;
+  source_url: string | null;
+  tier: 'free' | 'basic' | 'pro';
+  claimed_by: string | null;
+  claimed_at: string | null;
+  is_public: boolean;
+  updated_at?: Date | string | null;
+};
+
+export async function getListingBySlug(slug: string): Promise<Listing | null> {
+  const { rows } = await pool.query<Listing>('SELECT * FROM listing WHERE slug = $1', [slug]);
+  return rows[0] ?? null;
+}
+
+export async function searchListings(q: {
+  text?: string;
+  city?: string;
+  min?: number;
+  max?: number;
+  limit?: number;
+}): Promise<Listing[]> {
+  const conds: string[] = ['is_public = true'];
+  const params: unknown[] = [];
+  if (q.city) {
+    params.push(q.city.toLowerCase());
+    conds.push(`lower(city) = $${params.length}`);
+  }
+  if (q.min != null) {
+    params.push(q.min);
+    conds.push(`nightly_price >= $${params.length}`);
+  }
+  if (q.max != null) {
+    params.push(q.max);
+    conds.push(`nightly_price <= $${params.length}`);
+  }
+
+  let orderBy = 'ingested_at DESC';
+  if (q.text && q.text.trim()) {
+    params.push(q.text.trim());
+    const tsParam = params.length;
+    conds.push(`search_doc @@ websearch_to_tsquery('english', $${tsParam})`);
+    orderBy = `ts_rank(search_doc, websearch_to_tsquery('english', $${tsParam})) DESC, ingested_at DESC`;
+  }
+
+  params.push(q.limit ?? 24);
+  const sql = `SELECT * FROM listing WHERE ${conds.join(' AND ')} ORDER BY ${orderBy} LIMIT $${params.length}`;
+  const { rows } = await pool.query<Listing>(sql, params);
+  return rows;
+}
+
+export type Entity = {
+  id: string;
+  slug: string;
+  kind: 'person' | 'business' | 'architect' | 'studio';
+  display_name: string;
+  also_known_as: string[] | null;
+  birth_year: number | null;
+  death_year: number | null;
+  is_living: boolean | null;
+  bio_short: string | null;
+  wiki_url: string | null;
+  hero_image: string | null;
+};
+
+export async function getEntityBySlug(slug: string): Promise<Entity | null> {
+  const { rows } = await pool.query<Entity>('SELECT * FROM entity WHERE slug = $1', [slug]);
+  return rows[0] ?? null;
+}
+
+export type EntityAssociation = {
+  id: string;
+  listing_id: string;
+  relation: 'resident' | 'owner' | 'designer' | 'guest' | 'tenant';
+  date_from: Date | null;
+  date_to: Date | null;
+  summary: string | null;
+  source_tier: 'A' | 'B' | 'C' | 'D';
+  source_urls: string[] | null;
+  listing_slug: string;
+  listing_title: string;
+  listing_city: string | null;
+};
+
+export function yearOf(d: Date | string | null | undefined): string {
+  if (!d) return '?';
+  const date = d instanceof Date ? d : new Date(d);
+  return Number.isNaN(date.getTime()) ? '?' : String(date.getUTCFullYear());
+}
+
+export async function getEntityAssociations(
+  entityId: string
+): Promise<(EntityAssociation & { listing_lat: number | null; listing_lng: number | null })[]> {
+  const { rows } = await pool.query(
+    `SELECT epa.id, epa.listing_id, epa.relation, epa.date_from, epa.date_to,
+            epa.summary, epa.source_tier, epa.source_urls,
+            l.slug AS listing_slug, l.title AS listing_title, l.city AS listing_city,
+            l.latitude AS listing_lat, l.longitude AS listing_lng
+     FROM entity_place_association epa
+     JOIN listing l ON l.id = epa.listing_id
+     WHERE epa.entity_id = $1 AND epa.public_visible = true
+     ORDER BY COALESCE(epa.date_from, CURRENT_DATE) ASC`,
+    [entityId]
+  );
+  return rows;
+}
+
+export async function getListingAssociations(listingId: string): Promise<
+  (EntityAssociation & { entity_slug: string; entity_name: string; entity_kind: string })[]
+> {
+  const { rows } = await pool.query(
+    `SELECT epa.id, epa.listing_id, epa.relation, epa.date_from, epa.date_to,
+            epa.summary, epa.source_tier, epa.source_urls,
+            l.slug AS listing_slug, l.title AS listing_title, l.city AS listing_city,
+            e.slug AS entity_slug, e.display_name AS entity_name, e.kind AS entity_kind
+     FROM entity_place_association epa
+     JOIN entity e ON e.id = epa.entity_id
+     JOIN listing l ON l.id = epa.listing_id
+     WHERE epa.listing_id = $1 AND epa.public_visible = true
+     ORDER BY COALESCE(epa.date_from, CURRENT_DATE) ASC`,
+    [listingId]
+  );
+  return rows;
+}
+
+export async function getNearbyListings(city: string, excludeId: string, limit = 4): Promise<Listing[]> {
+  const { rows } = await pool.query<Listing>(
+    `SELECT * FROM listing
+     WHERE city = $1 AND id <> $2 AND is_public = true
+     ORDER BY ingested_at DESC
+     LIMIT $3`,
+    [city, excludeId, limit]
+  );
+  return rows;
+}
+
+export async function listEntities(kind?: string): Promise<Entity[]> {
+  const sql = kind
+    ? 'SELECT * FROM entity WHERE kind = $1 ORDER BY display_name ASC'
+    : 'SELECT * FROM entity ORDER BY display_name ASC';
+  const params = kind ? [kind] : [];
+  const { rows } = await pool.query<Entity>(sql, params);
+  return rows;
+}
+
+export async function listEntitiesWithCounts(kind?: string): Promise<(Entity & { oeuvre_count: number })[]> {
+  const sql = `
+    SELECT e.*, COUNT(epa.id)::int AS oeuvre_count
+    FROM entity e
+    LEFT JOIN entity_place_association epa ON epa.entity_id = e.id AND epa.public_visible = true
+    ${kind ? 'WHERE e.kind = $1' : ''}
+    GROUP BY e.id
+    ORDER BY e.display_name ASC
+  `;
+  const params = kind ? [kind] : [];
+  const { rows } = await pool.query(sql, params);
+  return rows;
+}
+
+export type MediaAsset = {
+  id: string;
+  listing_id: string;
+  kind: 'archival_photo' | 'historic_map' | 'postcard' | 'drone' | 'streetview_thumb' | 'newspaper_clip';
+  year: number | null;
+  caption: string | null;
+  storage_url: string;
+  source_tier: 'A' | 'B' | 'C' | 'D';
+  source_label: string | null;
+  source_url: string | null;
+  rights: string | null;
+};
+
+export async function getMediaForListing(listingId: string): Promise<MediaAsset[]> {
+  const { rows } = await pool.query<MediaAsset>(
+    `SELECT id, listing_id, kind, year, caption, storage_url, source_tier, source_label, source_url, rights
+     FROM media_asset
+     WHERE listing_id = $1 AND public_visible = true
+     ORDER BY year ASC NULLS LAST, added_at ASC`,
+    [listingId]
+  );
+  return rows;
+}
+
+export type Structure = {
+  id: string;
+  listing_id: string;
+  year_built: number | null;
+  year_demolished: number | null;
+  property_type: string | null;
+  style: string | null;
+  bedrooms: number | null;
+  bathrooms: number | null;
+  building_area_sqft: number | null;
+  lot_size_sqft: number | null;
+};
+
+export async function getStructuresForListing(listingId: string): Promise<Structure[]> {
+  const { rows } = await pool.query<Structure>(
+    `SELECT id, listing_id, year_built, year_demolished, property_type, style,
+            bedrooms, bathrooms, building_area_sqft, lot_size_sqft
+     FROM structure
+     WHERE listing_id = $1
+     ORDER BY year_built ASC NULLS LAST`,
+    [listingId]
+  );
+  return rows;
+}
+
+export type Parcel = { id: string; apn: string; jurisdiction: string };
+
+export async function getParcelForListing(listingId: string): Promise<Parcel | null> {
+  const { rows } = await pool.query<Parcel>(
+    `SELECT id, apn, jurisdiction FROM parcel WHERE current_listing_id = $1 LIMIT 1`,
+    [listingId]
+  );
+  return rows[0] ?? null;
+}
+
+export type PlaceEvent = {
+  id: string;
+  listing_id: string;
+  kind: 'permit' | 'sale' | 'remodel' | 'demolition' | 'designation' | 'media' | 'occupancy';
+  summary: string;
+  valid_from: Date | null;
+  valid_to: Date | null;
+  recorded_at: Date;
+  source_tier: 'A' | 'B' | 'C' | 'D';
+  source_label: string | null;
+  source_url: string | null;
+  source_id: string | null;
+  confidence: number;
+};
+
+export async function getEventsForListing(listingId: string): Promise<PlaceEvent[]> {
+  const { rows } = await pool.query<PlaceEvent>(
+    `SELECT * FROM place_event
+     WHERE listing_id = $1 AND public_visible = true
+     ORDER BY COALESCE(valid_from, recorded_at::date) ASC`,
+    [listingId]
+  );
+  return rows;
+}
+
+export async function getArchiveTimeline(opts: {
+  limit?: number;
+  kind?: string;
+  decade?: string; // "1920s" | "2020s" | etc.
+  city?: string;
+} = {}): Promise<(PlaceEvent & { listing_slug: string; listing_title: string; listing_city: string | null })[]> {
+  const limit = opts.limit ?? 300;
+  const conds: string[] = ['pe.public_visible = true', 'l.is_public = true'];
+  const params: unknown[] = [];
+  if (opts.kind) {
+    params.push(opts.kind);
+    conds.push(`pe.kind = $${params.length}`);
+  }
+  if (opts.city) {
+    params.push(opts.city);
+    conds.push(`l.city = $${params.length}`);
+  }
+  if (opts.decade && /^\d{3}0s$/.test(opts.decade)) {
+    const start = Number(opts.decade.slice(0, 4));
+    params.push(`${start}-01-01`);
+    params.push(`${start + 10}-01-01`);
+    conds.push(
+      `COALESCE(pe.valid_from, pe.recorded_at::date) >= $${params.length - 1}::date AND COALESCE(pe.valid_from, pe.recorded_at::date) < $${params.length}::date`
+    );
+  }
+  params.push(limit);
+  const sql = `SELECT pe.*, l.slug AS listing_slug, l.title AS listing_title, l.city AS listing_city
+               FROM place_event pe
+               JOIN listing l ON l.id = pe.listing_id
+               WHERE ${conds.join(' AND ')}
+               ORDER BY COALESCE(pe.valid_from, pe.recorded_at::date) DESC
+               LIMIT $${params.length}`;
+  const { rows } = await pool.query(sql, params);
+  return rows;
+}
+
+export async function getRecentPermits(
+  limit = 6
+): Promise<(PlaceEvent & { listing_slug: string; listing_title: string; listing_city: string | null; listing_hero: string | null })[]> {
+  const { rows } = await pool.query(
+    `SELECT pe.*, l.slug AS listing_slug, l.title AS listing_title, l.city AS listing_city, l.hero_image AS listing_hero
+     FROM place_event pe
+     JOIN listing l ON l.id = pe.listing_id
+     WHERE pe.kind = 'permit' AND pe.public_visible = true AND l.is_public = true
+     ORDER BY pe.valid_from DESC NULLS LAST
+     LIMIT $1`,
+    [limit]
+  );
+  return rows;
+}
+
+export async function getOwnerByEmail(email: string): Promise<{ id: string; email: string; name: string | null } | null> {
+  const { rows } = await pool.query(
+    `SELECT id, email, name FROM owner WHERE lower(email) = lower($1) LIMIT 1`,
+    [email]
+  );
+  return rows[0] ?? null;
+}
+
+export async function getListingsForOwner(ownerId: string): Promise<Listing[]> {
+  const { rows } = await pool.query<Listing>(
+    `SELECT * FROM listing WHERE claimed_by = $1 ORDER BY claimed_at DESC NULLS LAST`,
+    [ownerId]
+  );
+  return rows;
+}
+
+export async function getPendingClaimsForEmail(email: string): Promise<{ id: string; listing_id: string; status: string; created_at: Date; listing_slug: string; listing_title: string }[]> {
+  const { rows } = await pool.query(
+    `SELECT cr.id, cr.listing_id, cr.status, cr.created_at,
+            l.slug AS listing_slug, l.title AS listing_title
+     FROM claim_request cr
+     JOIN listing l ON l.id = cr.listing_id
+     WHERE lower(cr.email) = lower($1)
+     ORDER BY cr.created_at DESC`,
+    [email]
+  );
+  return rows;
+}
+
+export type FilmProduction = {
+  id: string;
+  slug: string;
+  kind: 'film' | 'tv' | 'commercial' | 'music_video' | 'documentary' | 'short' | 'student';
+  title: string;
+  year: number | null;
+  imdb_id: string | null;
+  wikidata_qid: string | null;
+  poster_url: string | null;
+  blurb: string | null;
+};
+
+export type FilmingLocation = {
+  id: string;
+  production_id: string;
+  listing_id: string;
+  role: string | null;
+  scene_note: string | null;
+  shoot_date_from: Date | null;
+  shoot_date_to: Date | null;
+  permit_number: string | null;
+  source_tier: 'A' | 'B' | 'C' | 'D';
+  source_label: string | null;
+  source_url: string | null;
+  confidence: number;
+};
+
+export async function getProductionBySlug(slug: string): Promise<FilmProduction | null> {
+  const { rows } = await pool.query<FilmProduction>('SELECT * FROM film_production WHERE slug = $1', [slug]);
+  return rows[0] ?? null;
+}
+
+export async function getLocationsForProduction(productionId: string): Promise<
+  (FilmingLocation & { listing_slug: string; listing_title: string; listing_city: string | null; listing_lat: number | null; listing_lng: number | null })[]
+> {
+  const { rows } = await pool.query(
+    `SELECT fl.*, l.slug AS listing_slug, l.title AS listing_title, l.city AS listing_city,
+            l.latitude AS listing_lat, l.longitude AS listing_lng
+     FROM filming_location fl
+     JOIN listing l ON l.id = fl.listing_id
+     WHERE fl.production_id = $1 AND fl.public_visible = true AND l.is_public = true
+     ORDER BY fl.shoot_date_from ASC NULLS LAST`,
+    [productionId]
+  );
+  return rows;
+}
+
+export async function getProductionsForListing(listingId: string): Promise<
+  (FilmingLocation & { production_slug: string; production_title: string; production_kind: string; production_year: number | null })[]
+> {
+  const { rows } = await pool.query(
+    `SELECT fl.*, fp.slug AS production_slug, fp.title AS production_title,
+            fp.kind AS production_kind, fp.year AS production_year
+     FROM filming_location fl
+     JOIN film_production fp ON fp.id = fl.production_id
+     WHERE fl.listing_id = $1 AND fl.public_visible = true
+     ORDER BY fl.shoot_date_from DESC NULLS LAST`,
+    [listingId]
+  );
+  return rows;
+}
+
+export type FilmingPin = {
+  slug: string;
+  title: string;
+  lat: number;
+  lng: number;
+  productions: { slug: string; title: string; year: number | null }[];
+};
+
+export async function listAllFilmingShoots(): Promise<
+  Array<{
+    id: string;
+    role: string | null;
+    scene_note: string | null;
+    shoot_date_from: Date | null;
+    permit_number: string | null;
+    listing_slug: string;
+    listing_title: string;
+    listing_city: string | null;
+    production_slug: string;
+    production_title: string;
+    production_kind: string;
+    production_year: number | null;
+  }>
+> {
+  const { rows } = await pool.query(
+    `SELECT fl.id, fl.role, fl.scene_note, fl.shoot_date_from, fl.permit_number,
+            l.slug AS listing_slug, l.title AS listing_title, l.city AS listing_city,
+            fp.slug AS production_slug, fp.title AS production_title,
+            fp.kind AS production_kind, fp.year AS production_year
+     FROM filming_location fl
+     JOIN listing l ON l.id = fl.listing_id
+     JOIN film_production fp ON fp.id = fl.production_id
+     WHERE fl.public_visible = true AND l.is_public = true
+     ORDER BY fl.shoot_date_from DESC NULLS LAST`
+  );
+  return rows;
+}
+
+export async function getNearbyFilming(
+  city: string,
+  excludeListingId: string,
+  limit = 5
+): Promise<
+  Array<{
+    listing_slug: string;
+    listing_title: string;
+    role: string | null;
+    shoot_year: number | null;
+    production_slug: string;
+    production_title: string;
+    production_kind: string;
+  }>
+> {
+  const { rows } = await pool.query(
+    `SELECT l.slug AS listing_slug, l.title AS listing_title,
+            fl.role,
+            EXTRACT(year FROM fl.shoot_date_from)::int AS shoot_year,
+            fp.slug AS production_slug, fp.title AS production_title, fp.kind AS production_kind
+     FROM filming_location fl
+     JOIN listing l ON l.id = fl.listing_id
+     JOIN film_production fp ON fp.id = fl.production_id
+     WHERE fl.public_visible = true AND l.is_public = true
+       AND l.city = $1 AND l.id <> $2
+     ORDER BY fl.shoot_date_from DESC NULLS LAST
+     LIMIT $3`,
+    [city, excludeListingId, limit]
+  );
+  return rows;
+}
+
+export async function getAllFilmingPins(): Promise<FilmingPin[]> {
+  const { rows } = await pool.query<FilmingPin>(
+    `SELECT l.slug, l.title, l.latitude::float AS lat, l.longitude::float AS lng,
+            json_agg(json_build_object('slug', fp.slug, 'title', fp.title, 'year', fp.year)
+                     ORDER BY fp.year DESC NULLS LAST) AS productions
+     FROM filming_location fl
+     JOIN listing l ON l.id = fl.listing_id
+     JOIN film_production fp ON fp.id = fl.production_id
+     WHERE fl.public_visible = true AND l.is_public = true
+       AND l.latitude IS NOT NULL AND l.longitude IS NOT NULL
+     GROUP BY l.id, l.slug, l.title, l.latitude, l.longitude`
+  );
+  return rows;
+}
+
+export async function listProductionsWithCounts(opts: { kind?: string; decade?: string } = {}): Promise<(FilmProduction & { location_count: number })[]> {
+  const conds: string[] = [];
+  const params: unknown[] = [];
+  if (opts.kind) {
+    params.push(opts.kind);
+    conds.push(`fp.kind = $${params.length}`);
+  }
+  if (opts.decade && /^\d{3}0s$/.test(opts.decade)) {
+    const start = Number(opts.decade.slice(0, 4));
+    params.push(start);
+    conds.push(`fp.year >= $${params.length}`);
+    params.push(start + 10);
+    conds.push(`fp.year < $${params.length}`);
+  }
+  const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+  const { rows } = await pool.query(
+    `SELECT fp.*, COUNT(fl.id)::int AS location_count
+     FROM film_production fp
+     LEFT JOIN filming_location fl ON fl.production_id = fp.id AND fl.public_visible = true
+     ${where}
+     GROUP BY fp.id
+     HAVING COUNT(fl.id) > 0
+     ORDER BY COUNT(fl.id) DESC, fp.year DESC NULLS LAST, fp.title`,
+    params
+  );
+  return rows;
+}
+
+export async function listNeighborhoodsWithGeo(): Promise<
+  { city: string; state: string; count: number; lat: number | null; lng: number | null }[]
+> {
+  const { rows } = await pool.query(
+    `SELECT city, state, count(*)::int AS count,
+            avg(latitude)::float AS lat, avg(longitude)::float AS lng
+     FROM listing
+     WHERE city IS NOT NULL AND is_public = true
+     GROUP BY city, state
+     ORDER BY count(*) DESC`
+  );
+  return rows;
+}
+
+export type ClaimExample = {
+  slug: string;
+  title: string;
+  city: string | null;
+  hero_image: string | null;
+  year_built: number | null;
+  property_type: string | null;
+  films_count: number;
+  permits_count: number;
+  owners_count: number;
+};
+
+/**
+ * Picks 4-6 listings with the richest metadata for the /submit page hero.
+ * Used to show what a claimed address looks like — demonstrates the value
+ * proposition before asking the user to add their own. Per UX critique R5.
+ */
+export async function getClaimExampleAddresses(limit = 4): Promise<ClaimExample[]> {
+  const { rows } = await pool.query<ClaimExample>(
+    `SELECT
+       l.slug, l.title, l.city, l.hero_image,
+       s.year_built, l.property_type,
+       COALESCE((SELECT count(*)::int FROM filming_location WHERE listing_id = l.id AND public_visible = true), 0) AS films_count,
+       COALESCE((SELECT count(*)::int FROM place_event WHERE listing_id = l.id AND public_visible = true AND kind = 'permit'), 0) AS permits_count,
+       COALESCE((SELECT count(DISTINCT epa.entity_id)::int FROM entity_place_association epa WHERE epa.listing_id = l.id AND epa.public_visible = true), 0) AS owners_count
+     FROM listing l
+     LEFT JOIN structure s ON s.listing_id = l.id
+     WHERE l.is_public = true AND l.hero_image IS NOT NULL
+     ORDER BY
+       COALESCE((SELECT count(*) FROM filming_location WHERE listing_id = l.id AND public_visible = true), 0) DESC,
+       (s.year_built IS NOT NULL) DESC,
+       l.updated_at DESC
+     LIMIT $1`,
+    [limit]
+  );
+  return rows;
+}
+
+export async function listNeighborhoods(): Promise<{ city: string; state: string; count: number }[]> {
+  const { rows } = await pool.query<{ city: string; state: string; count: string }>(
+    `SELECT city, state, count(*)::text AS count
+     FROM listing
+     WHERE city IS NOT NULL AND is_public = true
+     GROUP BY city, state
+     ORDER BY count(*) DESC`
+  );
+  return rows.map(r => ({ city: r.city, state: r.state, count: Number(r.count) }));
+}
+
+export type PdFilmImage = {
+  id: string;
+  source: 'wikimedia' | 'archive_org' | 'loc' | 'nara' | 'mhdl' | 'smithsonian' | 'nypl' | 'flickr_commons' | 'openverse' | 'met' | 'dpla';
+  source_id: string;
+  title: string;
+  caption: string | null;
+  image_url: string;
+  thumb_url: string | null;
+  width: number | null;
+  height: number | null;
+  year: number | null;
+  kind: string | null;
+  rights_url: string | null;
+  source_url: string;
+  production_id: string | null;
+};
+
+// Sources whose hot-link images are blocked by Chromium ORB / cross-origin
+// policy and therefore won't render in a browser <img> tag, even though the
+// rows are valid PD content. Kept in DB but excluded from the rendered grid.
+const BROWSER_BLOCKED_SOURCES = ['smithsonian'] as const;
+
+export async function listPdFilmImages(opts: {
+  limit?: number;
+  source?: PdFilmImage['source'];
+  sources?: PdFilmImage['source'][];      // multi-source filter (per-surface affinity)
+  kind?: string;
+  productionId?: string;
+  productionIds?: string[];   // multi-id filter, used by /address/[slug] to scope to films shot here
+  includeBlocked?: boolean;
+} = {}): Promise<PdFilmImage[]> {
+  const conds: string[] = ['image_url IS NOT NULL'];
+  const params: unknown[] = [];
+  if (opts.source)       { params.push(opts.source);       conds.push(`source = $${params.length}`); }
+  if (opts.sources && opts.sources.length) {
+    params.push(opts.sources);
+    conds.push(`source = ANY($${params.length}::text[])`);
+  }
+  if (opts.kind)         { params.push(opts.kind);         conds.push(`kind = $${params.length}`); }
+  if (opts.productionId) { params.push(opts.productionId); conds.push(`production_id = $${params.length}`); }
+  if (opts.productionIds && opts.productionIds.length) {
+    params.push(opts.productionIds);
+    conds.push(`production_id = ANY($${params.length}::uuid[])`);
+  }
+  if (!opts.includeBlocked) {
+    params.push(BROWSER_BLOCKED_SOURCES);
+    conds.push(`source <> ALL($${params.length}::text[])`);
+  }
+  params.push(opts.limit ?? 60);
+  // Interleave sources so every source gets fair representation in the first N rows:
+  // rank within source by year DESC NULLS LAST, then sort by rank then source.
+  // Effect: row 1 = best of each source, row 2 = next best of each source, etc.
+  const sql = `
+    SELECT id, source, source_id, title, caption, image_url, thumb_url,
+           width, height, year, kind, rights_url, source_url, production_id
+    FROM (
+      SELECT *, row_number() OVER (
+        PARTITION BY source
+        ORDER BY year DESC NULLS LAST, fetched_at DESC
+      ) AS rnk
+      FROM pd_film_image
+      WHERE ${conds.join(' AND ')}
+    ) ranked
+    ORDER BY rnk, source
+    LIMIT $${params.length}
+  `;
+  try {
+    const { rows } = await pool.query<PdFilmImage>(sql, params);
+    return rows;
+  } catch (e: unknown) {
+    if (e instanceof Error && /relation .* does not exist/.test(e.message)) return [];
+    throw e;
+  }
+}
diff --git a/src/lib/flags.ts b/src/lib/flags.ts
new file mode 100644
index 0000000..ea91d53
--- /dev/null
+++ b/src/lib/flags.ts
@@ -0,0 +1,14 @@
+/**
+ * Feature flags. Single source of truth for enabling/disabling
+ * non-essential surfaces.
+ *
+ * FREE_FOREVER (default true): Steve's standing decision — pastdoor is free
+ * forever, no paid tiers. When true, hides /promote, /hosts, sponsored cards,
+ * any paid-upgrade CTAs. Schema's upgrade/sponsored_placement tables stay
+ * intact for reversibility.
+ *
+ * Set FREE_FOREVER=false in .env if you ever want to revive the
+ * Airbnb-claim-and-upgrade model.
+ */
+export const FREE_FOREVER = process.env.FREE_FOREVER !== 'false';
+export const PAY_APPS_ENABLED = !FREE_FOREVER;
diff --git a/src/lib/la-arcgis.ts b/src/lib/la-arcgis.ts
new file mode 100644
index 0000000..bc08f67
--- /dev/null
+++ b/src/lib/la-arcgis.ts
@@ -0,0 +1,250 @@
+/**
+ * LA County GIS ArcGIS REST client — TS port of mcp-la-records/src/arcgis.mjs.
+ *
+ * Why duplicated: pastdoor is server-side and can call the public ArcGIS
+ * endpoint directly without going through MCP/stdio. The MCP server stays
+ * for agent + CLI use; this module is the in-app caller.
+ *
+ * Cached in `la_parcel` PG table — see migration 013_la_parcel_cache.sql.
+ */
+import { pool } from './db';
+
+const ARCGIS_BASE =
+  'https://public.gis.lacounty.gov/public/rest/services/LACounty_Cache/LACounty_Parcel/MapServer/0/query';
+
+const FIELDS = [
+  'APN', 'SitusFullAddress', 'SitusHouseNo', 'SitusDirection', 'SitusStreet',
+  'SitusUnit', 'SitusCity', 'SitusZIP',
+  'UseCode', 'UseType', 'UseDescription',
+  'YearBuilt1', 'EffectiveYear1',
+  'Units1', 'Bedrooms1', 'Bathrooms1', 'SQFTmain1',
+  'Roll_Year', 'Roll_LandValue', 'Roll_ImpValue', 'Roll_HomeOwnersExemp',
+  'CENTER_LAT', 'CENTER_LON',
+].join(',');
+
+const UA = 'pastdoor/1.0 (https://wholivedthere.com)';
+const CACHE_TTL_DAYS = 30;
+
+export type LAParcel = {
+  apn: string;
+  listing_id: string | null;
+  situs_address: string | null;
+  city: string | null;
+  zip: string | null;
+  use_code: string | null;
+  use_type: string | null;
+  use_description: string | null;
+  year_built: number | null;
+  effective_year: number | null;
+  sqft_main: number | null;
+  bedrooms: number | null;
+  bathrooms: number | null;
+  units: number | null;
+  roll_year: number | null;
+  land_value: number | null;
+  improvement_value: number | null;
+  total_value: number | null;
+  homeowner_exempt: boolean;
+  lat: number | null;
+  lng: number | null;
+  fetched_at: Date | string;
+};
+
+function normalizeForSearch(addr: string): string {
+  return String(addr)
+    .toUpperCase()
+    .replace(/[,]/g, ' ')
+    .replace(/\b(NORTH|SOUTH|EAST|WEST)\b/g, m => m[0])
+    .replace(/\b(STREET|AVENUE|BOULEVARD|DRIVE|ROAD|PLACE|TERRACE|COURT|LANE|WAY)\b/g, m =>
+      ({ STREET: 'ST', AVENUE: 'AVE', BOULEVARD: 'BLVD', DRIVE: 'DR', ROAD: 'RD', PLACE: 'PL', TERRACE: 'TER', COURT: 'CT', LANE: 'LN', WAY: 'WAY' } as Record<string, string>)[m] ?? m
+    )
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function tokens(normalized: string): { houseNo: string | null; street: string } {
+  const parts = normalized.split(' ');
+  const numIdx = parts.findIndex(p => /^\d+$/.test(p));
+  const houseNo = numIdx >= 0 ? parts[numIdx] : null;
+  const after = parts.slice(numIdx + 1);
+
+  // Cities are matched as multi-word phrases scanning from the end of the
+  // address — single-token hints (e.g., 'SANTA', 'WEST') falsely truncated
+  // streets like 'SANTA MONICA BLVD' or 'W HOLLYWOOD BLVD' to empty. Phrases
+  // use the post-normalize form, so 'WEST'→'W' and 'BOULEVARD'→'BLVD'.
+  const cityPhrases: string[][] = [
+    ['BEVERLY', 'HILLS'],
+    ['W', 'HOLLYWOOD'],
+    ['SANTA', 'MONICA'],
+    ['LOS', 'ANGELES'],
+    ['CULVER', 'CITY'],
+    ['LONG', 'BEACH'],
+    ['HOLLYWOOD'],
+    ['PASADENA'],
+    ['BURBANK'],
+    ['GLENDALE'],
+    ['MALIBU'],
+  ];
+
+  // Match the longest city phrase that is a suffix of `after`. Longest-first
+  // so 'W HOLLYWOOD' wins over 'HOLLYWOOD' when both could match.
+  const phrasesByLen = [...cityPhrases].sort((a, b) => b.length - a.length);
+  let cityStart = -1;
+  for (const phrase of phrasesByLen) {
+    if (phrase.length > after.length) continue;
+    const start = after.length - phrase.length;
+    let match = true;
+    for (let j = 0; j < phrase.length; j++) {
+      if (after[start + j] !== phrase[j]) { match = false; break; }
+    }
+    if (match) { cityStart = start; break; }
+  }
+
+  const street = (cityStart >= 0 ? after.slice(0, cityStart) : after).join(' ');
+  return { houseNo, street };
+}
+
+async function fetchArcGis(where: string): Promise<unknown[]> {
+  const url = `${ARCGIS_BASE}?where=${encodeURIComponent(where)}&outFields=${FIELDS}&f=json&resultRecordCount=5`;
+  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+  if (!r.ok) throw new Error(`arcgis ${r.status}`);
+  const data: unknown = await r.json();
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  return ((data as any)?.features ?? []) as unknown[];
+}
+
+function normalizeArcGisRow(a: Record<string, unknown>): Omit<LAParcel, 'listing_id' | 'fetched_at'> | null {
+  if (!a.APN) return null;
+  const num = (v: unknown) => {
+    const n = Number(v);
+    return Number.isFinite(n) && n !== 0 ? n : null;
+  };
+  const land = num(a.Roll_LandValue);
+  const imp  = num(a.Roll_ImpValue);
+  return {
+    apn: String(a.APN),
+    situs_address: (a.SitusFullAddress as string) ?? null,
+    city: (a.SitusCity as string)?.trim() ?? null,
+    zip: (a.SitusZIP as string)?.trim() ?? null,
+    use_code: (a.UseCode as string) ?? null,
+    use_type: (a.UseType as string) ?? null,
+    use_description: (a.UseDescription as string) ?? null,
+    year_built: num(a.YearBuilt1),
+    effective_year: num(a.EffectiveYear1),
+    sqft_main: num(a.SQFTmain1),
+    bedrooms: num(a.Bedrooms1),
+    bathrooms: num(a.Bathrooms1),
+    units: num(a.Units1),
+    roll_year: num(a.Roll_Year),
+    land_value: land,
+    improvement_value: imp,
+    total_value: (land ?? 0) + (imp ?? 0) || null,
+    homeowner_exempt: !!a.Roll_HomeOwnersExemp,
+    lat: num(a.CENTER_LAT),
+    lng: num(a.CENTER_LON),
+  };
+}
+
+/**
+ * Cache-first lookup. Returns the cached row if fresh; otherwise hits ArcGIS,
+ * stores, and returns. Returns null if no parcel matches.
+ */
+export async function getOrFetchParcelForAddress(
+  addressInput: string,
+  listingId: string | null
+): Promise<LAParcel | null> {
+  // 1. Cache hit by listing_id (fastest)
+  if (listingId) {
+    try {
+      const { rows } = await pool.query<LAParcel>(
+        `SELECT * FROM la_parcel WHERE listing_id = $1 AND fetched_at > now() - interval '${CACHE_TTL_DAYS} days' LIMIT 1`,
+        [listingId]
+      );
+      if (rows[0]) return rows[0];
+    } catch (e) {
+      // Table may not exist yet on a fresh deploy — fall through to fetch.
+      if (!(e instanceof Error && /relation .* does not exist/.test(e.message))) throw e;
+    }
+  }
+
+  // 2. Hit ArcGIS
+  const norm = normalizeForSearch(addressInput);
+  const { houseNo, street } = tokens(norm);
+
+  // Hard-allowlist tokens used in WHERE clauses. ArcGIS treats single quotes
+  // as string delimiters and supports LIKE wildcards (% and _) — both are
+  // meaningful and must not survive into a where clause built from user input.
+  // Refuse to interpolate anything that doesn't match. Belt-and-braces: also
+  // strip any character outside the allowlist before interpolation.
+  const sanitizeNo     = (s: string) => /^\d{1,8}$/.test(s) ? s : '';
+  const sanitizeStreet = (s: string) => s.replace(/[^A-Z0-9 ]/g, '').trim().slice(0, 60);
+  const safeNo     = houseNo ? sanitizeNo(houseNo) : '';
+  const safeStreet = street ? sanitizeStreet(street) : '';
+
+  const tries: string[] = [];
+  if (safeNo && safeStreet) {
+    tries.push(`SitusFullAddress LIKE '${safeNo} %${safeStreet}%'`);
+    tries.push(`SitusFullAddress LIKE '${safeNo} N ${safeStreet}%'`);
+    tries.push(`SitusFullAddress LIKE '${safeNo} S ${safeStreet}%'`);
+    tries.push(`SitusFullAddress LIKE '${safeNo} E ${safeStreet}%'`);
+    tries.push(`SitusFullAddress LIKE '${safeNo} W ${safeStreet}%'`);
+  }
+  if (!safeNo && safeStreet) {
+    tries.push(`SitusFullAddress LIKE '%${safeStreet}%'`);
+  }
+  if (tries.length === 0) {
+    // Input was either empty or contained only disallowed chars (apostrophe-only
+    // queries like O'Connor, ArcGIS wildcards like '%' or '_', SQL
+    // metacharacters). Refuse the lookup.
+    console.warn(`la-arcgis: refused unsafe input "${addressInput}"`);
+    return null;
+  }
+
+  let raw: Record<string, unknown> | null = null;
+  for (const where of tries) {
+    let feats: unknown[];
+    try { feats = await fetchArcGis(where); } catch (e) { console.warn(`arcgis ${where}: ${e instanceof Error ? e.message : e}`); continue; }
+    if (feats.length) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      raw = (feats[0] as any).attributes;
+      break;
+    }
+  }
+  if (!raw) return null;
+
+  const norm2 = normalizeArcGisRow(raw);
+  if (!norm2) return null;
+
+  // 3. Cache (best-effort — table may not exist yet)
+  try {
+    await pool.query(
+      `INSERT INTO la_parcel
+        (apn, listing_id, situs_address, city, zip, use_code, use_type, use_description,
+         year_built, effective_year, sqft_main, bedrooms, bathrooms, units,
+         roll_year, land_value, improvement_value, total_value, homeowner_exempt,
+         lat, lng, raw, fetched_at)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22, now())
+       ON CONFLICT (apn) DO UPDATE SET
+         listing_id = COALESCE(EXCLUDED.listing_id, la_parcel.listing_id),
+         situs_address = EXCLUDED.situs_address,
+         city = EXCLUDED.city, zip = EXCLUDED.zip,
+         use_code = EXCLUDED.use_code, use_type = EXCLUDED.use_type, use_description = EXCLUDED.use_description,
+         year_built = EXCLUDED.year_built, effective_year = EXCLUDED.effective_year,
+         sqft_main = EXCLUDED.sqft_main, bedrooms = EXCLUDED.bedrooms, bathrooms = EXCLUDED.bathrooms, units = EXCLUDED.units,
+         roll_year = EXCLUDED.roll_year, land_value = EXCLUDED.land_value, improvement_value = EXCLUDED.improvement_value,
+         total_value = EXCLUDED.total_value, homeowner_exempt = EXCLUDED.homeowner_exempt,
+         lat = EXCLUDED.lat, lng = EXCLUDED.lng, raw = EXCLUDED.raw, fetched_at = now()`,
+      [norm2.apn, listingId, norm2.situs_address, norm2.city, norm2.zip,
+       norm2.use_code, norm2.use_type, norm2.use_description,
+       norm2.year_built, norm2.effective_year, norm2.sqft_main, norm2.bedrooms, norm2.bathrooms, norm2.units,
+       norm2.roll_year, norm2.land_value, norm2.improvement_value, norm2.total_value, norm2.homeowner_exempt,
+       norm2.lat, norm2.lng, JSON.stringify(raw)]
+    );
+  } catch (e) {
+    if (!(e instanceof Error && /relation .* does not exist/.test(e.message))) {
+      console.warn('la_parcel upsert:', e instanceof Error ? e.message : e);
+    }
+  }
+
+  return { ...norm2, listing_id: listingId, fetched_at: new Date() };
+}
diff --git a/src/lib/la-permits.ts b/src/lib/la-permits.ts
new file mode 100644
index 0000000..e66ab4f
--- /dev/null
+++ b/src/lib/la-permits.ts
@@ -0,0 +1,123 @@
+/**
+ * LA City permits adapter — Socrata `pi9x-tg5x` (Building and Safety).
+ *
+ * Limitation: This is the City of LA dataset only. Beverly Hills, Santa
+ * Monica, Culver City, West Hollywood, Pasadena etc. are separate
+ * jurisdictions with their own portals; each needs its own adapter.
+ *
+ * APN format here is digits-only (no hyphens) — `4343024017` not `4343-024-017`.
+ * Cache in `la_permit` table.
+ */
+import { pool } from './db';
+
+const SOCRATA_BASE = 'https://data.lacity.org/resource/pi9x-tg5x.json';
+const UA = 'pastdoor/1.0 (https://wholivedthere.com)';
+const CACHE_TTL_DAYS = 7; // permits update more often than parcel data
+
+export type LAPermit = {
+  permit_nbr: string;
+  apn: string;
+  primary_address: string | null;
+  zip_code: string | null;
+  permit_type: string | null;
+  permit_subtype: string | null;
+  permit_group: string | null;
+  status: string | null;
+  issue_date: string | null;     // ISO date
+  status_date: string | null;
+  valuation: number | null;
+  use_code: string | null;
+  use_description: string | null;
+  jurisdiction: string;
+};
+
+function apnDigits(apn: string): string {
+  return String(apn).replace(/-/g, '').trim();
+}
+
+async function fetchJson(url: string): Promise<unknown> {
+  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
+  if (!r.ok) throw new Error(`socrata ${r.status}`);
+  return r.json();
+}
+
+/** Cache-first lookup. Returns up to `limit` permits for an APN. */
+export async function getPermitsForApn(apnInput: string, opts: { limit?: number } = {}): Promise<LAPermit[]> {
+  const apn = apnDigits(apnInput);
+  if (!apn) return [];
+  const limit = Math.min(opts.limit ?? 50, 200);
+
+  // Check cache freshness — if any permit for this APN was fetched within TTL, trust the cache.
+  try {
+    const fresh = await pool.query<{ exists: boolean }>(
+      `SELECT EXISTS (SELECT 1 FROM la_permit WHERE apn = $1 AND fetched_at > now() - interval '${CACHE_TTL_DAYS} days') AS exists`,
+      [apn]
+    );
+    if (fresh.rows[0]?.exists) {
+      const { rows } = await pool.query<LAPermit>(
+        `SELECT permit_nbr, apn, primary_address, zip_code, permit_type, permit_subtype, permit_group,
+                status, issue_date::text, status_date::text, valuation, use_code, use_description, jurisdiction
+         FROM la_permit
+         WHERE apn = $1
+         ORDER BY issue_date DESC NULLS LAST
+         LIMIT $2`,
+        [apn, limit]
+      );
+      return rows;
+    }
+  } catch (e) {
+    // Table may not exist yet — fall through to fetch.
+    if (!(e instanceof Error && /relation .* does not exist/.test(e.message))) throw e;
+  }
+
+  // Fetch from Socrata
+  const url = `${SOCRATA_BASE}?apn=${apn}&$order=issue_date DESC&$limit=${limit}`;
+  let data: unknown;
+  try { data = await fetchJson(url); } catch (e) { console.warn(`la_permits: ${e instanceof Error ? e.message : e}`); return []; }
+  const rows = Array.isArray(data) ? data : [];
+  if (!rows.length) return [];
+
+  const permits: LAPermit[] = rows.map(r => {
+    const x = r as Record<string, unknown>;
+    return {
+      permit_nbr: String(x.permit_nbr ?? ''),
+      apn,
+      primary_address: (x.primary_address as string) ?? null,
+      zip_code: (x.zip_code as string) ?? null,
+      permit_type: (x.permit_type as string) ?? null,
+      permit_subtype: (x.permit_sub_type as string) ?? null,
+      permit_group: (x.permit_group as string) ?? null,
+      status: (x.status as string) ?? null,
+      issue_date: typeof x.issue_date === 'string' ? x.issue_date.slice(0, 10) : null,
+      status_date: typeof x.status_date === 'string' ? x.status_date.slice(0, 10) : null,
+      valuation: x.valuation ? Number(x.valuation) : null,
+      use_code: (x.use_code as string) ?? null,
+      use_description: (x.use_desc as string) ?? null,
+      jurisdiction: 'LA City',
+    };
+  }).filter(p => p.permit_nbr);
+
+  // Best-effort cache write
+  try {
+    for (const p of permits) {
+      await pool.query(
+        `INSERT INTO la_permit
+          (permit_nbr, apn, primary_address, zip_code, permit_type, permit_subtype, permit_group,
+           status, issue_date, status_date, valuation, use_code, use_description, jurisdiction, raw, fetched_at)
+         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::date,$10::date,$11,$12,$13,$14,$15, now())
+         ON CONFLICT (permit_nbr) DO UPDATE SET
+           status = EXCLUDED.status, status_date = EXCLUDED.status_date,
+           valuation = EXCLUDED.valuation, fetched_at = now()`,
+        [p.permit_nbr, p.apn, p.primary_address, p.zip_code, p.permit_type, p.permit_subtype, p.permit_group,
+         p.status, p.issue_date, p.status_date, p.valuation, p.use_code, p.use_description, p.jurisdiction,
+         JSON.stringify(rows.find(r => (r as Record<string, unknown>).permit_nbr === p.permit_nbr) ?? {})]
+      );
+    }
+  } catch (e) {
+    if (!(e instanceof Error && /relation .* does not exist/.test(e.message))) {
+      console.warn('la_permit upsert:', e instanceof Error ? e.message : e);
+    }
+  }
+
+  return permits;
+}
diff --git a/src/lib/multiplier.ts b/src/lib/multiplier.ts
new file mode 100644
index 0000000..f69af2a
--- /dev/null
+++ b/src/lib/multiplier.ts
@@ -0,0 +1,85 @@
+/**
+ * Read the empirical LA 1978-1984 multiplier and project a nightly rate
+ * into an anchored monthly estimate.
+ *
+ * No canned CPI. No flat 3%. The projection is only as good as the
+ * observed cycle shape — anchor window + confidence dot are mandatory
+ * at the render site.
+ *
+ * See docs/rent-multiplier-1978-1984.md + scripts/ingest-la-price-series.ts.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+type Phase = { name: string; from: number; to: number; price_yoy: number };
+type Multiplier = {
+  generated_at: string;
+  anchor_window: string;
+  geography: string;
+  sources: { homePrice: string; rent: string };
+  price_yoy: { year: number; value: number; yoy_pct: number | null }[];
+  rent_yoy: { year: number; value: number; yoy_pct: number | null }[];
+  phases: Phase[];
+  months_to_double: {
+    first_leg_up: number;
+    rate_shock_stall: number;
+    recovery: number;
+    cycle_avg: number;
+  };
+};
+
+let cached: Multiplier | null = null;
+function load(): Multiplier | null {
+  if (cached) return cached;
+  const p = path.resolve(process.cwd(), 'data/multiplier_1978_1984.json');
+  if (!fs.existsSync(p)) return null;
+  cached = JSON.parse(fs.readFileSync(p, 'utf8')) as Multiplier;
+  return cached;
+}
+
+/**
+ * Project a monthly rent from a nightly rate using the cumulative LA
+ * 1978-84 ratio as the anchor for our confidence. We do NOT multiply
+ * forward by that %; the historical ratio tells us the degree of
+ * uncertainty for an LA-specific forecast. Our nominal projection is
+ * a simple occupancy-adjusted monthly, anchored to the observed cycle
+ * so the reader can see the band we're working within.
+ */
+export function projectMonthlyFromNightly(nightly: number | null): {
+  monthly: number | null;
+  confidence: number;
+  anchorWindow: string;
+  note: string;
+} {
+  if (!nightly) {
+    return {
+      monthly: null,
+      confidence: 0,
+      anchorWindow: 'no data',
+      note: 'Insufficient rate data to project.',
+    };
+  }
+  const m = load();
+  const monthly = Math.round(nightly * 22); // ~73% occupancy at nightly rate
+
+  if (!m) {
+    return {
+      monthly,
+      confidence: 0.3,
+      anchorWindow: 'stub (multiplier not yet ingested)',
+      note: 'Run scripts/ingest-la-price-series.ts to enable the empirical LA 1978-1984 anchor.',
+    };
+  }
+
+  const first = m.price_yoy[0]?.value ?? 1;
+  const last = m.price_yoy[m.price_yoy.length - 1]?.value ?? first;
+  const cumulative = last / first - 1;
+  const phaseMix = m.phases.map(p => `${p.name.replace(/_/g, ' ')} ${p.price_yoy.toFixed(1)}%/yr`).join(' · ');
+
+  return {
+    monthly,
+    confidence: 0.55,
+    anchorWindow: `LA 1978-1984 · ${m.sources.homePrice.split(' · ').pop()} · +${(cumulative * 100).toFixed(0)}% cum`,
+    note: `Anchored to observed cycle: ${phaseMix}. Not a flat inflation assumption; your projection inherits the cycle's shape.`,
+  };
+}
diff --git a/src/lib/site.ts b/src/lib/site.ts
new file mode 100644
index 0000000..263eed1
--- /dev/null
+++ b/src/lib/site.ts
@@ -0,0 +1,105 @@
+/**
+ * Single source of truth for the public-facing brand.
+ *
+ * Three-domain ecosystem (one Next.js process, host-routed via middleware.ts):
+ *   - wholivedthere.com  → flagship / archive (default)
+ *   - claimmyaddress.com → onboarding / claim flow
+ *   - bubbesblock.com    → community / neighborhoods
+ *
+ * `SITE_NAME` etc. remain pastdoor as the umbrella brand;
+ * per-host overrides come from siteForHost(headers().get('host')) in server components.
+ */
+import { headers } from 'next/headers';
+
+export const SITE_NAME = 'pastdoor';
+export const SITE_DOMAIN = 'pastdoor.com';
+export const SITE_TAGLINE = 'Nextdoor, but for then.';
+export const SITE_DESCRIPTION =
+  'Every address has a story. Look up the full history of a house — who lived there, what was built and when, what the archival maps show. The opposite of a real-estate listing.';
+
+export type SurfaceKey = 'flagship' | 'claim' | 'community';
+
+export interface SurfaceConfig {
+  key: SurfaceKey;
+  domain: string;
+  brandName: string;
+  tagline: string;
+  description: string;
+  primaryCta: { label: string; href: string };
+  themeAccent: 'green' | 'red' | 'gold';   // matches Gucci palette decision in PLAN.md
+  ogTitle: string;
+  /**
+   * GA4 measurement ID per surface — populated once the analytics skill creates
+   * the properties (pending Steve granting the service account GA Admin access).
+   * Layout.tsx reads NEXT_PUBLIC_GA_<SURFACE_KEY> env vars first; falls back here.
+   */
+  gaMeasurementId?: string;
+}
+
+const SURFACES: Record<string, SurfaceConfig> = {
+  // Flagship — full archive
+  'wholivedthere.com': {
+    key: 'flagship',
+    domain: 'wholivedthere.com',
+    brandName: 'WhoLivedThere',
+    tagline: 'Every address has a story.',
+    description:
+      'Look up the full history of any house — who lived there, when it was built, what the archives say. Maps, deeds, permits, photos, year-by-year Street View.',
+    primaryCta: { label: 'Search any address', href: '/' },
+    themeAccent: 'green',
+    ogTitle: 'WhoLivedThere — every address has a story',
+  },
+  // Onboarding — claim flow
+  'claimmyaddress.com': {
+    key: 'claim',
+    domain: 'claimmyaddress.com',
+    brandName: 'ClaimMyAddress',
+    tagline: 'Your home. Your file. Forever.',
+    description:
+      'Claim your address. Upload deeds, bills, photos, and the receipts that prove your home is yours. A free, permanent home file backed by public records.',
+    primaryCta: { label: 'Claim your home', href: '/submit' },
+    themeAccent: 'red',
+    ogTitle: 'ClaimMyAddress — your home, your file, forever',
+  },
+  // Community — neighborhoods
+  'bubbesblock.com': {
+    key: 'community',
+    domain: 'bubbesblock.com',
+    brandName: "Bubbe's Block",
+    tagline: 'The block, then and now.',
+    description:
+      "What's happening on your block — and what was happening here a hundred years ago. Neighborhood posts, cup-of-sugar requests, history grounded in the archive.",
+    primaryCta: { label: 'See your block', href: '/neighborhoods' },
+    themeAccent: 'gold',
+    ogTitle: "Bubbe's Block — the block, then and now",
+  },
+};
+
+const DEFAULT_SURFACE = SURFACES['wholivedthere.com'];
+
+/**
+ * Resolve the surface config for a given host. Strips `www.` and port.
+ * Falls back to the wholivedthere flagship when host is unknown (dev, preview, etc.)
+ */
+export function siteForHost(rawHost: string | null | undefined): SurfaceConfig {
+  if (!rawHost) return DEFAULT_SURFACE;
+  const host = rawHost.toLowerCase().replace(/^www\./, '').replace(/:\d+$/, '');
+  return SURFACES[host] ?? DEFAULT_SURFACE;
+}
+
+/**
+ * Convenience for server components — reads x-pastdoor-host (set by middleware)
+ * or the literal Host header. Async because Next 15's `headers()` is async.
+ */
+export async function currentSurface(): Promise<SurfaceConfig> {
+  const h = await headers();
+  return siteForHost(h.get('x-pastdoor-host') || h.get('host'));
+}
+
+/**
+ * Sister-domain links shown via the cross-promo CTA component.
+ * Returns the *other two* surfaces, in the order they should appear.
+ */
+export function sisterSurfaces(current: SurfaceKey): SurfaceConfig[] {
+  return Object.values(SURFACES).filter(s => s.key !== current);
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..7b14b12
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,113 @@
+/**
+ * Host-based routing for the three-domain pastdoor ecosystem.
+ *
+ * - wholivedthere.com → flagship, no rewrite (root archive)
+ * - claimmyaddress.com → onboarding, redirect / → /submit
+ * - bubbesblock.com → community, redirect / → /neighborhoods
+ *
+ * All three domains hit the same Next.js process via nginx host routing.
+ * This file plus the per-host metadata helper in lib/site.ts are the only
+ * changes needed in stayclaim to support the three-domain ecosystem.
+ *
+ * Header `x-pastdoor-host` is set so server components downstream can read
+ * the originating host even after rewrites/redirects.
+ */
+import { NextRequest, NextResponse } from 'next/server';
+
+const HOST_ROUTES: Record<string, string | null> = {
+  'wholivedthere.com': null,           // root index
+  'www.wholivedthere.com': null,
+  'claimmyaddress.com': '/submit',
+  'www.claimmyaddress.com': '/submit',
+  'bubbesblock.com': '/neighborhoods',
+  'www.bubbesblock.com': '/neighborhoods',
+  // dev / staging
+  'localhost:9821': null,
+  'pastdoor.com': null,
+  'stayclaim.com': null,
+};
+
+// SECURITY (P0 fix 2026-05-04): /admin/* was completely unauthenticated —
+// admin/layout.tsx literally said "auth: dev-mode (no auth)". Anyone hitting
+// {wholivedthere,claimmyaddress,bubbesblock}.com/admin saw the full PG
+// dashboard, claim-request emails, and sponsored-placement contact_email.
+// Gate via HTTP Basic against ADMIN_SECRET in env.
+function adminGate(req: NextRequest): NextResponse | null {
+  const adminSecret = process.env.ADMIN_SECRET || '';
+  if (!adminSecret) {
+    // Fail-closed when secret unset in production; allow in dev.
+    if (process.env.NODE_ENV === 'production') {
+      return new NextResponse('Admin not configured', { status: 503 });
+    }
+    return null;
+  }
+  const auth = req.headers.get('authorization') || '';
+  const [scheme, creds] = auth.split(' ');
+  if (scheme !== 'Basic' || !creds) {
+    return new NextResponse('Unauthorized', {
+      status: 401,
+      headers: { 'WWW-Authenticate': 'Basic realm="stayclaim-admin"' },
+    });
+  }
+  const decoded = Buffer.from(creds, 'base64').toString();
+  const expected = `admin:${adminSecret}`;
+  // constant-time compare to avoid trivial timing oracle
+  if (decoded.length !== expected.length) {
+    return new NextResponse('Unauthorized', {
+      status: 401,
+      headers: { 'WWW-Authenticate': 'Basic realm="stayclaim-admin"' },
+    });
+  }
+  let mismatch = 0;
+  for (let i = 0; i < decoded.length; i++) mismatch |= decoded.charCodeAt(i) ^ expected.charCodeAt(i);
+  if (mismatch !== 0) {
+    return new NextResponse('Unauthorized', {
+      status: 401,
+      headers: { 'WWW-Authenticate': 'Basic realm="stayclaim-admin"' },
+    });
+  }
+  return null;
+}
+
+export function middleware(req: NextRequest) {
+  const host = (req.headers.get('host') || '').toLowerCase();
+  const url = req.nextUrl;
+
+  // P0 fix: gate /admin/* in middleware so EVERY admin route inherits auth,
+  // including admin/data/page.tsx (397-line arbitrary-table reader).
+  if (url.pathname.startsWith('/admin')) {
+    const blocked = adminGate(req);
+    if (blocked) return blocked;
+  }
+
+  // P0 fix: /dashboard?email=victim was a trivial IDOR — block in production
+  // until real auth ships. Dev still has it for local testing.
+  if (url.pathname === '/dashboard' && process.env.NODE_ENV === 'production') {
+    return new NextResponse('Not Found', { status: 404 });
+  }
+
+  // Strip www. for canonical comparison; preserve in headers
+  const canonicalHost = host.replace(/^www\./, '');
+
+  // Surface header for downstream components (per-host SEO, CTAs, theme)
+  const res = url.pathname === '/' && HOST_ROUTES[host] !== undefined && HOST_ROUTES[host] !== null
+    ? NextResponse.rewrite(new URL(HOST_ROUTES[host]!, req.url))
+    : NextResponse.next();
+
+  res.headers.set('x-pastdoor-host', canonicalHost);
+  return res;
+}
+
+export const config = {
+  matcher: [
+    /*
+     * Match all request paths except:
+     * - _next (internal)
+     * - api routes (host routing not relevant; /admin auth is page-route only)
+     * - static files (have a file extension)
+     * - favicons + robots + sitemap
+     * If any /api/admin/* routes are added later, gate them inline (not here).
+     */
+    '/((?!_next|api|.*\\.[a-z]+$|favicon.ico|robots.txt|sitemap.xml).*)',
+  ],
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..64c9bfc
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,21 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+  content: ['./src/**/*.{ts,tsx}'],
+  theme: {
+    extend: {
+      fontFamily: {
+        display: ['Instrument Serif', 'Georgia', 'serif'],
+        sans: ['Inter', 'system-ui', 'sans-serif'],
+      },
+      colors: {
+        ink: '#1a1410',
+        sand: '#f6f1e8',
+        moss: '#4a5d45',
+        coral: '#d4664a',
+      },
+    },
+  },
+  plugins: [],
+};
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..334fafd
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [{ "name": "next" }],
+    "paths": { "@/*": ["./src/*"] }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..3d9e4ec
--- /dev/null
+++ b/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/future/route-kind.d.ts","./node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/route-match.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/server/lib/revalidate.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/font-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-modules/route-module.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/client/components/request-async-storage-instance.d.ts","./node_modules/next/dist/client/components/request-async-storage.external.d.ts","./node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","./node_modules/next/dist/client/components/app-router.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/client/components/action-async-storage-instance.d.ts","./node_modules/next/dist/client/components/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/search-params.d.ts","./node_modules/next/dist/client/components/not-found-boundary.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/future/normalizers/normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","./node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","./node_modules/next/dist/server/future/normalizers/request/action.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","./node_modules/next/dist/build/swc/index.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/types/index.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/client/components/draft-mode.d.ts","./node_modules/next/dist/client/components/headers.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corepluginlist.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./node_modules/pg-types/index.d.ts","./node_modules/pg-protocol/dist/messages.d.ts","./node_modules/pg-protocol/dist/serializer.d.ts","./node_modules/pg-protocol/dist/parser.d.ts","./node_modules/pg-protocol/dist/index.d.ts","./node_modules/@types/pg/lib/type-overrides.d.ts","./node_modules/@types/pg/index.d.ts","./node_modules/@types/pg/index.d.mts","./scripts/enrich-entities-wikidata.ts","./scripts/enrich-entities-wikipedia.ts","./scripts/enrich-productions-wikidata.ts","./scripts/extract-news-addresses.ts","./scripts/ingest-bh-permits-arcgis.ts","./node_modules/slugify/slugify.d.ts","./scripts/ingest-bh-permits.ts","./scripts/ingest-la-additional-permits.ts","./scripts/ingest-la-assessor-legacy.ts","./scripts/ingest-la-boe-permits.ts","./scripts/ingest-la-business-licenses.ts","./scripts/ingest-la-city-permits.ts","./scripts/ingest-la-code-enforcement.ts","./scripts/ingest-la-cofo.ts","./scripts/ingest-la-county-assessor.ts","./scripts/ingest-la-film-permits.ts","./scripts/ingest-la-historic-monuments.ts","./scripts/ingest-la-lahd.ts","./scripts/ingest-la-newspapers-metadata.ts","./scripts/ingest-la-price-series.ts","./scripts/ingest-loc-sanborn.ts","./scripts/ingest-pasadena-permits.ts","./scripts/ingest-santa-monica-permits.ts","./scripts/ingest-weho-film-permits.ts","./scripts/ingest-wikipedia-named-houses.ts","./scripts/pull-la-newspapers-ocr.ts","./src/middleware.ts","./src/lib/site.ts","./src/lib/flags.ts","./src/app/robots.ts","./src/lib/db.ts","./src/app/sitemap.ts","./src/app/api/events/route.ts","./src/app/api/health/route.ts","./src/lib/la-arcgis.ts","./src/app/api/la-records/lookup/route.ts","./src/app/api/pastdoor-health/route.ts","./node_modules/zod/v3/helpers/typealiases.d.cts","./node_modules/zod/v3/helpers/util.d.cts","./node_modules/zod/v3/index.d.cts","./node_modules/zod/v3/zoderror.d.cts","./node_modules/zod/v3/locales/en.d.cts","./node_modules/zod/v3/errors.d.cts","./node_modules/zod/v3/helpers/parseutil.d.cts","./node_modules/zod/v3/helpers/enumutil.d.cts","./node_modules/zod/v3/helpers/errorutil.d.cts","./node_modules/zod/v3/helpers/partialutil.d.cts","./node_modules/zod/v3/standard-schema.d.cts","./node_modules/zod/v3/types.d.cts","./node_modules/zod/v3/external.d.cts","./node_modules/zod/index.d.cts","./src/app/api/promote/route.ts","./src/app/api/search/route.ts","./src/app/api/stars/nearby/route.ts","./src/app/api/submit/route.ts","./src/app/timeline/rss.xml/route.ts","./src/lib/la-permits.ts","./src/lib/multiplier.ts","./src/components/wordmark.tsx","./src/components/headersearch.tsx","./src/components/guccistripe.tsx","./src/app/layout.tsx","./src/app/not-found.tsx","./node_modules/next/dist/compiled/@vercel/og/index.edge.d.ts","./node_modules/next/dist/compiled/@vercel/og/index.node.d.ts","./node_modules/next/dist/server/og/image-response.d.ts","./node_modules/next/og.d.ts","./src/app/opengraph-image.tsx","./node_modules/@types/geojson/index.d.ts","./node_modules/@types/leaflet/index.d.ts","./node_modules/react-leaflet/lib/hooks.d.ts","./node_modules/react-leaflet/lib/attributioncontrol.d.ts","./node_modules/@react-leaflet/core/lib/attribution.d.ts","./node_modules/@react-leaflet/core/lib/context.d.ts","./node_modules/@react-leaflet/core/lib/element.d.ts","./node_modules/@react-leaflet/core/lib/events.d.ts","./node_modules/@react-leaflet/core/lib/layer.d.ts","./node_modules/@react-leaflet/core/lib/path.d.ts","./node_modules/@react-leaflet/core/lib/circle.d.ts","./node_modules/@react-leaflet/core/lib/div-overlay.d.ts","./node_modules/@react-leaflet/core/lib/component.d.ts","./node_modules/@react-leaflet/core/lib/control.d.ts","./node_modules/@react-leaflet/core/lib/dom.d.ts","./node_modules/@react-leaflet/core/lib/generic.d.ts","./node_modules/@react-leaflet/core/lib/grid-layer.d.ts","./node_modules/@react-leaflet/core/lib/media-overlay.d.ts","./node_modules/@react-leaflet/core/lib/pane.d.ts","./node_modules/@react-leaflet/core/lib/index.d.ts","./node_modules/react-leaflet/lib/circle.d.ts","./node_modules/react-leaflet/lib/circlemarker.d.ts","./node_modules/react-leaflet/lib/layergroup.d.ts","./node_modules/react-leaflet/lib/featuregroup.d.ts","./node_modules/react-leaflet/lib/geojson.d.ts","./node_modules/react-leaflet/lib/imageoverlay.d.ts","./node_modules/react-leaflet/lib/layerscontrol.d.ts","./node_modules/react-leaflet/lib/mapcontainer.d.ts","./node_modules/react-leaflet/lib/marker.d.ts","./node_modules/react-leaflet/lib/pane.d.ts","./node_modules/react-leaflet/lib/polygon.d.ts","./node_modules/react-leaflet/lib/polyline.d.ts","./node_modules/react-leaflet/lib/popup.d.ts","./node_modules/react-leaflet/lib/rectangle.d.ts","./node_modules/react-leaflet/lib/scalecontrol.d.ts","./node_modules/react-leaflet/lib/svgoverlay.d.ts","./node_modules/react-leaflet/lib/tilelayer.d.ts","./node_modules/react-leaflet/lib/tooltip.d.ts","./node_modules/react-leaflet/lib/videooverlay.d.ts","./node_modules/react-leaflet/lib/wmstilelayer.d.ts","./node_modules/react-leaflet/lib/zoomcontrol.d.ts","./node_modules/react-leaflet/lib/index.d.ts","./src/components/interactivemap.tsx","./src/components/interactivemapmount.tsx","./src/components/maphero.tsx","./src/components/rail.tsx","./src/components/nodecard.tsx","./src/components/filmsgrid.tsx","./src/components/filmsgridserver.tsx","./src/components/herocollage.tsx","./src/components/starsctarow.tsx","./src/app/page.tsx","./src/components/breadcrumbarchive.tsx","./src/components/tierbadge.tsx","./src/app/about/page.tsx","./src/app/address/[slug]/opengraph-image.tsx","./src/components/addressmasthead.tsx","./src/components/timeline.tsx","./src/components/factrow.tsx","./src/components/confidencedot.tsx","./src/components/projection.tsx","./src/components/claimcta.tsx","./src/components/sponsored/sponsoredcard.tsx","./src/components/curvesparkline.tsx","./src/components/streetview.tsx","./src/components/streetviewhistory.tsx","./src/components/mediawall.tsx","./src/components/jsonld.tsx","./src/components/provenancefooter.tsx","./src/components/crosspromo.tsx","./src/components/addressnarrative.tsx","./src/app/address/[slug]/page.tsx","./src/app/admin/layout.tsx","./src/app/admin/page.tsx","./src/app/admin/data/page.tsx","./src/app/admin/sources/page.tsx","./src/app/architects/page.tsx","./src/app/browse/page.tsx","./src/app/claim/[slug]/page.tsx","./src/app/dashboard/page.tsx","./src/app/entity/[slug]/opengraph-image.tsx","./src/app/entity/[slug]/page.tsx","./src/components/posterplaceholder.tsx","./src/app/film/[slug]/page.tsx","./src/app/films/page.tsx","./src/components/retiredfeaturenotice.tsx","./src/app/hosts/page.tsx","./src/components/listinggrid.tsx","./src/app/houses/page.tsx","./src/app/locations-by-year/page.tsx","./src/app/map/mapwithlayers.tsx","./src/app/map/page.tsx","./src/app/neighborhood/[slug]/page.tsx","./src/app/neighborhoods/page.tsx","./src/components/sourcelink.tsx","./src/components/permittable.tsx","./src/app/parcel/[apn]/page.tsx","./src/app/people/page.tsx","./src/app/promote/page.tsx","./src/app/promote/[slug]/promoteformclient.tsx","./src/app/promote/[slug]/page.tsx","./src/app/restaurants/page.tsx","./src/components/minimap.tsx","./src/app/restaurants/[slug]/page.tsx","./src/app/search/page.tsx","./src/app/stars/page.tsx","./src/app/stars/nearby/starsnearbyclient.tsx","./src/app/stars/nearby/page.tsx","./src/app/stars/walk/page.tsx","./src/components/submitminimap.tsx","./src/app/submit/submitformclient.tsx","./src/components/claimexamples.tsx","./src/app/submit/page.tsx","./src/app/timeline/page.tsx","./src/components/home/claimhome.tsx","./src/components/home/communityhome.tsx","./src/components/home/flagshiphome.tsx","./.next/types/app/layout.ts","./.next/types/app/page.ts","./.next/types/app/address/[slug]/page.ts","./.next/types/app/admin/layout.ts","./.next/types/app/admin/data/page.ts","./.next/types/app/houses/page.ts","./.next/types/app/neighborhoods/page.ts","./.next/types/app/parcel/[apn]/page.ts","./.next/types/app/search/page.ts","./.next/types/app/submit/page.ts"],"fileIdsList":[[99,145,359,582],[99,145,359,585],[99,145,359,583],[99,145,359,599],[99,145,359,504],[99,145,359,604],[99,145,359,562],[99,145,359,607],[99,145,359,615],[99,145,359,623],[99,145,407,408],[99,145],[99,145,512],[87,99,145,512,520],[87,99,145,517,522],[87,99,145,512],[99,145,512,517],[99,145,512,516,517,519],[87,99,145,516],[87,99,145,512,516,517,519,520,522,523],[99,145,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529],[99,145,512,516,517,518],[99,145,512,519],[99,145,512,516],[99,145,512,517,519],[99,145,511],[99,142,145],[99,144,145],[145],[99,145,150,178],[99,145,146,151,156,164,175,186],[99,145,146,147,156,164],[94,95,96,99,145],[99,145,148,187],[99,145,149,150,157,165],[99,145,150,175,183],[99,145,151,153,156,164],[99,144,145,152],[99,145,153,154],[99,145,155,156],[99,144,145,156],[99,145,156,157,158,175,186],[99,145,156,157,158,171,175,178],[99,145,153,156,159,164,175,186],[99,145,156,157,159,160,164,175,183,186],[99,145,159,161,175,183,186],[97,98,99,100,101,102,103,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192],[99,145,156,162],[99,145,163,186,191],[99,145,153,156,164,175],[99,145,165],[99,145,166],[99,144,145,167],[99,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192],[99,145,169],[99,145,170],[99,145,156,171,172],[99,145,171,173,187,189],[99,145,156,175,176,178],[99,145,177,178],[99,145,175,176],[99,145,178],[99,145,179],[99,142,145,175,180],[99,145,156,181,182],[99,145,181,182],[99,145,150,164,175,183],[99,145,184],[99,145,164,185],[99,145,159,170,186],[99,145,150,187],[99,145,175,188],[99,145,163,189],[99,145,190],[99,140,145],[99,140,145,156,158,167,175,178,186,189,191],[99,145,175,192],[99,145,441],[99,145,156,175,183,193,435,436,439,440,441],[87,99,145,197,198,199],[87,99,145,197,198],[87,99,145],[87,91,99,145,196,360,403],[87,91,99,145,195,360,403],[84,85,86,99,145],[92,99,145],[99,145,364],[99,145,366,367,368],[99,145,370],[99,145,202,212,218,220,360],[99,145,202,209,211,214,232],[99,145,212],[99,145,212,214,338],[99,145,267,285,300,406],[99,145,308],[99,145,202,212,219,253,263,335,336,406],[99,145,219,406],[99,145,212,263,264,265,406],[99,145,212,219,253,406],[99,145,406],[99,145,202,219,220,406],[99,145,293],[99,144,145,193,292],[87,99,145,286,287,288,305,306],[87,99,145,286],[99,145,276],[99,145,275,277,380],[87,99,145,286,287,303],[99,145,282,306,392],[99,145,390,391],[99,145,226,389],[99,145,279],[99,144,145,193,226,242,275,276,277,278],[87,99,145,303,305,306],[99,145,303,305],[99,145,303,304,306],[99,145,170,193],[99,145,274],[99,144,145,193,211,213,270,271,272,273],[87,99,145,203,383],[87,99,145,186,193],[87,99,145,219,251],[87,99,145,219],[99,145,249,254],[87,99,145,250,363],[87,99,145,403],[87,99,145,175,193,403,506],[87,91,99,145,159,193,195,196,360,401,402],[99,145,360],[99,145,201],[99,145,353,354,355,356,357,358],[99,145,355],[87,99,145,250,286,363],[87,99,145,286,361,363],[87,99,145,286,363],[99,145,159,193,213,363],[99,145,159,193,210,211,222,240,242,274,279,280,302,303],[99,145,271,274,279,287,289,290,291,293,294,295,296,297,298,299,406],[99,145,272],[87,99,145,170,193,211,212,240,242,243,245,270,302,306,360,406],[99,145,159,193,213,214,226,227,275],[99,145,159,193,212,214],[99,145,159,175,193,210,213,214],[99,145,159,170,186,193,210,211,212,213,214,219,222,223,233,234,236,239,240,242,243,244,245,269,270,303,311,313,316,318,321,323,324,325,326],[99,145,159,175,193],[99,145,202,203,204,210,211,360,363,406],[99,145,159,175,186,193,207,337,339,340,406],[99,145,170,186,193,207,210,213,230,234,236,237,238,243,270,316,327,329,335,349,350],[99,145,212,216,270],[99,145,210,212],[99,145,223,317],[99,145,319,320],[99,145,319],[99,145,317],[99,145,319,322],[99,145,206,207],[99,145,206,246],[99,145,206],[99,145,208,223,315],[99,145,314],[99,145,207,208],[99,145,208,312],[99,145,207],[99,145,302],[99,145,159,193,210,222,241,261,267,281,284,301,303],[99,145,255,256,257,258,259,260,282,283,306,361],[99,145,310],[99,145,159,193,210,222,241,247,307,309,311,360,363],[99,145,159,186,193,203,210,212,269],[99,145,266],[99,145,159,193,343,348],[99,145,233,242,269,363],[99,145,331,335,349,352],[99,145,159,216,335,343,344,352],[99,145,202,212,233,244,346],[99,145,159,193,212,219,244,330,331,341,342,345,347],[99,145,194,240,241,242,360,363],[99,145,159,170,186,193,208,210,211,213,216,221,222,230,233,234,236,237,238,239,243,245,269,270,313,327,328,363],[99,145,159,193,210,212,216,329,351],[99,145,507],[99,145,159,193,211,213],[87,99,145,159,170,193,201,203,210,211,214,222,239,240,242,243,245,310,360,363],[99,145,159,170,186,193,205,208,209,213],[99,145,206,268],[99,145,159,193,206,211,222],[99,145,159,193,212,223],[99,145,159,193],[99,145,226],[99,145,225],[99,145,227],[99,145,212,224,226,230],[99,145,212,224,226],[99,145,159,193,205,212,213,219,227,228,229],[87,99,145,303,304,305],[99,145,262],[87,99,145,203],[87,99,145,236],[87,99,145,194,239,242,245,360,363],[99,145,203,383,384],[87,99,145,254],[87,99,145,170,186,193,201,248,250,252,253,363],[99,145,213,219,236],[99,145,235],[87,99,145,157,159,170,193,201,254,263,360,361,362],[83,87,88,89,90,99,145,195,196,360,403],[99,145,150],[99,145,332,333,334],[99,145,332],[99,145,372],[99,145,374],[99,145,376],[99,145,378],[99,145,381],[99,145,385],[91,93,99,145,360,365,369,371,373,375,377,379,382,386,388,394,395,397,404,405,406],[99,145,387],[99,145,393],[99,145,508],[99,145,250],[99,145,396],[99,144,145,227,228,229,230,398,399,400,403],[99,145,193],[87,91,99,145,159,161,170,193,195,196,197,199,201,214,352,359,363,403],[99,145,193,436,437,438],[99,145,175,193,436],[99,145,425],[99,145,423,425],[99,145,414,422,423,424,426,428],[99,145,412],[99,145,415,420,425,428],[99,145,411,428],[99,145,415,416,419,420,421,428],[99,145,415,416,417,419,420,428],[99,145,412,413,414,415,416,420,421,422,424,425,426,428],[99,145,428],[99,145,410,412,413,414,415,416,417,419,420,421,422,423,424,425,426,427],[99,145,410,428],[99,145,415,417,418,420,421,428],[99,145,419,428],[99,145,420,421,425,428],[99,145,413,423],[87,99,145,512,530],[87,99,145,512,530,533],[87,99,145,511,512,530,533],[99,145,513,514,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551],[87,99,145,511,512,530],[99,145,430,431],[99,145,429,432],[99,112,116,145,186],[99,112,145,175,186],[99,107,145],[99,109,112,145,183,186],[99,145,164,183],[99,107,145,193],[99,109,112,145,164,186],[99,104,105,108,111,145,156,175,186],[99,112,119,145],[99,104,110,145],[99,112,133,134,145],[99,108,112,145,178,186,193],[99,133,145,193],[99,106,107,145,193],[99,112,145],[99,106,107,108,109,110,111,112,113,114,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,134,135,136,137,138,139,145],[99,112,127,145],[99,112,119,120,145],[99,110,112,120,121,145],[99,111,145],[99,104,107,112,145],[99,112,116,120,121,145],[99,116,145],[99,110,112,115,145,186],[99,104,109,112,119,145],[99,145,175],[99,107,112,133,145,191,193],[99,145,492],[99,145,483,484],[99,145,480,481,483,485,486,491],[99,145,481,483],[99,145,491],[99,145,483],[99,145,480,481,483,486,487,488,489,490],[99,145,480,481,482],[99,145,442],[99,145,157,166,442,448],[99,145,146,157,158,171,442],[99,145,146,157,158,166,171,175,176,442],[99,145,157,166],[99,145,146,158,166,442],[99,145,388,470,563,564],[99,145,382,470,473,509],[99,145,388,394,407,470,473,477,499,500,554,557,559,563,567,568,569,571,572,573,574,575,576,577,578,579,580,581],[99,145,388,473],[99,145,388,407],[99,145,404,473],[99,145,404],[99,145,404,477],[99,145,382,404,470,473],[99,145,404,471,473,493],[99,145,404,473,493],[99,145,388,407,470,473,563,580],[99,145,388,407,473,557,563],[99,145,394,407,473,563],[99,145,388,394,407,470,473,554,563,564,578],[99,145,388,394,407,470,473,554,559,563,564,578,580,593],[99,145,388,407,473,559,563,593],[99,145,388,470,471,563,596],[99,145,388,407,473,563,598],[99,145,382,388,407,470,471,501,502,503],[99,145,388,407,473,563],[87,99,145,554],[99,145,407,473,554,563,601],[99,145,407,473,557,563],[99,145,388,407,470,473,554,559,560,561,563,580],[99,145,388],[99,145,382,470,509],[99,145,470,473,555,556,557,559,560,561],[99,145,388,394,407,473,563,605,606],[99,145,394,407,471,473,563,573,596,610],[99,145,388,407,471,473,557,563,596],[87,99,145,388,394,407,473,563,613],[99,145,388,407,473,554,563],[99,145,382,407,470,471],[99,145,382,388,407,470,563],[99,145,382,407,470,471,473],[99,145,407,563,617],[87,99,145,388],[99,145,407,470,559,560,561,563,621,622],[87,99,145,620],[99,145,388,407,473,563,564],[99,145,404,470,473],[99,145,473],[99,145,473,477,499],[99,145,388,471],[99,145,388,470],[99,145,564],[87,99,145,470],[99,145,470,473,558],[99,145,470,473],[99,145,388,470,473],[87,99,145,388,512,552],[99,145,375,553],[99,145,554],[99,145,473,564],[99,145,386,388],[99,145,473,605],[99,145,570],[99,145,382,388,470,563],[99,145,375,552],[99,145,470],[99,145,382],[99,145,433]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"035312d4945d13efa134ae482f6dc56a1a9346f7ac3be7ccbad5741058ce87f3","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"714435130b9015fae551788df2a88038471a5a11eb471f27c4ede86552842bc9","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3989ccb24f2526f7e82cf54268e23ce9e1df5b9982f8acd099ddd4853c26babd","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"805c5db07d4b131bede36cc2dbded64cc3c8e49594e53119f4442af183f97935","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"b3aa6ede7dda2ee53ee78f257d5d6188f6ba75ac0a34a4b88be4ca93b869da07","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"15e3409b8397457d761d8d6f8c524795845c3aeb5dd0d4291ca0c54fec670b72","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a403c4aeeb153bc0c1f11458d005f8e5a0af3535c4c93eedc6f7865a3593f8e","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"250f9a1f11580b6b8a0a86835946f048eb605b3a596196741bfe72dc8f6c69cc","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"9ff1e8df66450af44161c1bfe34bc92c43074cfeec7a0a75f721830e9aabe379","impliedFormat":1},{"version":"3ecfccf916fea7c6c34394413b55eb70e817a73e39b4417d6573e523784e3f8e","impliedFormat":1},{"version":"1630192eac4188881201c64522cd3ef08209d9c4db0f9b5f0889b703dc6d936a","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"9dd9d642cdb87d4d5b3173217e0c45429b3e47a6f5cf5fb0ead6c644ec5fed01",{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"00cf4001e0d9c6e5e036bc545b9d73e2b8b84cddb02e61ad05bab3752b1d4522","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},{"version":"704a2474645ec6c97171405bf8ea82ab7ba4637b910eea088676d2cbd6c7a37a","signature":"f2542ed28646ccec19a2b407da97ef71777f4a2722da6990c958c2c9612ae978"},{"version":"f60e3e3060207ac982da13363181fd7ee4beecc19a7c569f0d6bb034331066c2","impliedFormat":1},{"version":"17230b34bb564a3a2e36f9d3985372ccab4ad1722df2c43f7c5c2b553f68e5db","impliedFormat":1},{"version":"6e5c9272f6b3783be7bdddaf207cccdb8e033be3d14c5beacc03ae9d27d50929","impliedFormat":1},{"version":"21ac4cf3f8d8c6e1201cb31f600be708c9a37867fc5c73b7ccf80560fae591c8","impliedFormat":1},{"version":"0dfe35191a04e8f9dc7caeb9f52f2ee07402736563d12cbccd15fb5f31ac877f","impliedFormat":1},{"version":"798367363a3274220cbed839b883fe2f52ba7197b25e8cb2ac59c1e1fd8af6b7","impliedFormat":1},{"version":"2636a309ed87d6876728d9aca846a76b372cf2a21a4fdf9940a82a2dd86687d0","impliedFormat":1},{"version":"5aea76ab98173f2c230b1f78dc010da403da622c105c468ace9fe24e3b77883c","impliedFormat":99},{"version":"d2d6511a2de6041d13ce3bc51bd0a545e70de2b7361444afa48cbb5821bb3d2d","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"85b2dac6b0fcc6aa83fd480bc0f7d0fd09616752394a45808314a589e3d49f58","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"ba2116e52618555197af9fe44a4d63535d40cb240b4b23b39bcda91167eae850","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"7235cf43190bebb72aae4c5dfc5cd0a788aa83b02b89668f3b313c70eabbf947","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"e2fd090a5a2fca07144f7f6f2cafdd2a869b9d38868b219056692530d4ebd250","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"a780291453a5e167be611a1dbcdca44df0acfa1d081cd3dd7a28eeac3b60fe57","impliedFormat":1},{"version":"5f870e6f65023ee16e31f8f96b639a49cf6c7d6d25e69415aaac9a14900cab6d","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"b4ac79611bc3d0fd75a46cf487949f1b9be3faa6d7161aa74d47b9dfb19e8aa0","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"a3466702b4a004dae1587a0ca5afee1a3fb0b95cecdf73b4d31defbdc56b6fcd","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"bc0857da7efe1bdf8abf94cfbc370671d6739c25dc24fbb9aa7eadad035dbcf7","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"35919059d72603e3b32c760fc9df68879faeb6d77a4ddb66f7cf085b4f19cc47","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"3c20daf12bb1c4ce30b0af655af51c998ff8685e9220653b468b14c925437d10","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"f2e6c262000c802eacc6c65ea418955722ac378f5bd727ed10f45361e4635340","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"2ff5d9899941f2dd9953dd8d77be6d26f70257baac590ed9b765767cbbc3ada6","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"42cfddfd5f29f5d3446ed4c7e78da2d525fec10b07cd3bbe26464ab2c3999621","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"0f76de4a5d18bd069d20b2e888e2c233e10f4b8b2b795a4cdb227ab02cfd1470","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"5b9ca08138b2e0d68e10d72a3cb378d8e00b1ce4b679518e7e88dbd6731511f9","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"305cc3b918e042e110ea875132c0afa939cd635a9e2769dd229510f298369b68","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"12be5bec6b71c761a4401aad3e1ea84762a66891bf6c1e5a2dccbedd2c5d51ac","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"2ef910159dbfc55d7139a497f3e242a75da49f73a487f667c3dc13e63c30c344","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"46eff378a9377baba7972a2962c4892ad7bb959f8150ca3b736abc4707bc8c94","signature":"8f7e35a5685c1660e4ca5cab566119a9ceec053e34429ce6384bfbc2ac66a960"},{"version":"5a4672696aa1443da08cb11dd0321bf45871b711209f4efc3d7e2342deda6810","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"021cd44edf590071a9fa8d810f086765d9fddf2fbac3197d50ca6f8dd2137b52","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"c571d567bbe7f821514eeeb521cc1d04b69640888df5e5baed961fe1576ffa8b","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"299fa3d78beb51c6236d7636bac6782898b1a3ed7fbe16dd6d01923760510a40","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"404f6783dc4d4e4341d0d0456e3ce4a2185a3ed39b1461c790562cb1a89c47c3","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"832c250c8b95c69c0ddaff84664598cf482a1ba771343290dc591d8e2cf5bd0f","signature":"8957a524e141acb098de3aae8ff58278514d6af9f8cbb756cfca4daaeca07fd6"},{"version":"b8815527bc6c6feac569cfd301932145e8fb9a84ffb1c3360481d83357bd3670","signature":"21e0ea6616fbaf6290ea32e2daf791b309bb8889c8d899b361ff62bdfa5e98d4"},{"version":"8fbcee8017afc736e446210efab3ec46eb9fee66378d970916ba46952a56f93d","signature":"dc07b7c23550735514c2d99d3f9919b290b491f7e63dbbb3e7584b1e1e211590"},{"version":"d58a66ca1542fe7379bbe37cfcca1dc51f28c37fef1ddd52d4afb0330d992c28","signature":"4b72059dda2129998e9d353e7f4c1e65b0735cf5b000e057e59ed9629642a986"},{"version":"7508bfae993e3904adefe0dc0bd4197b0ce31528c387358f77b188a990575e12","signature":"d2f924049c4926c6820c1e2a22947f6e8d11616d83bfd7d2b8f021280a7c38f0","affectsGlobalScope":true},{"version":"800e908a38b01b897f6c97d51e6cc5ce96121657b8586b2097418c759ffdff01","signature":"b1572adbe777329e648715648b6fd66f3c47101a566c3d22bdd612b51e1d8cf6"},{"version":"bd5f26b3e91a1e78612ae47940e6bd1f7af6abc798bc0249d28951c2a776f3b2","signature":"bca87735954c3a6a2111cab6daa2505d7260130a5a49eb61d237a8182773ac91"},{"version":"281f1b8015f01742403e34c7d375b91cfd2261c00e52afd97534d31a9bf48a41","signature":"c64fb79a179b42e52bb72e68985c8545d9d4924981a8cea252f995eae7523973"},{"version":"49dfb21052b496c2b1a2841955d1eca794c50f08765b0b75f5bb680c606b3028","signature":"2f87d0b64ce07503900598ac181a2b48e13a0b1a99d1b6018f29163c35fab855"},{"version":"6a527eca426de8bda3d61e598638a920aca21bc03281291e119b96c5604d0101","signature":"033239ca0b547567742ebd9809279d3e42d275da502bf36c7c6e49ee271d3295"},{"version":"0605f6c5c0b1859660e66bd7f7bbf3880d30d8fe8540966e1af6eb9109e1a8cf","signature":"c936c71802d81f1d3d7f12bad7e7cc2b9eb5cf6e3ff9a8da763c3d53c285eda5"},{"version":"d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5","impliedFormat":1},{"version":"293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec","impliedFormat":1},{"version":"833e92c058d033cde3f29a6c7603f517001d1ddd8020bc94d2067a3bc69b2a8e","impliedFormat":1},{"version":"08b2fae7b0f553ad9f79faec864b179fc58bc172e295a70943e8585dd85f600c","impliedFormat":1},{"version":"f12edf1672a94c578eca32216839604f1e1c16b40a1896198deabf99c882b340","impliedFormat":1},{"version":"e3498cf5e428e6c6b9e97bd88736f26d6cf147dedbfa5a8ad3ed8e05e059af8a","impliedFormat":1},{"version":"dba3f34531fd9b1b6e072928b6f885aa4d28dd6789cbd0e93563d43f4b62da53","impliedFormat":1},{"version":"f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c","impliedFormat":1},{"version":"e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904","impliedFormat":1},{"version":"2329d90062487e1eaca87b5e06abcbbeeecf80a82f65f949fd332cfcf824b87b","impliedFormat":1},{"version":"25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262","impliedFormat":1},{"version":"4fdb529707247a1a917a4626bfb6a293d52cd8ee57ccf03830ec91d39d606d6d","impliedFormat":1},{"version":"a9ebb67d6bbead6044b43714b50dcb77b8f7541ffe803046fdec1714c1eba206","impliedFormat":1},{"version":"5780b706cece027f0d4444fbb4e1af62dc51e19da7c3d3719f67b22b033859b9","impliedFormat":1},{"version":"5386f553d8cfd857d73d6795ebbfbd9437331e2e8623bb137a9167919c1a8c18","signature":"3b65f528e9fcba61b2a1c4562746f0fda18175cd9d14aef434b0d52dfaa515a3"},{"version":"a44c92ef8f3307c8f09108106ce854bb97e096ce4884019babd0006d4491e4c0","signature":"412f36c9047f8b16e3fb4883733c0fc9edd701f0cb982354f5fb326f25ec4cf7"},{"version":"93c6b67911fbd1751c595a82414ac661a2adbb387fec3fed2a2d00a6f044ec36","signature":"ed4e026cb4d0ac3a7a2c7fd7078316cf0c1fb26d52584a668bc814ef3fe5edbb"},{"version":"1b0e0dee17af0ed7fed10399be01b2fb69c2a059f34429019dd5a64f0eff6e4d","signature":"f39d442968ac9213b4f919d14d87a92de7197c5da5b9447c7787938d851d7e14"},"52d832ee655b32e383f12e4c32decdcae3181062bf2282e532d2f0bcff115deb",{"version":"c17c1982fd991e105684a94ca476c633d720249361ad615b6457554d7e66f048","signature":"10fe15afb45da8f916a2d24776e1c40b42bfdb526f3b1c0757f1f7a388b23469"},{"version":"59ec877da8731ab466e3d0c32ba9e6fc36e9ea6e992644fca114396cb5499c0c","signature":"d6d48dd8c671645b61436bdc7f6c91ffeba09247feb471c3070ad8fcb02a9d71"},{"version":"55297fb03bde0e225f6bc60babe6e712a991a53949a291191c2cf12d96352ce3","signature":"6ae784b75bd20e3bde706fe8110d89d7a04fcc30f63d43a11e5bda6e8bc54919"},{"version":"f340a20b5dd28000fcc56ec847e3b2c63ca7fe8df79f859cfe4ad1e222ee92f8","signature":"9216d1cd17ede3b4045656deaf1f53ed3b2e89b02815f0b94a7d8bfa98b452ea"},{"version":"c1d72c35b2b23d1c8da98e97763e2893dc4b9133b9fe87ff5b54933a86dc6efe","signature":"ee1225de733497492c9f385f3a170c4388a704130b0f4c65c166fc9354b8574b"},{"version":"b4c171cff1cb7a5a7be354346b075028b58c360ec286c97f164f5393d9b904de","signature":"55c8494ac12e83bba7037cb08656ca9019c2259338ceaafe667a73522f13e614"},{"version":"bbcab911061e9270eae91717f056ec18958ccadf23dda3a6aaedf07d24361069","signature":"2ebc90be29c541cbe65fc76ab9642c7348e8f76f04a670930d705b67cc0df910"},{"version":"867ffaf4dfa681ba867017e94f08b6d2b855b57d6f273a315e4e3829429a005a","impliedFormat":99},{"version":"3b4cdb17fbfdd1a47a80afc0076e2476a4b513c95232570decdee7dca15b9ea4","impliedFormat":99},{"version":"7348c35ce10f8323a515ea6a6c237c0dc7c3fd5e491c4dacec2a7b93f0dfcd26","impliedFormat":1},{"version":"258e67b2638408b67fbf5fe6c953b8f2dd2a69f9171d31c9a976cd2329716e4c","impliedFormat":1},{"version":"4e7499d527db754a99ce03314c79fc05ab7718bc47a232977a2eab450c803eb4","signature":"7e11dbde16492c2709847c5ef2787e27eddc0916f00849077882ec971a4884c2"},{"version":"d30e67059f5c545c5f8f0cc328a36d2e03b8c4a091b4301bc1d6afb2b1491a3a","impliedFormat":1},{"version":"a356d9a0ba90d10ec6c0fa33386c0b0e2843fd6f0a49513c44f7809926ac25ff","impliedFormat":1},{"version":"f77db1be13db8dcf8c7a268c1f235c1ba1e4707e829175cdc237421b3c346f9d","impliedFormat":99},{"version":"05cb95655ecadfd1230547b84367d1c4d40a4bb6309545b8dc4d1638a3de1a50","impliedFormat":99},{"version":"e980a9cf8404a649ff3a2f0628d76e4469613c02b98bd3b922756d7d962476c9","impliedFormat":99},{"version":"f8adbcb59256526bc69d94fb5c826e812ebd1321b30ab35baa9997d74d45dd73","impliedFormat":99},{"version":"050a4570de5ad6e47cc9ac9fd9db7a26e57dbe1daadadbc19b20567941f8bf1a","impliedFormat":99},{"version":"5ed040255a4d5181f8ecb4ba90b8b38e0a6f1becf0ed860ca75b6e52c46db0bc","impliedFormat":99},{"version":"e22a49cd604cab3b62b1968a363d7b182edcb23d46793ed12cf5cfc6b1597f39","impliedFormat":99},{"version":"ff1b4730f5d49d37b73ee2db3443145daa0bfc7ff9c865134d871b08955e389b","impliedFormat":99},{"version":"8e64b72fa289b7f133b8cdb7d837f73e30ca7eb76ad88e1020d97c405c94fd7e","impliedFormat":99},{"version":"1f907507e41cc3df66b4521b80134bb8f7afada8d31c10f7100c93c90ab0f84e","impliedFormat":99},{"version":"6bb14070b70b4c9a897a4f5088af984e6e316b420d00d82fb962bad577896723","impliedFormat":99},{"version":"46e17953f7ffbf43d4328fcb5983e0ade2932fb56e84181e6929fcdcfa7c7aa6","impliedFormat":99},{"version":"113aef5576cd65f310927b17ae5f6ac8745c542a660bace5f019034d536fbd04","impliedFormat":99},{"version":"ddf0fdbb010c94978c1151441171f0aac236a23b6786e9f6332f745527d905e9","impliedFormat":99},{"version":"a2c1678ec68c42795e2ac068a7d026b61680357d2a881c9df211dd0f83d077fd","impliedFormat":99},{"version":"2fe207d2e8662abb709772fff1f3ec3116a4787b5caa4e862daa5dab2753edd7","impliedFormat":99},{"version":"a7af5f01007f450dc8cf2cdbbb11f4d4bf8bf3faa869d21267db5de74ebf665a","impliedFormat":99},{"version":"709cb4986cbe2b58ac3bbbad45dbfa24cda1b62c794c73b96e9ff1236dd0d5d1","impliedFormat":99},{"version":"afdc9b1fd1937d9b649bca2b377d1144cc9c48158403c17cfd21b6e1e8b25099","impliedFormat":99},{"version":"1d47324801b498d62f31ea179f58e1f3eaa1e607914504a7c92fb5465affb851","impliedFormat":99},{"version":"95fdf978302838125ac79d9d5e9485d8fa1ddd909664bf5cc3b45ec31f794fda","impliedFormat":99},{"version":"d92bf7d6d30c85e53b961236ceeb099e73a1a874849d038a348b51383087872f","impliedFormat":99},{"version":"e56e4a57ca5aa762d67fd3d16471c47592469944315fa5e92b3b09c83eabae91","impliedFormat":99},{"version":"f3d31927b7a3d0f2f119a05a102af2bdd1fc4f759fe43d508a64a80b3b341f6b","impliedFormat":99},{"version":"678700fba88589e28648a923e4b98ab60f3f7df4742412419e29f95966da4475","impliedFormat":99},{"version":"5a71b307074ef3d2794c4104248b7a3cad5f486df204da65862a7d24f698fc95","impliedFormat":99},{"version":"9a4496ad6d48bc801a122c11e94ee1e3f0710bda38b125573f67f5cb0add1733","impliedFormat":99},{"version":"afa5e16f2ad07d847701e3bde9e7ab36f87e0e3a5c0cb7998644791a1fa3c5b1","impliedFormat":99},{"version":"98cd9124b5d8438db4b4dbd247b2c68ac22b6366a43e6dc4945ae32972f157fc","impliedFormat":99},{"version":"dc21879e45f3a023b5fe459c3da5f2f3cf995f21a1ac533049d8950ce394c045","impliedFormat":99},{"version":"622d6ce66ac838d5d7e968daf4ae760cf49797e3fbfaa2b21d01e0fb5d625bc9","impliedFormat":99},{"version":"ecfa30418b2200ba6496b5f59b4c09a95cce9ea37c1daaf5a5db9bb306ee038f","impliedFormat":99},{"version":"3b56e30b1cbe1bfa7710e88e5e0b8fa6eddc7c2e67615f73bdf8637af68403e6","impliedFormat":99},{"version":"92a8de4f8f6595bf1eb24a19aebff7371c66ae8751f2e045edd9e25ca435e4a2","impliedFormat":99},{"version":"01810afb0ed31afdea0846cee91e85a474727d0966e5bb57c2a4a732854deab1","impliedFormat":99},{"version":"c874e98cd875727ea62fdcd978ac9e067ce07cf7493aa4b8b193fdc3b7318eea","impliedFormat":99},{"version":"455e843c1f8e0df452f101c9ec0b63ab8e749f296c947249f8bbc29bff58c83c","impliedFormat":99},{"version":"eadc4c556b494cc52676e084eadf0b60fb2cc6e2408d1411eeae5cb74068ca86","impliedFormat":99},{"version":"3b8689266e8fb628ca2068ff610ed0b842ff4e407c3a914358ef1895dabfcfcd","impliedFormat":99},{"version":"fc741907f6d8158b2c4722932d745b11dd41f9355a5b325c8cd3cdfbd966d76d","impliedFormat":99},{"version":"f02f6b8fbbdc89d49e1a9ee72637da79ef8eab3dfd9e6cb65ff8638ff1ec77d6","signature":"d599d9527fe728669a340a929e6906ac3278b0dfeba86f83e0d1305e86399953"},{"version":"ec7d496e3b75eba41e799f0b107bad386fee47b40ef6cc692fe761e4db6d2920","signature":"e3b56ef4324017ea361c5ca7a2a2be047a328f59fd468d130cafba6fdf85277e"},{"version":"d8ce914ac4220296d4caba6f5b21f64a76ef6338d18ba797ad0daa005c5e84ab","signature":"8dc6327752d27f23c40c92a9df6af9239a202420186bec3b0b43c781bd0ca72b"},{"version":"95bc1caf051f0108d82613d321fac207b7c90257ec47a17af02b1b5c041e476a","signature":"a787f74d922b0513dcc8d147a51d88680d18ffbbf5f3003a6f20d855d5e30ccf"},{"version":"2a8f701d2e0dd71e26c6bc977d41f0ee27998d854ed44de1bf96185444ffaf5a","signature":"942539020cddf81c84e7b509ddb926a4c7d9d0d165a3f66787bde03653de93c4"},{"version":"a9b182cfd5630aaa758967e554d053d860ca46b5354112a64de6cf6d3cc9cb1b","signature":"e0818a693313ecb1f8347f651f4c2421e29e254b4be78536a2b77b1f66422b0e"},{"version":"c6bc7d689983cb50ecd6b150a4cc981c749582495d61a377c1daf67bad0b1fdc","signature":"1ca69f9594e5be4fa3889bae939f1f56ff16d58ba2464d043a0bb05b811a6496"},{"version":"eeaffe11de59d3d953d67e4bad602225dc17d438a143c830941b381b0f31b92b","signature":"23f1e872ea12ad813b02e0a65850fe5e5a82ad94e4f845d7f4f5d52ed1e0b659"},{"version":"26887f3ab3a1eb328d11447084b4685db52479dcf14483c78de39cb468dad8f4","signature":"8760f213975576ac5d1f992e9ed7556dc1cbe98668c25c82c1333131a3426d97"},{"version":"89117734569e72bbaa763b47d044e9de0a22ac922d106e463ade5c05716c2667","signature":"201fc8aae0c45af346dfdfca74450da8c102941ecdc7e88024ed0f77829be2b5"},{"version":"29bad200a336e08e60eacc6916713f2a66f710d6d8856f4424d02a215f2441a5","signature":"d7c89d45d1a4a88d5e93b1340279640fc5a163448b7f88f891eceacb1e3fee73"},{"version":"29d0b210ed29897704eb30e3359d8d7945965bfaa0708b984af0de0bd134b383","signature":"fba21335cc80f941ce726de79be012a5433025205bd1a83f758ebd13509ba747"},"8e8a5977d319aee4e8818dd2a42e814da03241925788f882dc877b50a3d3c8fd",{"version":"20e4e3b0044a0d80fb832c15d0bf9a6527ce39bab932f7bd36aed98e1e8929a3","signature":"33cb435bbb963099599d932891bd52e2cdc36fb87fd5d1ec8d06408f3d439411"},{"version":"02421f946386db15eac224c9749b4555b0863e01f6b814ec39b9467d6b8ee143","signature":"9b06157564c72a8c906a51a19ffb145386073ec1077f455e965eaf3d0b0ae86d"},{"version":"129004d727abf8ea0a325b059733fa0386afcfd15fa82d22afb0ce7096427447","signature":"7a88a480dd27358afbfe59e35bbb7b29f48174bd1df10bcd727812f12d503d06"},{"version":"7cfb3608b819b5241712bcfbd725c14559a4ca38cb4a871f5233f4ebba8fa37a","signature":"c0dcd712576d3bc49ad21cd18f44ab1bdd715aadb88637a95918df87139aece5"},{"version":"51e29a33ca44d736c6779c74700e8a9e84e9ebc49bdabeea9e8ff4cbe39d8785","signature":"528b4ea3d5ab085935d10bcdf3f2ec329295b386fc65ee01254e78e829156736"},{"version":"0a98b71bc404dea34eb1ce91a3b1a3bee63b60a70a79a707b8d1bd6a49bd4e70","signature":"b6b35aed3f1fbd250d295fd4488636aa67a753f26002f65013d81ea014c9bb1d"},{"version":"0c938a37d2ff776eeb0ead9d9c2c88001458514c15fb51a937c2df5fb33c9dcc","signature":"d8786120809808cca09436e3d8333df65d0554a1c8a2de41724df4ddb6f0b40e"},{"version":"02deace99de94352d67163f772aca8ec437c4458abe9dcf27ea01036d1191e90","signature":"02190ac5b6561caaa077b8ae612e157bed35270a21a167a8e089bc800948bc46"},{"version":"322264e06a1e7cdd7d4a0f5cc67481a4586ba3366fa2552a05d2635c617161aa","signature":"6d325a034d923001e779611d5eeafb389d0ebab9bf3c235c0f7d5229e32ecfe6"},{"version":"d0246b0b4ce4335272e4b9f994c6bdc8c6d7b524596dcee9409e6646d950bef5","signature":"e8be897f2181c72fc43e68b4fdb0b5921bf8f109b51ffbdc425bf22e1552bd67"},{"version":"79eef0dfb2928fff7190b0a57f0c26bf65b51d5738aead64f1316ba02677b22f","signature":"49d7babcfa5400f763daca36b56c8600bcffe0068733996161399c3cd63087b8"},{"version":"4acb9b22e71eedaadd2a476eccd1b2765cefa8299af4eafe3c681a1709202de5","signature":"c5272d6f5f6548eb361d26d7d270d3c42b970ff09b41e98d384bbc377c83d7da"},{"version":"ceb953d1d59badaeb16dc95cbfbc7a933b8bc0bfe7c9a35f66d62bb147e95a04","signature":"87dba6f4d3e6f91fcee310efe847fc5bb8cbd74cdb61ff2431538cfb109351bc"},{"version":"5b712377341a58aef517e08ab8d22e1979063edb661faff89fcc012eeb026593","signature":"5a1d32f4a4bc0e1a6004c304bafc21005e398bbdd9073c09e6a78be04eed617c"},"c4633b2b9b96a2537bc2d50258d5a3fdf9fe2cb58cf7b5c500575b430ae35e6e",{"version":"0d743949af0ead4e05395ad20eddd2d8d4bc008490664e6422faea1ddc0184a8","signature":"3465eb5e2915bc4da3954db4508a5b1ec723610d39a1e7e8bca96ce0e8eb5a50"},{"version":"b4e2ea8800ce3495ed92b29926754559ebd6214bd6a52b60e6c48bfcff54841d","signature":"94b36666994ce3b351e7fdd8e9d06834101f9d962330d7c3b3fb61458aa94b24"},{"version":"08268019a80c41d45a93421cf0cec230745a5d521c9711c36c7d5b2716d6b21f","signature":"f198b71543c2d6ca21b5b30f1ff2ed2df5fb51aca238ded35cec059605a9017f"},{"version":"01bdf32f8e861750260220a481473feedf1b277ecc90592640e5339821000ee5","signature":"bdbac09a2009d84f452e9a6f739c1a5bfc19b8d314c2080ea9ac923965727482"},{"version":"bc12ebcdd075f050708d6f2a3e72af62fcdc7db36a00315856d05d1529d029d7","signature":"e118c59f4273eed41c42a4f3dcefc8993471d066881f70fedfd0a2f326064d28"},{"version":"5d38ac9b5601be78775b3ee33ca29cdabde0a1f44fbf50288e6b16819e8f6ff6","signature":"168b5e664817649ec794d1b81961d200a6e2d74172307b7535f8002d2e6ced5e"},"fa0cc5a8459f5894cb84e726f4f2f905f575deb7edba12d4b03bcc87b2777b25",{"version":"b65affd17c63b8df0cd88df50c676630856cb9419506d6e8ad6b9c472218b3fd","signature":"bb6d78bb20e9ff51e0323c7f720ad2ceb00722f75b0cbd5c6924cbe753fc6098"},{"version":"0dbcce69f53475a863292775541b9daf02b2117b00a134bc87a16325186fd74b","signature":"326d61020a72de88e1a43e087d8794fa9e1a8b153a48c17c8c211cd6f2441199"},{"version":"c47195851d99c28d7c7de2f177855e88632a70d427839f9f0e76eb4b43381ebf","signature":"453347f0549e2596d8830f060a1d9762ef3655f59e8ce1810db6e1f1a6539e16"},{"version":"699330737a897e0b5e9198cf57063427e9015a4d192f1776fd271c946c831b35","signature":"33cb435bbb963099599d932891bd52e2cdc36fb87fd5d1ec8d06408f3d439411"},"5b04c445e4c6fbc443390c034d504b5b19e03c36405129abbcff53402d5092ca",{"version":"5214eeff610784f968b3b2f6e585fbe9afd9b682518094715379df7ddcfa38c6","signature":"71f397c7ee37e9585c00f86cedd67652a4a9dd75ec8ae014749c43179046c957"},{"version":"d0fe58adffd63f57dc249731f9117441bf03151cd9db56139f649e28267ceb41","signature":"e6c83d44fa6963e66f3f32a8a4326caa27fdbcae36660b9aab858f404224ce9a"},{"version":"633084d1e8d67746cbea7e714be12bce431a22af1634ead9e9e4b9d892412a2a","signature":"d4604139da5a646d3064fc1af517b53d8a7e0f56d601068e1cb1e10ac08a7c7b"},{"version":"6d3d489bb63e439076dc9fae2b4472f0ab5af14475c0bf3bc3a3fb3fe4ba6897","signature":"c68ca2cd04e166875af59a95c177e394c160a837b381e75ffa723d538a26af59"},{"version":"ba0b6f58dffe50aedd786558b5d211b3ac4115a3b10715792493976ee2bf6a6a","signature":"cf5c00cd069e8de4629d263bb778af154ae65d9cfefb6a2ad890d172226638ee"},{"version":"2aa0a71ce2039eebf56a9a8bcc628c36e4fbe6e4a5f16c9b642e3764d58db5d9","signature":"14769dd9783aab66846fc2cba4b2f98f6c01e8f550984e69e6deb20f87d72bfc"},{"version":"322d3de79a877a5afe0ad157b25387eb5dfddc6b27deabefc640f3673cf11b81","signature":"5c6189899922cdf9fc28896281e48e377b20302c425f79358034b932a3cdd135"},{"version":"31b6d48fa9137016f6ba40b5d77ef523adfbe2c06759587cb3248223f34aca04","signature":"91850f31f1ef37fc84d2ca1196972ad1a2374b9bbc69fe4851c45a16b14e05c1"},{"version":"7d60898192cc30e9485fe7c10b0bd999d9b25877c033ed9a3c3b8df59b561ced","signature":"49d33d2535316773b5e298d95f1746af38d28c9634f8a1c7ad2b9dbabccc134a"},{"version":"ff1f2f5f6959f17a010707e6a34b5f69b74aa01bc6ac8a19959277d979eb2d19","signature":"5f4d8f19e9c9bd7466b0fc8647f1258a34d7190072f1028b4ed45162c3ed65de"},{"version":"b24406d22057b60f699077f33fd9a56d77be167d1459c01792d3e2be356e7bd5","signature":"23308a832675e13759bb7fb6f5076eefb14ec973fe81f7f5a9ca2f5473a38c2a"},{"version":"aca1ebad62dc5cfe2fa78dbd9feadccf3b2b4116f81f4bfef60799440a05189b","signature":"93ec11aec11e86ec672bd3e1f505c8935f726981649a15a55d1c42b0d32cf450"},{"version":"e78a8c1a4782b7762e8fef35a0dc6e1bd828d7d09250beb884b4447b94124403","signature":"cf1e99d874b134fd0352d3c27a758dc587952c0789bb32518700b3ab5d82702f"},{"version":"a8864dce614d296a70cf9ce92871fb83af32dad7206951c3aa2a7bb19b92d05b","signature":"899d39de253c2093b1b353332b73a346cb9dd611c02e191ee7cd75b5248c9b23"},{"version":"a0719513cef0a1b870e1b89207f9e8ee151f4a652e62d2773271260df46f25b3","signature":"09c2ffcba11523e17e1f1b5202d1ae4cafe8829fa012cf76c918760ec8d2820c"},"fc86b52b4817d9537001e3713b87c9e48fb605d463b67202395145a0bb4f6eda",{"version":"6bcb6ca231b634db6ee373853de473fb48e66d02d3aa715c72b9954f5cb95444","signature":"e8dd5865e393b353cb702032fb1b4833213826b567693bd80e0b1bfe29f838e3"},{"version":"dbed7fa8a8911582a28ce812c27f01a46e538026807a8e6f05459071178fc419","signature":"f51ef369237cf4998a2daeb0514308199108dd58439a1adc4a556bc43ef774b6"},{"version":"87c1e86775e70f450379902223fe76a14310085d87e7d174ea63c47c016eb528","signature":"8069acc4f4a0e51b4ba4e2b97b4f9e5f08e0e688c47550022e4767783c677236"},{"version":"6a1eb6caafbf183cffc6861e12715c31196ca2dce6be0f472ed7317c07f6a72c","signature":"fd70f95d4f6d14b901e0990aeb233c171c964a32dca3dd2ce203150e9d988e2d"},{"version":"f85ac0f7afb5f7bd43b3aaac3519160c17e98142dc456702ec73051984428fe1","signature":"b8ffb487271cf922f23007a94fff2ef75d5aa4bba52e60b2359f4d717f39c6e6"},{"version":"1b11353e35bead11fbb0b28acf48e6837f75ad5101bda508cc1382f0d7eddfaf","signature":"63762cf7c5a4bc992fc4e1c92beb2a3c0804fceb4f26a6aaeee26b6fb3cd0ff6"},{"version":"d04dd4996c270997a1c001c6d0bea9cf573b1c0922a7b0c724c48393605ef4c0","signature":"7d54d69bd28258dcf401dcfabc79979e51be0c0acb07af00e6e5e0b75004584a"},{"version":"2c4c73d885472ba155ba4ed58a0cc032702faee5ac3efd2edcf9ea7df96e2571","signature":"edba6f71e1165dc5b906d32970a92e520c8b2f736732149ce518210d4e2f38cf"},{"version":"119af9d3785040ba2547949250d5ebe2eca3fd7a38947e7c721fef3bbda1f41e","signature":"91a607dcd863a5561f3469e9ad860c5c6fdc7f0bce10064975fd548ac77a50f0"},{"version":"538945c2b30a6f03f261cc56fb8c55fcf58962acbb3acb8e685c5ead0b673862","signature":"95e7a65b4f3ddfc309da0c7faf04e5415651c11fcea71e06ee51c45e465e0531"},{"version":"0429b03cad5d8a34e5b938dd64a42ac70b0ffd36bb6f8bc4a9a0db867db0ced8","signature":"f11eae824171ed229af1b3968022a15822b0d289aaf5a6e946f58a1f551164c8"},{"version":"b9d678379f5902f2dd7e387c5d31318fea876ca5698aa7e94faab89a4af5a741","signature":"fac4424979a581f8401d2ad663c26608f020d34f7eb7a4f1d621134475807707"},{"version":"10eedbf59e4d03631c4b16673b4ecff5740d12e71c70856ccf09d232c41655ce","signature":"094e86d221c7161cd827f74d09d3e0e8249b496579304b654ab4af86300a2ace"},{"version":"e631010e8454319bfaae18f2d79b57ebcc47f3538093cb33c2fa27eda4fc2aed","signature":"17473aec06661b772ea0188799389b35164eb6c89201c022aba1236d5873c1a2"},{"version":"c73ef3325a265eeac1337bd48d53f74a41c64431d870f3a305e72594cddbec87","signature":"5122b135b46cd68c8af61390cdd353cefeb94ab5947051f573cd2fe23a678114"},{"version":"a0de3831be31885600f78cf3792fb60253707e83d301f4cd9c1483924d2e77d3","signature":"a8d9b35f388f270c7e9cecfb998193c125239ce4e02873609d44abab37ea5bff"},{"version":"fa100623257bb4539418a67948200bd9ec6501d509e1e907a464b28cfa26c5bc","signature":"7ada08b5f22595664b83d1a7a4cf71cdc8faeb52607be9aee72f98e984e0b523"},{"version":"b299134195921f117a48b62b1f4f4032f3b58e3eb8696929efe82bd18ed184d6","signature":"3218c9afa277985d0eaf4cd2a740a827d3e12386c94d391d3c68bb3bb104e967"},{"version":"00c4e82c186815f5342903e73281083d68a6a719be28c59e3555614905d87ecd","signature":"4eeb6006b5691f72f855271e66abd6863cad219c16f371a681485256cf1dbb61"},"2e4f047d100df1f307f688253d3713d813a25014effb5e14c6184e5237b7968c","7dd579b8847f127d81c00a432513cb865aa334e08fcaaa2568698685e2dd4ffe",{"version":"37972d4b904d2cd304f9bf53d2d7ebff3601916a2913db3d8c4220f17496a35a","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"18ae7379f6c8d6e432d5e3ecfd64b8012debe5e0f71a8ea5c71e9935e56ce6d1","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"9cd05da82a256f180a52eb6c79c4848577642ce52b43a0d83227f98be2b7e77e","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"abff2da6c0f0d285bcffcd63ce0cc4781d40b5345a3c201a29c488eafd24c553","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},"11ed1a4be020d725fcf72fbfbed7f5a6ec248457cc31cdd0060c3e1d47bb4062",{"version":"da57a133d7e363ad3a1ebc9c588165def07682533781661dac8a95914f6b268a","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"14fbe1efaacc1d48b297759861edb693e121c649013e1e00a676f7fbf2e7639a","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},"d41cd59ba669cdce5037c392c03b6ec948e46dc0b17cf04c12f9bf9dd5906c5d"],"root":[409,434,[443,447],[449,479],[494,505],510,[553,637]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[630,1],[632,2],[631,3],[633,4],[628,5],[634,6],[629,7],[635,8],[636,9],[637,10],[409,11],[362,12],[515,13],[521,14],[523,15],[516,16],[524,17],[522,18],[525,12],[517,19],[518,17],[526,20],[527,13],[530,21],[519,22],[528,23],[529,24],[520,25],[511,12],[512,26],[142,27],[143,27],[144,28],[99,29],[145,30],[146,31],[147,32],[94,12],[97,33],[95,12],[96,12],[148,34],[149,35],[150,36],[151,37],[152,38],[153,39],[154,39],[155,40],[156,41],[157,42],[158,43],[100,12],[98,12],[159,44],[160,45],[161,46],[193,47],[162,48],[163,49],[164,50],[165,51],[166,52],[167,53],[168,54],[169,55],[170,56],[171,57],[172,57],[173,58],[174,12],[175,59],[177,60],[176,61],[178,62],[179,63],[180,64],[181,65],[182,66],[183,67],[184,68],[185,69],[186,70],[187,71],[188,72],[189,73],[190,74],[101,12],[102,12],[103,12],[141,75],[191,76],[192,77],[442,78],[441,79],[440,78],[86,12],[198,80],[199,81],[197,82],[195,83],[196,84],[84,12],[87,85],[286,82],[85,12],[93,86],[365,87],[369,88],[371,89],[219,90],[233,91],[336,92],[265,12],[339,93],[301,94],[309,95],[337,96],[220,97],[264,12],[266,98],[338,99],[240,100],[221,101],[245,100],[234,100],[204,100],[292,102],[293,103],[209,12],[289,104],[294,105],[380,106],[287,105],[381,107],[271,12],[290,108],[393,109],[392,110],[296,105],[391,12],[389,12],[390,111],[291,82],[278,112],[279,113],[288,114],[304,115],[305,116],[295,117],[273,118],[274,119],[384,120],[387,121],[252,122],[251,123],[250,124],[396,82],[249,125],[225,12],[399,12],[402,12],[506,126],[507,127],[401,82],[403,128],[200,12],[330,12],[232,129],[202,130],[353,12],[354,12],[356,12],[359,131],[355,12],[357,132],[358,132],[218,12],[231,12],[364,133],[372,134],[376,135],[214,136],[281,137],[280,12],[272,118],[300,138],[298,139],[297,12],[299,12],[303,140],[276,141],[213,142],[238,143],[327,144],[205,145],[212,146],[201,92],[341,147],[351,148],[340,12],[350,149],[239,12],[223,150],[318,151],[317,12],[324,152],[326,153],[319,154],[323,155],[325,152],[322,154],[321,152],[320,154],[261,156],[246,156],[312,157],[247,157],[207,158],[206,12],[316,159],[315,160],[314,161],[313,162],[208,163],[285,164],[302,165],[284,166],[308,167],[310,168],[307,166],[241,163],[194,12],[328,169],[267,170],[349,171],[270,172],[344,173],[211,12],[345,174],[347,175],[348,176],[331,12],[343,145],[243,177],[329,178],[352,179],[215,12],[217,12],[508,180],[222,181],[311,182],[210,183],[216,12],[269,184],[268,185],[224,186],[277,187],[275,188],[226,189],[228,190],[400,12],[227,191],[229,192],[367,12],[366,12],[368,12],[398,12],[230,193],[283,82],[92,12],[306,194],[253,12],[263,195],[242,12],[374,82],[383,196],[260,82],[378,105],[259,197],[361,198],[258,196],[203,12],[385,199],[256,82],[257,82],[248,12],[262,12],[255,200],[254,201],[244,202],[237,117],[346,12],[236,203],[235,12],[370,12],[282,82],[363,204],[83,12],[91,205],[88,82],[89,12],[90,12],[342,206],[335,207],[334,12],[333,208],[332,12],[373,209],[375,210],[377,211],[379,212],[382,213],[408,214],[386,214],[407,215],[388,216],[394,217],[509,218],[395,219],[397,220],[404,221],[406,12],[405,222],[360,223],[439,224],[436,222],[438,225],[437,12],[435,12],[426,226],[424,227],[425,228],[413,229],[414,227],[421,230],[412,231],[417,232],[427,12],[418,233],[423,234],[429,235],[428,236],[411,237],[419,238],[420,239],[415,240],[422,226],[416,241],[514,16],[531,242],[532,242],[534,243],[535,244],[513,13],[536,242],[552,245],[533,242],[537,16],[538,16],[539,242],[540,82],[541,242],[542,246],[543,242],[544,242],[545,16],[546,242],[547,242],[548,242],[549,242],[550,242],[551,16],[448,12],[410,12],[432,247],[431,12],[430,12],[433,248],[81,12],[82,12],[13,12],[14,12],[16,12],[15,12],[2,12],[17,12],[18,12],[19,12],[20,12],[21,12],[22,12],[23,12],[24,12],[3,12],[25,12],[26,12],[4,12],[27,12],[31,12],[28,12],[29,12],[30,12],[32,12],[33,12],[34,12],[5,12],[35,12],[36,12],[37,12],[38,12],[6,12],[42,12],[39,12],[40,12],[41,12],[43,12],[7,12],[44,12],[49,12],[50,12],[45,12],[46,12],[47,12],[48,12],[8,12],[54,12],[51,12],[52,12],[53,12],[55,12],[9,12],[56,12],[57,12],[58,12],[60,12],[59,12],[61,12],[62,12],[10,12],[63,12],[64,12],[65,12],[11,12],[66,12],[67,12],[68,12],[69,12],[70,12],[1,12],[71,12],[72,12],[12,12],[76,12],[74,12],[79,12],[78,12],[73,12],[77,12],[75,12],[80,12],[119,249],[129,250],[118,249],[139,251],[110,252],[109,253],[138,222],[132,254],[137,255],[112,256],[126,257],[111,258],[135,259],[107,260],[106,222],[136,261],[108,262],[113,263],[114,12],[117,263],[104,12],[140,264],[130,265],[121,266],[122,267],[124,268],[120,269],[123,270],[133,222],[115,271],[116,272],[125,273],[105,274],[128,265],[127,263],[131,12],[134,275],[493,276],[485,277],[492,278],[487,12],[488,12],[486,279],[489,280],[480,12],[481,12],[482,276],[484,281],[490,12],[491,282],[483,283],[443,284],[444,284],[445,284],[446,284],[447,284],[449,285],[450,284],[451,286],[452,284],[453,284],[454,284],[455,284],[456,284],[457,287],[458,285],[459,284],[460,284],[461,284],[462,288],[463,284],[464,284],[465,284],[466,284],[467,284],[468,289],[565,290],[566,291],[582,292],[585,293],[583,294],[584,293],[586,293],[475,295],[476,296],[478,297],[479,298],[494,299],[495,295],[496,295],[497,300],[587,301],[588,302],[589,303],[590,302],[591,291],[592,304],[594,305],[595,306],[597,307],[599,308],[504,309],[600,310],[601,311],[602,312],[603,313],[604,314],[505,315],[510,316],[562,317],[607,318],[608,301],[611,319],[610,82],[609,320],[614,321],[612,322],[472,323],[615,324],[474,325],[618,326],[617,327],[616,322],[619,322],[623,328],[621,329],[624,330],[498,331],[567,332],[581,333],[563,315],[572,334],[622,293],[570,12],[580,335],[574,288],[569,336],[558,337],[559,338],[503,12],[502,327],[560,339],[625,340],[626,340],[627,340],[553,341],[554,342],[578,12],[598,82],[555,343],[577,344],[613,12],[557,345],[606,346],[593,12],[571,347],[579,12],[556,12],[596,348],[605,315],[573,12],[561,335],[575,12],[576,82],[620,349],[564,12],[568,336],[501,350],[473,284],[471,12],[477,332],[499,332],[500,288],[470,351],[469,296],[434,352]],"semanticDiagnosticsPerFile":[[451,[{"start":2390,"length":1,"code":2345,"category":1,"messageText":{"messageText":"Argument of type '(value: unknown) => void' is not assignable to parameter of type '() => void'.","category":1,"code":2345,"next":[{"messageText":"Target signature provides too few arguments. Expected 1 or more, but got 0.","category":1,"code":2849}]}}]],[457,[{"start":2451,"length":1,"code":2345,"category":1,"messageText":{"messageText":"Argument of type '(value: unknown) => void' is not assignable to parameter of type '() => void'.","category":1,"code":2345,"next":[{"messageText":"Target signature provides too few arguments. Expected 1 or more, but got 0.","category":1,"code":2849}]}}]],[606,[{"start":63,"length":10,"messageText":"Module '\"@/lib/db\"' has no exported member 'RealPermit'.","category":1,"code":2305},{"start":75,"length":19,"messageText":"Module '\"@/lib/db\"' has no exported member 'CodeEnforcementCase'.","category":1,"code":2305},{"start":96,"length":18,"messageText":"Module '\"@/lib/db\"' has no exported member 'NewsAddressMention'.","category":1,"code":2305}]],[607,[{"start":359,"length":10,"messageText":"Module '\"@/lib/db\"' has no exported member 'RealPermit'.","category":1,"code":2305},{"start":371,"length":19,"messageText":"Module '\"@/lib/db\"' has no exported member 'CodeEnforcementCase'.","category":1,"code":2305}]],[625,[{"start":6606,"length":1,"messageText":"Parameter 'c' implicitly has an 'any' type.","category":1,"code":7006}]],[626,[{"start":4115,"length":1,"messageText":"Parameter 'h' implicitly has an 'any' type.","category":1,"code":7006},{"start":6023,"length":1,"messageText":"Parameter 'a' implicitly has an 'any' type.","category":1,"code":7006},{"start":6026,"length":1,"messageText":"Parameter 'i' implicitly has an 'any' type.","category":1,"code":7006}]]],"affectedFilesPendingEmit":[630,632,631,633,628,634,629,635,636,637,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,565,566,582,585,583,584,586,475,476,478,479,494,495,496,497,587,588,589,590,591,592,594,595,597,599,504,600,601,602,603,604,505,510,562,607,608,611,610,609,614,612,472,615,474,618,617,616,619,623,621,624,498,567,581,563,572,622,570,580,574,569,558,559,503,502,560,625,626,627,553,554,578,598,555,577,613,557,606,593,571,579,556,596,605,573,561,575,576,620,564,568,501,473,471,477,499,500,470,469,434],"version":"5.9.3"}
\ No newline at end of file

(oldest)  ·  back to Stayclaim  ·  tighten .gitignore: add missing standing-rule patterns (tmp/ e6cf6eb →