[object Object]

← back to Small Business Builder

snapshot: 66 file(s) changed, +54 new, ~12 modified

dadcde3e64b793382fcea42da1a741ee1be4c838 · 2026-05-13 08:58:02 -0700 · Steve

Files touched

Diff

commit dadcde3e64b793382fcea42da1a741ee1be4c838
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:58:02 2026 -0700

    snapshot: 66 file(s) changed, +54 new, ~12 modified
---
 _qa/2026-05-05-builds/01-initial.png          |  Bin 0 -> 129600 bytes
 _qa/2026-05-05-builds/02-filter-morning.png   |  Bin 0 -> 93644 bytes
 _qa/2026-05-05-builds/03-after-reset.png      |  Bin 0 -> 126940 bytes
 _qa/2026-05-05-builds/04-top-chip.png         |  Bin 0 -> 121483 bytes
 _qa/2026-05-05-builds/05-stale-chip.png       |  Bin 0 -> 99009 bytes
 _qa/2026-05-05-builds/06-drawer-open.png      |  Bin 0 -> 157106 bytes
 _qa/2026-05-05-builds/07-drawer-after-dd.png  |  Bin 0 -> 157184 bytes
 _qa/2026-05-05-builds/08-drawer-next.png      |  Bin 0 -> 160138 bytes
 _qa/2026-05-05-builds/08-drawer-no-dd-btn.png |  Bin 0 -> 88089 bytes
 _qa/2026-05-05-builds/09-drawer-closed.png    |  Bin 0 -> 129169 bytes
 _qa/2026-05-05-builds/09-drawer-next.png      |  Bin 0 -> 87875 bytes
 _qa/2026-05-05-builds/10-drawer-closed.png    |  Bin 0 -> 127895 bytes
 _qa/2026-05-05-builds/findings.json           |   76 +
 _qa/ui-1-entry.png                            |  Bin 0 -> 548826 bytes
 _qa/ui-2-wall.png                             |  Bin 0 -> 1986538 bytes
 _qa/ui-3-grades.png                           |  Bin 0 -> 875895 bytes
 _qa/ui-3-leaderboard.png                      |  Bin 0 -> 224202 bytes
 _qa/ui-4-wa-detail.png                        |  Bin 0 -> 996681 bytes
 _qa/ui-5-mockup-brutalist.png                 |  Bin 0 -> 310864 bytes
 _qa/ui-entry-dark-final.png                   |  Bin 0 -> 546869 bytes
 _qa/ui-entry-dark-fixed.png                   |  Bin 0 -> 537747 bytes
 _qa/ui-entry-dark.png                         |  Bin 0 -> 524323 bytes
 _qa/ui-entry-light.png                        |  Bin 0 -> 548689 bytes
 data/wa-screenshots/retrowalls-com.png        |  Bin 0 -> 1114733 bytes
 design/figma/website-analysis-brutalist.md    |  180 ++
 package-lock.json                             |  268 ++
 package.json                                  |    3 +
 public/favicon.svg                            |    4 +
 scripts/audit-public-sites.mjs                |  262 ++
 scripts/backfill-la-neighborhoods.js          |  234 ++
 scripts/build-all-analyses.mjs                |  203 ++
 scripts/bulk-draft-interviews.js              |  100 +
 scripts/data-hound-census.js                  |  100 +
 scripts/data-hound-press.js                   |  173 ++
 scripts/data-hound-social.js                  |  119 +
 scripts/enrich-copy-local.mjs                 |  124 +
 scripts/enrich-rescue.mjs                     |  116 +
 scripts/flag-sole-prop-barbers.js             |  131 +
 scripts/generate-roasts.mjs                   |  112 +
 scripts/geocode-via-nominatim.js              |  141 +
 scripts/ingest-bbcb-salons.js                 |  234 ++
 scripts/ingest-hollywood-corridor.js          |  308 ++
 scripts/ingest-osm-salons.js                  |  249 ++
 scripts/ingest-sunset-corridor.js             |  314 ++
 scripts/ingest-ventura-corridor.js            |  276 ++
 scripts/ingest-wilshire-corridor.js           |  327 ++
 scripts/loop-tick.sh                          |   20 +
 scripts/overnight-loop.sh                     |   26 +
 scripts/rerender-url-mockups.mjs              |   70 +
 src/lib/audit-grade.js                        |   54 +
 src/lib/builds-index.js                       |  238 ++
 src/lib/deep-dive.js                          |   95 +
 src/lib/escape.js                             |   36 +
 src/lib/ollama-draft.js                       |  183 ++
 src/lib/svg-hero.js                           |   88 +
 src/lib/vertical.js                           |   42 +
 src/render/builds-index.js                    | 1144 +++++++
 src/render/category.js                        |  285 ++
 src/render/corridor-map.js                    |  416 +++
 src/render/corridor.js                        |  404 +++
 src/render/edit.js                            |    4 +-
 src/render/fresh.js                           |  150 +
 src/render/grid.js                            |   13 +-
 src/render/home.js                            |   92 +-
 src/render/interview-live.js                  |  515 ++++
 src/render/map.js                             |  546 ++++
 src/render/near.js                            |  287 ++
 src/render/neighborhoods.js                   |  165 ++
 src/render/profile.js                         |  156 +-
 src/render/proprietors.js                     |  156 +
 src/render/templates/_helpers.js              |   10 +-
 src/render/templates/template-1.js            |    7 +-
 src/render/templates/template-2.js            |    6 +-
 src/render/wall.js                            |  281 ++
 src/render/website-analysis-themes.js         |  581 ++++
 src/render/website-analysis.js                |  480 +++
 src/scrape/website_meta.js                    |  113 +-
 src/server/index.js                           | 3933 ++++++++++++++++++++++++-
 78 files changed, 14602 insertions(+), 48 deletions(-)

diff --git a/_qa/2026-05-05-builds/01-initial.png b/_qa/2026-05-05-builds/01-initial.png
new file mode 100644
index 0000000..c03d09e
Binary files /dev/null and b/_qa/2026-05-05-builds/01-initial.png differ
diff --git a/_qa/2026-05-05-builds/02-filter-morning.png b/_qa/2026-05-05-builds/02-filter-morning.png
new file mode 100644
index 0000000..644a9ce
Binary files /dev/null and b/_qa/2026-05-05-builds/02-filter-morning.png differ
diff --git a/_qa/2026-05-05-builds/03-after-reset.png b/_qa/2026-05-05-builds/03-after-reset.png
new file mode 100644
index 0000000..2578165
Binary files /dev/null and b/_qa/2026-05-05-builds/03-after-reset.png differ
diff --git a/_qa/2026-05-05-builds/04-top-chip.png b/_qa/2026-05-05-builds/04-top-chip.png
new file mode 100644
index 0000000..8bfdf9a
Binary files /dev/null and b/_qa/2026-05-05-builds/04-top-chip.png differ
diff --git a/_qa/2026-05-05-builds/05-stale-chip.png b/_qa/2026-05-05-builds/05-stale-chip.png
new file mode 100644
index 0000000..f783823
Binary files /dev/null and b/_qa/2026-05-05-builds/05-stale-chip.png differ
diff --git a/_qa/2026-05-05-builds/06-drawer-open.png b/_qa/2026-05-05-builds/06-drawer-open.png
new file mode 100644
index 0000000..d4e8d27
Binary files /dev/null and b/_qa/2026-05-05-builds/06-drawer-open.png differ
diff --git a/_qa/2026-05-05-builds/07-drawer-after-dd.png b/_qa/2026-05-05-builds/07-drawer-after-dd.png
new file mode 100644
index 0000000..a897429
Binary files /dev/null and b/_qa/2026-05-05-builds/07-drawer-after-dd.png differ
diff --git a/_qa/2026-05-05-builds/08-drawer-next.png b/_qa/2026-05-05-builds/08-drawer-next.png
new file mode 100644
index 0000000..dedd8d8
Binary files /dev/null and b/_qa/2026-05-05-builds/08-drawer-next.png differ
diff --git a/_qa/2026-05-05-builds/08-drawer-no-dd-btn.png b/_qa/2026-05-05-builds/08-drawer-no-dd-btn.png
new file mode 100644
index 0000000..04d56e5
Binary files /dev/null and b/_qa/2026-05-05-builds/08-drawer-no-dd-btn.png differ
diff --git a/_qa/2026-05-05-builds/09-drawer-closed.png b/_qa/2026-05-05-builds/09-drawer-closed.png
new file mode 100644
index 0000000..b484b78
Binary files /dev/null and b/_qa/2026-05-05-builds/09-drawer-closed.png differ
diff --git a/_qa/2026-05-05-builds/09-drawer-next.png b/_qa/2026-05-05-builds/09-drawer-next.png
new file mode 100644
index 0000000..51e69dc
Binary files /dev/null and b/_qa/2026-05-05-builds/09-drawer-next.png differ
diff --git a/_qa/2026-05-05-builds/10-drawer-closed.png b/_qa/2026-05-05-builds/10-drawer-closed.png
new file mode 100644
index 0000000..1c3e45d
Binary files /dev/null and b/_qa/2026-05-05-builds/10-drawer-closed.png differ
diff --git a/_qa/2026-05-05-builds/findings.json b/_qa/2026-05-05-builds/findings.json
new file mode 100644
index 0000000..575a326
--- /dev/null
+++ b/_qa/2026-05-05-builds/findings.json
@@ -0,0 +1,76 @@
+{
+  "findings": [
+    {
+      "step": "initial",
+      "file": "01-initial.png"
+    },
+    {
+      "step": "filter-morning",
+      "file": "02-filter-morning.png"
+    },
+    {
+      "step": "after-reset",
+      "file": "03-after-reset.png"
+    },
+    {
+      "step": "top-chip",
+      "file": "04-top-chip.png"
+    },
+    {
+      "step": "stale-chip",
+      "file": "05-stale-chip.png"
+    },
+    {
+      "step": "drawer-open",
+      "file": "06-drawer-open.png"
+    },
+    {
+      "step": "drawer-after-dd",
+      "file": "07-drawer-after-dd.png"
+    },
+    {
+      "step": "drawer-next",
+      "file": "08-drawer-next.png"
+    },
+    {
+      "step": "drawer-closed",
+      "file": "09-drawer-closed.png"
+    },
+    {
+      "step": "metrics",
+      "title": "Builds — Index",
+      "gridCards": 350,
+      "visibleCards": 350,
+      "gradeCounts": [
+        [
+          "A",
+          0
+        ],
+        [
+          "B",
+          9
+        ],
+        [
+          "C",
+          173
+        ],
+        [
+          "D",
+          380
+        ],
+        [
+          "F",
+          160
+        ]
+      ],
+      "pageHeight": 17855,
+      "pageWidth": 1928
+    }
+  ],
+  "consoleErrs": [],
+  "nav": [
+    "https://builds.agentabrams.com/builds",
+    "https://builds.agentabrams.com/builds",
+    "https://builds.agentabrams.com/builds"
+  ]
+}
\ No newline at end of file
diff --git a/_qa/ui-1-entry.png b/_qa/ui-1-entry.png
new file mode 100644
index 0000000..792513f
Binary files /dev/null and b/_qa/ui-1-entry.png differ
diff --git a/_qa/ui-2-wall.png b/_qa/ui-2-wall.png
new file mode 100644
index 0000000..765dca2
Binary files /dev/null and b/_qa/ui-2-wall.png differ
diff --git a/_qa/ui-3-grades.png b/_qa/ui-3-grades.png
new file mode 100644
index 0000000..e150d5a
Binary files /dev/null and b/_qa/ui-3-grades.png differ
diff --git a/_qa/ui-3-leaderboard.png b/_qa/ui-3-leaderboard.png
new file mode 100644
index 0000000..bf4bd63
Binary files /dev/null and b/_qa/ui-3-leaderboard.png differ
diff --git a/_qa/ui-4-wa-detail.png b/_qa/ui-4-wa-detail.png
new file mode 100644
index 0000000..ae2174e
Binary files /dev/null and b/_qa/ui-4-wa-detail.png differ
diff --git a/_qa/ui-5-mockup-brutalist.png b/_qa/ui-5-mockup-brutalist.png
new file mode 100644
index 0000000..df2aa33
Binary files /dev/null and b/_qa/ui-5-mockup-brutalist.png differ
diff --git a/_qa/ui-entry-dark-final.png b/_qa/ui-entry-dark-final.png
new file mode 100644
index 0000000..1579ebc
Binary files /dev/null and b/_qa/ui-entry-dark-final.png differ
diff --git a/_qa/ui-entry-dark-fixed.png b/_qa/ui-entry-dark-fixed.png
new file mode 100644
index 0000000..ac4b7ca
Binary files /dev/null and b/_qa/ui-entry-dark-fixed.png differ
diff --git a/_qa/ui-entry-dark.png b/_qa/ui-entry-dark.png
new file mode 100644
index 0000000..92f8352
Binary files /dev/null and b/_qa/ui-entry-dark.png differ
diff --git a/_qa/ui-entry-light.png b/_qa/ui-entry-light.png
new file mode 100644
index 0000000..3902d15
Binary files /dev/null and b/_qa/ui-entry-light.png differ
diff --git a/data/wa-screenshots/retrowalls-com.png b/data/wa-screenshots/retrowalls-com.png
new file mode 100644
index 0000000..8789660
Binary files /dev/null and b/data/wa-screenshots/retrowalls-com.png differ
diff --git a/design/figma/website-analysis-brutalist.md b/design/figma/website-analysis-brutalist.md
new file mode 100644
index 0000000..47ee534
--- /dev/null
+++ b/design/figma/website-analysis-brutalist.md
@@ -0,0 +1,180 @@
+# Figma — Website Analysis (Brutalist)
+
+Importable Figma spec for the Website Analysis app inside small-business-builder.
+Produced 2026-05-04. Mirrors `src/render/website-analysis.js` exactly so what's in
+prod and what's in Figma never drift.
+
+> **Plugin**: Open Figma → Plugins → "html.to.design" or "Builder.io" → paste the
+> URL `http://127.0.0.1:9760/website-analysis`. Both plugins reproduce 95%+ of this
+> spec automatically. The remaining 5% is the marquee animation (Figma Smart
+> Animate on the tape `<span>` between two frames offset by 50%).
+
+---
+
+## 1 · Design tokens
+
+```
+COLORS
+  ink         #000000      ─ all body text, all borders
+  paper       #FFFFFF      ─ default surface
+  cream       #FEF3C7      ─ alt surface (cards, second row)
+  yellow      #FACC15      ─ accent (★ buttons, CTAs, marquee)
+  red         #DC2626      ─ stamp / error only — never decorative
+  ash         #666666      ─ small caps secondary text
+  silver      #AAAAAA      ─ footer text on black
+
+TYPE
+  display     'Archivo Black' 900   tracking -.04em  uppercase
+  ui          'Space Mono' 700      tracking +.04em  uppercase
+  body        'Space Mono' 400      tracking 0       sentence case
+
+BORDER
+  hard        4px solid #000        ─ no rounding, no shadows, ever
+  hairline    1px solid #00000022   ─ for editorial subgrids only
+
+SPACING (px)
+  4  8  14  16  24  32  48  64  80  96  120
+
+GRID
+  desktop     1320 / 12 col / 24 gutter / 24 outer
+  tablet      ≤1024 — 2-col stack, gutters collapse to 16
+  mobile      ≤640  — single column, marquee speed halved
+```
+
+---
+
+## 2 · Frames
+
+### Frame A · `/website-analysis` — Entry
+
+```
+A. Marquee tape       1440 × 36     yellow #FACC15 / black 4px bottom border
+                                    Animated text: ★ WEBSITE ANALYSIS ★ POWERED BY 7 SKILLS ★
+
+B. Header             1440 × 64     black bg, 4px black bottom border
+   ├ Logo "WEBSITE / ANALYSIS."          Archivo Black 28 / yellow period
+   └ Nav: [START·active] [SMB BUILDER] [REFERENCE]
+            12px Space Mono 700, white-bordered tabs, active=yellow bg
+
+C. Hero (light)       1440 × 720    paper, 4px black bottom border
+   ├ Stamp            ROTATE -1.5°  red bg, white text, "★ ANALYZE ANY SITE ★"
+   ├ H1               Archivo Black 120 / line-height .85
+                      "SEE YOUR
+                       SITE — REBUILT."
+                      Last word inside <mark> = yellow highlight
+   ├ Subhead          18px Space Mono 700, max 60ch
+   ├ Form (4px black border, no rounding)
+   │   ├ Row 1  [200×80 black/yellow label "★ WEBSITE"]   [input flex, 24px Space Mono 700]
+   │   ├ Row 2  [200×80 black/yellow label "★ INSTAGRAM"] [input optional]
+   │   └ Submit  full-width 96px tall, yellow bg / black 24px Archivo Black, "GET STARTED →"
+   └ Trust line       12px Space Mono 700 ash
+                      "★ NO ACCOUNT ★ NO STORED PII ★ ANALYSIS RUNS IN ~30 SECONDS"
+
+D. How it works       1440 × 480    cream bg, 4px black bottom border
+   ├ H2 "★ HOW IT WORKS"  Archivo Black 40
+   └ 3-col grid (4px black border around grid + each cell)
+       ├ 01 YOUR SITE     yellow tile-numeral on black square
+       ├ 02 3 REBUILDS    cell 2 has cream bg as visual rhythm
+       └ 03 SHIP IT
+
+E. Footer             1440 × 64     black bg, silver text, yellow link
+                      "© 2026 AGENT ABRAMS ★ ALL ANALYSIS RUNS LOCALLY ★ REFERENCE PATTERN"
+```
+
+### Frame B · `/wa/:slug` — Result (3-card picker)
+
+```
+A. Marquee + Header (identical to Frame A · A+B)
+
+C. Result header      1440 × 200    paper, 4px bottom
+   ├ Eyebrow          "★ ANALYZING"  small caps ash
+   ├ H1               Archivo Black 56 — the site domain (e.g. "EXAMPLE.COM")
+   ├ IG line          14px Space Mono 700, underlined w/ 2px black border-bottom
+   └ Right side       "← ANALYZE ANOTHER" black button 4px border, inverse on hover
+
+D. "Your site"        1440 × auto   cream bg, 4px black borders top+bot
+   ├ H2               "★ YOUR SITE — RIGHT NOW"  Archivo Black 32
+   └ iframe (16:9)    paper bg, 4px black border, the live target site
+
+E. "3 new directions" 1440 × auto   paper bg
+   ├ H2               "★ 3 NEW DIRECTIONS"  Archivo Black 48
+   ├ Subhead          "★ SAME CONTENT — THREE COMPLETELY DIFFERENT AESTHETIC SYSTEMS"
+   └ 3-col card grid  gap 24
+       Each card 416 × 480 (responsive):
+       ├ Strip       black bg, yellow Archivo Black 14, "★ OPTION 01" / theme name
+       ├ Preview     4:3 aspect, scaled-down iframe of mockup HTML (transform .5)
+       └ Caption     cream bg, 4px black top, Archivo Black 20 title +
+                     "★ CLICK TO PREVIEW FULL SIZE"  ash 11px
+
+F. Footer (identical to Frame A · E)
+```
+
+### Frame C · `/wa/:slug/m/:mockup` — Single mockup full page
+
+Three variants, one per theme:
+
+```
+THEME 1 · EDITORIAL    Cormorant Garamond + Inter ; off-white #FAFAF6 + ink + warm tan accent
+THEME 2 · BRUTALIST    Archivo Black + Space Mono ; mirror of /website-analysis itself
+THEME 3 · MINIMALIST   Inter 300 weight ; #FEFEFE bg, narrow 760 column, rounded CTAs
+```
+
+These render the user's scraped content (title, tagline, palette, hero) into
+three distinct aesthetic systems. Living source: `src/render/website-analysis-themes.js`.
+
+---
+
+## 3 · Components
+
+```
+☐ Btn / Yellow Block        — full-width, 28 padding, Archivo Black 24, yellow → hover black
+☐ Btn / Black Slim          — 14×24 padding, Archivo Black 13
+☐ Input / Stacked Label     — 200px black/yellow label + flex input, 4px black border
+☐ Stamp / Red Rotate        — -1.5° rotate, red, 8×14 padding, 12px Space Mono 700
+☐ Tape / Marquee            — yellow, 8px padding, 4px black bottom, 40s linear infinite
+☐ Card / Mockup Preview     — black strip header + iframe + cream caption + 4px borders
+☐ Card / Step (01/02/03)    — yellow numeral on black square, Archivo Black 22 title
+☐ Eyebrow / Smallcaps Ash   — Space Mono 700, 12px, .06em tracking, ash
+☐ H1 / Display 120          — Archivo Black 120, lh .85, ls -.05em
+☐ H1 / Display 56 (result)  — Archivo Black 56, lh .9, ls -.03em
+☐ H2 / Display 48           — Archivo Black 48, lh 1, ls -.03em
+☐ H2 / Display 40           — same family, smaller
+☐ H2 / Display 32           — same
+☐ Highlighter / Yellow Mark — <mark> equivalent, yellow bg, 0 8px padding, no rounding
+☐ Iframe / Result Embed     — 4px black border, no rounding, 16:9 ratio
+☐ Iframe / Card Preview     — scaled .5 transform, sandboxed allow-same-origin
+```
+
+---
+
+## 4 · Plugin import recipe
+
+1. **Run the dev server** — `pm2 restart smb-builder` (port 9760).
+2. **Open Figma** — file → "Website Analysis · Brutalist".
+3. **Run "html.to.design" plugin** — URL → `http://127.0.0.1:9760/website-analysis`.
+   Capture the entry frame.
+4. **Run again** — URL → `http://127.0.0.1:9760/wa/example-com` (after submitting
+   form once, that slug exists). Capture the result frame.
+5. **Run again × 3** — capture each mockup frame at
+   `http://127.0.0.1:9760/wa/example-com/m/01-editorial` (and `02-brutalist`,
+   `03-minimalist`).
+6. **Wire interactions** — Prototype → entry button → result frame; result cards →
+   single-mockup frame; "← Analyze another" → entry frame.
+
+---
+
+## 5 · Source-of-truth links
+
+```
+Live entry          http://127.0.0.1:9760/website-analysis
+Live result example http://127.0.0.1:9760/wa/example-com
+Living renderer     ~/Projects/small-business-builder/src/render/website-analysis.js
+Theme renderer      ~/Projects/small-business-builder/src/render/website-analysis-themes.js
+Reference site      https://sdccanalysis.agentabrams.com  (admin / DWSecure2024!)
+Skills              ~/.claude/skills/{site-audit, peer-survey, competitors,
+                                       mockups, tools-pack, info-hub, extension}
+Agent               ~/.claude/agents/website-analysis.md
+```
+
+Whenever the live entry/result pages change, re-import via plugin and overwrite
+the Figma frames. The HTML is the source of truth — Figma is the storyboard.
diff --git a/package-lock.json b/package-lock.json
index 6392cd5..0ab4f35 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,16 +9,64 @@
       "version": "0.1.0",
       "license": "UNLICENSED",
       "dependencies": {
+        "@browserbasehq/sdk": "^2.10.0",
         "cheerio": "^1.0.0",
         "compression": "^1.8.1",
         "cookie-parser": "^1.4.7",
         "dotenv": "^16.4.7",
         "express": "^4.21.2",
+        "helmet": "^8.1.0",
         "multer": "^2.1.1",
         "pg": "^8.13.1",
+        "playwright-core": "^1.59.1",
         "undici": "^7.2.0"
       }
     },
+    "node_modules/@browserbasehq/sdk": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.10.0.tgz",
+      "integrity": "sha512-pOL4yW8P8AI2+N5y6zEP6XXKqIXtYyKunr1JXppqQDOyKLxxvZEDqQCHJXWUzqgx3R1tGWpn7m9AjXN7MeYInA==",
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/node-fetch": {
+      "version": "2.6.13",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+      "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "form-data": "^4.0.4"
+      }
+    },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/accepts": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -41,6 +89,18 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/agentkeepalive": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+      "license": "MIT",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
     "node_modules/append-field": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -53,6 +113,12 @@
       "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
       "license": "MIT"
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
     "node_modules/body-parser": {
       "version": "1.20.5",
       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -207,6 +273,18 @@
         "url": "https://github.com/sponsors/fb55"
       }
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/compressible": {
       "version": "2.0.18",
       "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -338,6 +416,15 @@
         "ms": "2.0.0"
       }
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/depd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -508,6 +595,21 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -523,6 +625,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/express": {
       "version": "4.22.1",
       "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -587,6 +698,41 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+      "license": "MIT"
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -675,6 +821,21 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/hasown": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -687,6 +848,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/htmlparser2": {
       "version": "10.1.0",
       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -738,6 +908,15 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.0.0"
+      }
+    },
     "node_modules/iconv-lite": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -877,6 +1056,46 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/nth-check": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -1075,6 +1294,18 @@
         "split2": "^4.1.0"
       }
     },
+    "node_modules/playwright-core": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/postgres-array": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1385,6 +1616,12 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
     "node_modules/type-is": {
       "version": "1.6.18",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1413,6 +1650,12 @@
         "node": ">=20.18.1"
       }
     },
+    "node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "license": "MIT"
+    },
     "node_modules/unpipe": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1446,6 +1689,21 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
     "node_modules/whatwg-encoding": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -1468,6 +1726,16 @@
         "node": ">=18"
       }
     },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/xtend": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index 154c0ba..f409cf2 100644
--- a/package.json
+++ b/package.json
@@ -14,13 +14,16 @@
     "pm2:logs": "pm2 logs smb-builder"
   },
   "dependencies": {
+    "@browserbasehq/sdk": "^2.10.0",
     "cheerio": "^1.0.0",
     "compression": "^1.8.1",
     "cookie-parser": "^1.4.7",
     "dotenv": "^16.4.7",
     "express": "^4.21.2",
+    "helmet": "^8.1.0",
     "multer": "^2.1.1",
     "pg": "^8.13.1",
+    "playwright-core": "^1.59.1",
     "undici": "^7.2.0"
   }
 }
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..b3e6dbe
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">S</text>
+</svg>
\ No newline at end of file
diff --git a/scripts/audit-public-sites.mjs b/scripts/audit-public-sites.mjs
new file mode 100644
index 0000000..3e3281d
--- /dev/null
+++ b/scripts/audit-public-sites.mjs
@@ -0,0 +1,262 @@
+// Real site audit — fetches every website_analyses row that has a public URL,
+// parses with cheerio, extracts SEO/quality signals, and writes the report
+// into summary_json.live_audit. Adds REAL HTML-derived signal to the audit
+// grade (beyond the synthesis-only rubric in audit-grade.js).
+//
+//   USAGE:
+//     node scripts/audit-public-sites.mjs                     # all public URLs
+//     node scripts/audit-public-sites.mjs --slug <slug>       # one
+//     node scripts/audit-public-sites.mjs --rebuild           # re-audit even
+//                                                             # those with live_audit
+
+import 'dotenv/config';
+import * as cheerio from 'cheerio';
+import { many, query } from '../src/lib/db.js';
+
+const args = process.argv.slice(2);
+const REBUILD = args.includes('--rebuild');
+const SLUG = (() => { const i = args.indexOf('--slug'); return i >= 0 ? args[i + 1] : null; })();
+
+const TIMEOUT_MS = 15_000;
+const UA = 'SmallBusinessBuilderAuditBot/1.0 (+https://builds.agentabrams.com)';
+
+function isPublicUrl(u) {
+  try {
+    const p = new URL(u);
+    if (p.protocol !== 'http:' && p.protocol !== 'https:') return false;
+    const h = p.hostname;
+    if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(h)) return false;
+    if (h === 'localhost' || h.endsWith('.local')) return false;
+    return true;
+  } catch { return false; }
+}
+
+async function fetchHtml(url) {
+  const res = await fetch(url, {
+    redirect: 'follow',
+    signal: AbortSignal.timeout(TIMEOUT_MS),
+    headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8' },
+  });
+  // Re-validate post-redirect target hasn't slipped into private space.
+  if (!isPublicUrl(res.url)) throw new Error(`redirected to non-public ${res.url}`);
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  const ct = res.headers.get('content-type') || '';
+  if (!ct.includes('html')) throw new Error(`non-html content-type ${ct}`);
+  const buf = await res.arrayBuffer();
+  if (buf.byteLength > 5_000_000) throw new Error(`html too large (${buf.byteLength} bytes)`);
+  return { html: new TextDecoder().decode(buf), bytes: buf.byteLength, finalUrl: res.url, status: res.status };
+}
+
+function parse(html, finalUrl) {
+  const $ = cheerio.load(html);
+  const u = new URL(finalUrl);
+
+  const title = ($('title').first().text() || '').trim();
+  const metaDesc = ($('meta[name="description"]').attr('content') || '').trim();
+  const ogImage = ($('meta[property="og:image"]').attr('content') || '').trim();
+  const ogTitle = ($('meta[property="og:title"]').attr('content') || '').trim();
+  const ogDesc = ($('meta[property="og:description"]').attr('content') || '').trim();
+  const twitterCard = ($('meta[name="twitter:card"]').attr('content') || '').trim();
+  const viewport = ($('meta[name="viewport"]').attr('content') || '').trim();
+  const charset = ($('meta[charset]').attr('charset') || '').trim();
+  const lang = ($('html').attr('lang') || '').trim();
+  const canonical = ($('link[rel="canonical"]').attr('href') || '').trim();
+  const favicon = $('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]').length > 0;
+  const robots = ($('meta[name="robots"]').attr('content') || '').trim();
+
+  const h1s = $('h1').map((_, el) => $(el).text().trim()).get().filter(Boolean);
+  const h2Count = $('h2').length;
+  const h3Count = $('h3').length;
+
+  const imgs = $('img').toArray();
+  const imgsWithAlt = imgs.filter(el => ($(el).attr('alt') || '').trim().length > 0).length;
+  const imgCount = imgs.length;
+
+  const links = $('a[href]').toArray();
+  let internal = 0, external = 0;
+  for (const el of links) {
+    const href = $(el).attr('href') || '';
+    if (href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) continue;
+    try {
+      const abs = new URL(href, finalUrl);
+      if (abs.host === u.host) internal += 1; else external += 1;
+    } catch {}
+  }
+
+  const jsonLd = $('script[type="application/ld+json"]').map((_, el) => {
+    try { return JSON.parse($(el).contents().text()); } catch { return null; }
+  }).get().filter(Boolean);
+  const jsonLdTypes = [...new Set(jsonLd.flatMap(o => Array.isArray(o['@graph']) ? o['@graph'].map(g => g['@type']) : [o['@type']]).filter(Boolean))];
+
+  return {
+    title, title_len: title.length,
+    meta_description: metaDesc, meta_description_len: metaDesc.length,
+    og_image: ogImage || null,
+    og_title: ogTitle || null,
+    og_description: ogDesc || null,
+    twitter_card: twitterCard || null,
+    viewport: viewport || null,
+    charset: charset || null,
+    lang: lang || null,
+    canonical: canonical || null,
+    favicon,
+    robots: robots || null,
+    h1_count: h1s.length,
+    h1_first: h1s[0] || null,
+    h2_count: h2Count,
+    h3_count: h3Count,
+    img_count: imgCount,
+    img_with_alt: imgsWithAlt,
+    img_alt_pct: imgCount > 0 ? Math.round((imgsWithAlt / imgCount) * 100) : null,
+    links_internal: internal,
+    links_external: external,
+    jsonld_types: jsonLdTypes,
+    is_https: u.protocol === 'https:',
+  };
+}
+
+function scoreReport(rep) {
+  let score = 0;
+  const reasons = [];
+  const wins = [];
+  const issues = [];
+
+  if (rep.title_len >= 30 && rep.title_len <= 60) { score += 10; wins.push('title length 30-60 chars'); }
+  else if (rep.title_len > 0) { score += 4; issues.push(`title ${rep.title_len} chars (target 30-60)`); }
+  else { issues.push('no <title> tag'); }
+
+  if (rep.meta_description_len >= 70 && rep.meta_description_len <= 160) { score += 10; wins.push('meta description 70-160 chars'); }
+  else if (rep.meta_description_len > 0) { score += 4; issues.push(`meta description ${rep.meta_description_len} chars (target 70-160)`); }
+  else { issues.push('no meta description'); }
+
+  if (rep.og_image) { score += 8; wins.push('og:image set'); } else { issues.push('no og:image — bad social previews'); }
+  if (rep.og_title || rep.og_description) { score += 5; wins.push('og:title/description set'); }
+  if (rep.twitter_card) { score += 3; wins.push('twitter:card set'); }
+  if (rep.favicon) { score += 5; wins.push('favicon present'); } else { issues.push('no favicon'); }
+  if (rep.viewport) { score += 10; wins.push('mobile viewport meta'); } else { issues.push('no viewport meta — broken mobile'); }
+  if (rep.charset) { score += 3; wins.push('charset declared'); }
+  if (rep.lang) { score += 5; wins.push(`html lang="${rep.lang}"`); } else { issues.push('no html lang attribute'); }
+  if (rep.canonical) { score += 5; wins.push('canonical link set'); }
+  if (rep.h1_count === 1) { score += 10; wins.push('exactly one <h1>'); }
+  else if (rep.h1_count === 0) { issues.push('no <h1> tag'); }
+  else { score += 3; issues.push(`${rep.h1_count} <h1> tags (one is best)`); }
+
+  if (rep.img_count === 0) { /* no penalty — text-only is fine */ }
+  else if (rep.img_alt_pct >= 80) { score += 10; wins.push(`${rep.img_alt_pct}% of images have alt text`); }
+  else { issues.push(`only ${rep.img_alt_pct}% of ${rep.img_count} images have alt text`); }
+
+  if (rep.jsonld_types.length > 0) { score += 8; wins.push(`structured data (${rep.jsonld_types.join(', ')})`); }
+  else { issues.push('no JSON-LD structured data'); }
+
+  if (rep.is_https) { score += 5; wins.push('HTTPS'); } else { issues.push('not on HTTPS'); }
+
+  if (score > 100) score = 100;
+  const letter = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 45 ? 'C' : score >= 25 ? 'D' : 'F';
+  return { score, letter, wins, issues, reasons };
+}
+
+async function auditOne(row) {
+  const t0 = Date.now();
+  const fetched = await fetchHtml(row.url);
+  const rep = parse(fetched.html, fetched.finalUrl);
+  const grade = scoreReport(rep);
+  const audit = {
+    url: row.url,
+    final_url: fetched.finalUrl,
+    fetched_at: new Date().toISOString(),
+    http_status: fetched.status,
+    bytes: fetched.bytes,
+    fetch_ms: Date.now() - t0,
+    signals: rep,
+    grade,
+    top_3_issues: grade.issues.slice(0, 3),
+    top_3_wins:   grade.wins.slice(0, 3),
+  };
+
+  // Audit history: keep the last 4 snapshots (slim — just score + issues count
+  // + fetched_at) so we can plot a sparkline + flag regressions.
+  const prevAudit = row.summary_json?.live_audit;
+  const prevHistory = Array.isArray(row.summary_json?.live_audit_history) ? row.summary_json.live_audit_history : [];
+  const newHistory = prevAudit ? [
+    {
+      score:       prevAudit.grade?.score,
+      letter:      prevAudit.grade?.letter,
+      fetched_at:  prevAudit.fetched_at,
+      issues:      prevAudit.top_3_issues?.length ?? 0,
+      bytes:       prevAudit.bytes,
+    },
+    ...prevHistory,
+  ].slice(0, 4) : prevHistory;
+
+  // Flag regression if score dropped > 10 points or letter dropped (e.g. B → C).
+  const prevScore = prevAudit?.grade?.score;
+  const regression = (Number.isFinite(prevScore) && (prevScore - grade.score) > 10)
+    ? { from: prevScore, to: grade.score, delta: grade.score - prevScore, detected_at: new Date().toISOString() }
+    : null;
+
+  // Use jsonb || merge instead of read-modify-write so concurrent runs on the
+  // same slug don't silently overwrite each other's live_audit / copy_pack.
+  await query("UPDATE website_analyses SET summary_json = COALESCE(summary_json, '{}'::jsonb) || $1::jsonb, updated_at=NOW() WHERE id=$2",
+    [JSON.stringify({
+      live_audit: audit,
+      live_audit_history: newHistory,
+      ...(regression ? { live_audit_regression: regression } : {}),
+    }), row.id]);
+  return {
+    slug: row.slug, status: 'ok',
+    score: grade.score, letter: grade.letter, ms: audit.fetch_ms,
+    issues: grade.issues.length, wins: grade.wins.length,
+    delta: Number.isFinite(prevScore) ? grade.score - prevScore : null,
+    regression: !!regression,
+  };
+}
+
+async function main() {
+  let rows;
+  if (SLUG) {
+    rows = await many('SELECT id, slug, url, summary_json FROM website_analyses WHERE slug = $1', [SLUG]);
+  } else {
+    const baseWhere = `WHERE url ~ '^https?://' AND url NOT LIKE '%.local%' AND url NOT LIKE 'local://%' AND url NOT LIKE 'http://100.%' AND url NOT LIKE 'http://192.168.%'`;
+    const skipExisting = REBUILD ? '' : ` AND NOT (summary_json ? 'live_audit')`;
+    rows = await many(`SELECT id, slug, url, summary_json FROM website_analyses ${baseWhere}${skipExisting} ORDER BY id`);
+  }
+  const filtered = rows.filter(r => isPublicUrl(r.url));
+  console.log(`Auditing ${filtered.length} public URLs (skipped ${rows.length - filtered.length} private)`);
+  let ok = 0, err = 0;
+  const regressions = [];
+  for (const row of filtered) {
+    try {
+      const r = await auditOne(row);
+      ok += 1;
+      const deltaStr = r.delta != null ? (r.delta > 0 ? `+${r.delta}` : `${r.delta}`).padStart(4) : '   ·';
+      console.log(`  [${ok}/${filtered.length}] ${r.letter} ${String(r.score).padStart(3)}${r.regression ? ' ⚠' : '  '} ${deltaStr}  ${r.slug.padEnd(28)}  ${r.ms}ms  ✓${r.wins} ✗${r.issues}`);
+      if (r.regression) regressions.push({ slug: r.slug, score: r.score, delta: r.delta });
+    } catch (e) {
+      err += 1;
+      console.error(`  ERR  ${row.slug}  ${e.message}`);
+    }
+  }
+  console.log(`\nDONE — ok:${ok} err:${err}${regressions.length ? `  ⚠ ${regressions.length} REGRESSIONS` : ''}`);
+
+  // Email digest via George if any site regressed > 10 points.
+  if (regressions.length) {
+    try {
+      const body = `Live audit run finished with ${regressions.length} regression${regressions.length === 1 ? '' : 's'}:\n\n` +
+        regressions.map(r => `  ${r.slug}: now ${r.score} (${r.delta > 0 ? '+' : ''}${r.delta})`).join('\n') +
+        `\n\nFull details: https://livesiteaudit.agentabrams.com/wall (login to /builds for admin view)`;
+      await fetch('http://localhost:9850/api/send', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          to: 'steveabramsdesigns@gmail.com',
+          subject: `[livesiteaudit] ${regressions.length} site${regressions.length === 1 ? '' : 's'} regressed`,
+          body,
+        }),
+      });
+      console.log(`Alerted Steve via George (${regressions.length} regressions)`);
+    } catch (e) { console.error('George alert failed:', e.message); }
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/backfill-la-neighborhoods.js b/scripts/backfill-la-neighborhoods.js
new file mode 100644
index 0000000..56d42e1
--- /dev/null
+++ b/scripts/backfill-la-neighborhoods.js
@@ -0,0 +1,234 @@
+#!/usr/bin/env node
+// Backfill `businesses.neighborhood` for BBCB shops sitting under the
+// placeholder neighborhood='LOS ANGELES' (which is really a county tag, not a
+// neighborhood). This is the difference between a directory that says "LA" on
+// 12K rows and a directory that says "Silver Lake / Echo Park / K-Town / …"
+// — i.e. the difference between a useless directory and a useful one.
+//
+// Source of truth: USPS ZIP code → LA neighborhood mapping. The City of LA
+// formally adopted "Mapping LA" neighborhood boundaries (Times, 2009),
+// but for ZIP-level routing we use the simpler postal mapping. Where a ZIP
+// straddles two neighborhoods we pick the larger / more iconic one and rely
+// on a future address-based pass to refine.
+//
+// For ZIPs that are NOT inside the City of LA (e.g. Beverly Hills, Torrance,
+// Pasadena, Glendale) we set neighborhood = the city name, because those
+// users will look for "salons in Beverly Hills" not "salons in Beverly Hills
+// neighborhood of LA County".
+//
+//   node scripts/backfill-la-neighborhoods.js          # full backfill
+//   DRY=1 node scripts/backfill-la-neighborhoods.js    # report-only
+//   LIMIT=500 node scripts/backfill-la-neighborhoods.js # cap
+//
+// Idempotent — running it twice is a no-op.
+
+import 'dotenv/config';
+import { query, one } from '../src/lib/db.js';
+
+const DRY = process.env.DRY === '1';
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+// ZIP5 → neighborhood (or city for non-LA-proper zips).
+// City of LA neighborhoods are Title Case; standalone cities are also Title Case.
+// Sources: USPS ZIP boundaries × LA Times Mapping LA × common postal usage.
+const ZIP_TO_HOOD = {
+  // ===== CITY OF LOS ANGELES =====
+  // Downtown / DTLA
+  '90012':'Downtown LA', '90013':'Downtown LA', '90014':'Downtown LA',
+  '90015':'Downtown LA', '90017':'Downtown LA', '90021':'Downtown LA',
+  '90071':'Downtown LA',
+  // Koreatown / Mid-Wilshire / Westlake
+  '90004':'Koreatown', '90005':'Koreatown', '90010':'Koreatown',
+  '90020':'Koreatown', '90006':'Koreatown', '90019':'Mid-Wilshire',
+  '90036':'Mid-Wilshire', '90057':'Westlake',
+  // Hollywood / East Hollywood / Los Feliz / Silver Lake / Echo Park
+  '90028':'Hollywood', '90038':'Hollywood', '90068':'Hollywood Hills',
+  '90027':'Los Feliz', '90029':'East Hollywood',
+  '90039':'Silver Lake', '90026':'Echo Park', '90065':'Highland Park',
+  '90042':'Highland Park', '90041':'Eagle Rock', '90031':'Lincoln Heights',
+  '90032':'El Sereno', '90033':'Boyle Heights', '90063':'East LA',
+  // West LA / Westside
+  '90024':'Westwood', '90025':'West LA', '90049':'Brentwood',
+  '90064':'Cheviot Hills', '90034':'Palms', '90035':'Beverlywood',
+  '90048':'Beverly Grove', '90069':'West Hollywood',
+  '90046':'Hollywood Hills West', '90077':'Bel-Air',
+  '90272':'Pacific Palisades', '90291':'Venice',
+  '90293':'Playa del Rey', '90094':'Playa Vista',
+  '90066':'Mar Vista',
+  // South LA
+  '90001':'South LA', '90002':'South LA', '90003':'South LA',
+  '90007':'University Park', '90008':'Crenshaw',
+  '90011':'South LA', '90016':'West Adams', '90018':'Jefferson Park',
+  '90037':'South LA', '90043':'Hyde Park', '90044':'South LA',
+  '90047':'South LA', '90056':'Ladera Heights', '90059':'Watts',
+  '90061':'South LA', '90062':'South LA',
+  // San Fernando Valley (City of LA portions)
+  '91303':'Canoga Park', '91304':'Canoga Park', '91306':'Winnetka',
+  '91307':'West Hills', '91311':'Chatsworth', '91316':'Encino',
+  '91324':'Northridge', '91325':'Northridge', '91326':'Porter Ranch',
+  '91330':'Northridge', '91335':'Reseda', '91340':'San Fernando',
+  '91342':'Sylmar', '91343':'North Hills', '91344':'Granada Hills',
+  '91345':'Mission Hills', '91352':'Sun Valley', '91356':'Tarzana',
+  '91364':'Woodland Hills', '91367':'Woodland Hills',
+  '91401':'Van Nuys', '91402':'Panorama City', '91403':'Sherman Oaks',
+  '91405':'Van Nuys', '91406':'Van Nuys', '91411':'Sherman Oaks',
+  '91423':'Sherman Oaks', '91436':'Encino',
+  '91504':'Burbank', '91505':'Burbank', '91506':'Burbank',
+  '91601':'North Hollywood', '91602':'Toluca Lake', '91604':'Studio City',
+  '91605':'North Hollywood', '91606':'Valley Glen', '91607':'Valley Village',
+  // Harbor / Wilmington / San Pedro
+  '90731':'San Pedro', '90732':'San Pedro', '90744':'Wilmington',
+  '90710':'Harbor City', '90717':'Lomita',
+
+  // ===== STANDALONE LA COUNTY CITIES (non-LA proper) =====
+  // Beverly Hills
+  '90209':'Beverly Hills', '90210':'Beverly Hills',
+  '90211':'Beverly Hills', '90212':'Beverly Hills',
+  // Santa Monica
+  '90401':'Santa Monica', '90402':'Santa Monica', '90403':'Santa Monica',
+  '90404':'Santa Monica', '90405':'Santa Monica',
+  // Culver City
+  '90230':'Culver City', '90231':'Culver City', '90232':'Culver City',
+  // Inglewood / Hawthorne / Lennox
+  '90301':'Inglewood', '90302':'Inglewood', '90303':'Inglewood',
+  '90304':'Lennox', '90305':'Inglewood',
+  '90250':'Hawthorne', '90251':'Hawthorne',
+  '90260':'Lawndale', '90261':'Lawndale',
+  '90247':'Gardena', '90248':'Gardena', '90249':'Gardena',
+  '90274':'Rancho Palos Verdes', '90275':'Rancho Palos Verdes',
+  '90277':'Redondo Beach', '90278':'Redondo Beach',
+  '90245':'El Segundo', '90266':'Manhattan Beach', '90254':'Hermosa Beach',
+  // South Bay
+  '90501':'Torrance', '90502':'Torrance', '90503':'Torrance',
+  '90504':'Torrance', '90505':'Torrance', '90506':'Torrance',
+  '90507':'Torrance', '90508':'Torrance', '90509':'Torrance',
+  '90510':'Torrance',
+  // Long Beach
+  '90802':'Long Beach', '90803':'Long Beach', '90804':'Long Beach',
+  '90805':'Long Beach', '90806':'Long Beach', '90807':'Long Beach',
+  '90808':'Long Beach', '90809':'Long Beach', '90810':'Long Beach',
+  '90813':'Long Beach', '90814':'Long Beach', '90815':'Long Beach',
+  // Southeast LA county
+  '90201':'Bell', '90202':'Bell Gardens', '90220':'Compton',
+  '90221':'Compton', '90222':'Compton', '90255':'Huntington Park',
+  '90262':'Lynwood', '90270':'Maywood', '90280':'South Gate',
+  '90040':'Commerce', '90022':'East LA', '90023':'East LA',
+  '90640':'Montebello', '90660':'Pico Rivera', '90670':'Santa Fe Springs',
+  '90706':'Bellflower', '90712':'Lakewood', '90713':'Lakewood',
+  '90715':'Lakewood', '90723':'Paramount',
+  // East San Gabriel Valley
+  '91006':'Arcadia', '91007':'Arcadia', '91016':'Monrovia',
+  '91024':'Sierra Madre', '91030':'South Pasadena',
+  '91101':'Pasadena', '91103':'Pasadena', '91104':'Pasadena',
+  '91105':'Pasadena', '91106':'Pasadena', '91107':'Pasadena',
+  '91108':'San Marino', '91123':'Pasadena', '91124':'Pasadena',
+  '91125':'Pasadena', '91126':'Pasadena',
+  '91201':'Glendale', '91202':'Glendale', '91203':'Glendale',
+  '91204':'Glendale', '91205':'Glendale', '91206':'Glendale',
+  '91207':'Glendale', '91208':'Glendale', '91214':'La Crescenta',
+  '91501':'Burbank', '91502':'Burbank', '91503':'Burbank',
+  '91706':'Baldwin Park', '91710':'Chino', '91711':'Claremont',
+  '91722':'Covina', '91723':'Covina', '91724':'Covina',
+  '91731':'El Monte', '91732':'El Monte', '91733':'South El Monte',
+  '91740':'Glendora', '91741':'Glendora', '91744':'La Puente',
+  '91745':'Hacienda Heights', '91746':'La Puente', '91748':'Rowland Heights',
+  '91750':'La Verne', '91754':'Monterey Park', '91755':'Monterey Park',
+  '91765':'Diamond Bar', '91766':'Pomona', '91767':'Pomona',
+  '91768':'Pomona', '91770':'Rosemead', '91773':'San Dimas',
+  '91775':'San Gabriel', '91776':'San Gabriel', '91780':'Temple City',
+  '91789':'Walnut', '91790':'West Covina', '91791':'West Covina',
+  '91792':'West Covina', '91801':'Alhambra', '91803':'Alhambra',
+  // Santa Clarita Valley
+  '91350':'Santa Clarita', '91351':'Canyon Country',
+  '91354':'Valencia', '91355':'Valencia', '91381':'Stevenson Ranch',
+  '91384':'Castaic', '91387':'Canyon Country', '91390':'Santa Clarita',
+  // Antelope Valley
+  '93534':'Lancaster', '93535':'Lancaster', '93536':'Lancaster',
+  '93550':'Palmdale', '93551':'Palmdale', '93552':'Palmdale',
+
+  // ===== second-pass coverage (top unmapped zips after first run) =====
+  '90240':'Downey', '90241':'Downey', '90242':'Downey',
+  '90601':'Whittier', '90602':'Whittier', '90603':'Whittier',
+  '90604':'Whittier', '90605':'Whittier', '90606':'Whittier',
+  '90638':'La Mirada', '90650':'Norwalk',
+  '90701':'Artesia', '90703':'Cerritos', '90716':'Hawaiian Gardens',
+  '90745':'Carson', '90746':'Carson', '90747':'Carson', '90749':'Long Beach',
+  '90045':'Westchester', // Westchester / LAX
+  '90265':'Malibu', '90290':'Topanga', '90292':'Marina del Rey',
+  '91001':'Altadena', '91010':'Duarte', '91011':'La Cañada Flintridge',
+  '91020':'Montrose', '91040':'Sunland', '91042':'Tujunga',
+  '91301':'Agoura Hills', '91302':'Calabasas',
+  '91321':'Newhall', '91331':'Pacoima', '91346':'Mission Hills',
+  '91702':'Azusa',
+  '91747':'Hacienda Heights',
+  '91755':'Monterey Park', '91756':'Monterey Park',
+  '91802':'Alhambra',
+  // miscellaneous valley stragglers
+  '91357':'Tarzana', '91365':'Woodland Hills', '91371':'West Hills',
+  '91408':'Van Nuys', '91410':'Van Nuys', '91414':'Van Nuys',
+  '91431':'Sherman Oaks', '91482':'Van Nuys',
+  '91804':'Alhambra',
+};
+
+async function main() {
+  log(`mode=${DRY ? 'DRY' : 'WRITE'}${LIMIT ? ` limit=${LIMIT}` : ''}`);
+
+  // Pull every BBCB shop tagged with the placeholder county. Truncate ZIP+4 to
+  // the 5-digit prefix. Skip rows without a usable ZIP.
+  const rows = await query(
+    `SELECT id, zip
+       FROM businesses
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+        AND neighborhood IN ('LOS ANGELES')
+        AND zip IS NOT NULL AND zip != ''
+      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
+  );
+
+  log(`fetched ${rows.rows.length} candidate rows`);
+
+  let matched = 0, skipped = 0, updated = 0;
+  const hoodHits = {};
+
+  for (const r of rows.rows) {
+    const zip5 = (r.zip || '').slice(0, 5);
+    const hood = ZIP_TO_HOOD[zip5];
+    if (!hood) { skipped++; continue; }
+    matched++;
+    hoodHits[hood] = (hoodHits[hood] || 0) + 1;
+    if (DRY) continue;
+    // Update both `neighborhood` and a JSON breadcrumb so a future pass can
+    // tell which mapping source set it.
+    await query(
+      `UPDATE businesses
+          SET neighborhood = $1,
+              source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{neighborhood_source}', '"zip-map"'),
+              updated_at = now()
+        WHERE id = $2`,
+      [hood, r.id]
+    );
+    updated++;
+    if (updated % 500 === 0) log(`  …${updated} updated`);
+  }
+
+  log(`MATCHED: ${matched} · SKIPPED (no zip-map entry): ${skipped} · UPDATED: ${updated}`);
+  // Top hoods
+  const top = Object.entries(hoodHits).sort((a,b)=>b[1]-a[1]).slice(0, 20);
+  log(`TOP NEIGHBORHOODS BY HIT COUNT:`);
+  for (const [h, n] of top) console.log(`    ${String(n).padStart(5)} · ${h}`);
+
+  if (!DRY) {
+    const tally = await one(
+      `SELECT COUNT(*) FILTER (WHERE neighborhood='LOS ANGELES') AS still_generic,
+              COUNT(DISTINCT neighborhood) AS distinct_neighborhoods
+         FROM businesses
+        WHERE source_data_json->>'source' = 'dca_bbcb'`
+    );
+    log(`AFTER: still_generic=${tally.still_generic} distinct_neighborhoods=${tally.distinct_neighborhoods}`);
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/build-all-analyses.mjs b/scripts/build-all-analyses.mjs
new file mode 100644
index 0000000..f8aabb9
--- /dev/null
+++ b/scripts/build-all-analyses.mjs
@@ -0,0 +1,203 @@
+// Batch-builder: walks every row from listBuilds() and generates a 3-mockup
+// website_analyses entry per project, using the directory name + memory
+// description as the synthesized "site content". No external scraping —
+// purely synthesizes mockups from existing local data.
+//
+//   USAGE: node scripts/build-all-analyses.mjs [--dry-run] [--rebuild]
+//
+//   --dry-run   prints what would be created, makes no DB writes
+//   --rebuild   delete existing analyses for these slugs first
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import { listBuilds, isComplianceSensitive } from '../src/lib/builds-index.js';
+import { detectVertical, pickThemesFor } from '../src/lib/vertical.js';
+import { renderTheme } from '../src/render/website-analysis-themes.js';
+import { one, query } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const args = new Set(process.argv.slice(2));
+const DRY = args.has('--dry-run');
+const REBUILD = args.has('--rebuild');
+
+// Race guard: bail if enrich-copy-local.mjs is running (it UPDATEs summary_json
+// concurrently — a SELECT→DELETE→INSERT here would silently lose enrichment that
+// landed between the SELECT and DELETE).
+if (REBUILD && !DRY) {
+  try {
+    const out = (await import('node:child_process')).execSync('pgrep -f enrich-copy-local.mjs', { encoding: 'utf8' }).trim();
+    if (out) {
+      console.error(`REFUSING TO RUN: enrich-copy-local.mjs is running (pid ${out}). Wait for it to finish or kill it first.`);
+      process.exit(2);
+    }
+  } catch { /* pgrep exits 1 when no match — that's the happy path */ }
+}
+
+function pseudoUrl(row) {
+  // Prefer real URL if it's parseable + public, else build a synthetic
+  // file://~/Projects/<dir> placeholder so the schema's NOT-NULL `url` constraint
+  // still has a meaningful value.
+  if (row.url && /^https?:\/\//.test(row.url)) return row.url;
+  return `local://projects/${row.name}`;
+}
+
+function isPublicUrl(u) {
+  try {
+    const p = new URL(u);
+    if (p.protocol !== 'http:' && p.protocol !== 'https:') return false;
+    // Block tailnet 100.x.x.x and RFC1918 IPs from being treated as public.
+    const h = p.hostname;
+    if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(h)) return false;
+    if (h === 'localhost' || h.endsWith('.local')) return false;
+    return true;
+  } catch { return false; }
+}
+
+function buildCtx(row) {
+  const desc = (row.description || '').trim();
+  const title = row.memory_title?.split(/[—\-:]/)[0]?.trim()
+    || row.name.replace(/-/g, ' ');
+  // Vertical selection based on category + desc text.
+  const verticalHint = {
+    agent:        'saas-landing',
+    factory:      'saas-landing',
+    viewer:       'saas-landing',
+    tooling:      'saas-landing',
+    site:         'ecom',
+    directory:    'advocacy',
+    project:      'generic',
+    feedback:     'generic',
+    reference:    'generic',
+  }[row.category] || 'generic';
+  const vertical = desc ? detectVertical({ title, description: desc, html: '' }) : verticalHint;
+  const themes = pickThemesFor(vertical);
+  return { title, desc, vertical, themes };
+}
+
+async function buildOne(row) {
+  const url = pseudoUrl(row);
+  const slug = (slugify(row.name) || 'build').slice(0, 80);
+
+  // Q2 dream team consensus: skip compliance-sensitive slugs entirely. Generating
+  // mockups for them and hiding behind auth is data-liability-without-upside —
+  // the only safe state is "the file does not exist." Dropping out before any DB
+  // mutation keeps any prior copy_pack/audit_report untouched too.
+  if (isComplianceSensitive(slug)) {
+    return { slug, status: 'skip-compliance' };
+  }
+
+  // Preserve any qwen3-enriched copy_pack + audit_report across rebuilds —
+  // the script used to DELETE then re-insert, nuking 30+ minutes of local-LLM
+  // enrichment work. Now we read the existing summary_json first.
+  const existing = await one('SELECT id, summary_json FROM website_analyses WHERE slug=$1', [slug]);
+  if (existing && !REBUILD) return { slug, status: 'skip-exists' };
+  const preservedSummary = existing?.summary_json || {};
+  if (REBUILD) await query('DELETE FROM website_analyses WHERE slug=$1', [slug]);
+
+  const { title, desc, vertical, themes } = buildCtx(row);
+  // ★ Use enriched copy_pack when present — overrides synthesized title/tagline.
+  const cp = preservedSummary.copy_pack;
+  const ctx = {
+    title:    cp?.headline || title,
+    tagline:  cp?.tagline  || desc.slice(0, 240) || `${row.category} build at ~/Projects/${row.name}`,
+    cta_primary:   cp?.cta_primary,
+    cta_secondary: cp?.cta_secondary,
+    cards:    cp?.cards,
+    palette: [],
+    hero_image: null,
+    vertical,
+    ig_handle: null,
+    url: isPublicUrl(url) ? url : `https://${row.name}.local`,
+  };
+
+  if (DRY) return { slug, status: 'dry', themes };
+
+  const summary = {
+    grade: 'pending',
+    top_3_issues: [],
+    vertical,
+    title,
+    tagline: ctx.tagline,
+    // ★ Preserve previously-computed enrichment so 30+ min of qwen3 work isn't wiped on rebuild.
+    ...(preservedSummary.copy_pack    ? { copy_pack:    preservedSummary.copy_pack }    : {}),
+    ...(preservedSummary.audit_report ? { audit_report: preservedSummary.audit_report } : {}),
+  };
+  const meta = {
+    iframe_blocked: true,  // local builds don't embed
+    palette: [],
+    hero_image: null,
+    title,
+    tagline: ctx.tagline,
+    source: 'build-all-analyses',
+    project_path: row.path,
+    compliance_sensitive: isComplianceSensitive(slug),
+  };
+
+  const inserted = await one(
+    `INSERT INTO website_analyses (slug, url, ig_handle, vertical, status, summary_json, meta_json)
+     VALUES ($1,$2,NULL,$3,'ready',$4::jsonb,$5::jsonb) RETURNING id, slug`,
+    [slug, url, vertical, JSON.stringify(summary), JSON.stringify(meta)]
+  );
+
+  for (let n = 0; n < themes.length; n += 1) {
+    const theme = themes[n];
+    const mockupId = `${String(n + 1).padStart(2, '0')}-${theme}`;
+    const html = renderTheme(theme, ctx);
+    await query(
+      `INSERT INTO website_analysis_mockups (analysis_id, mockup_id, title, theme, preview_html)
+       VALUES ($1,$2,$3,$4,$5)`,
+      [inserted.id, mockupId, theme.charAt(0).toUpperCase() + theme.slice(1).replace(/-/g, ' '), theme, html]
+    );
+  }
+  return { slug, status: 'created', themes };
+}
+
+async function main() {
+  const rows = listBuilds();
+  console.log(`Found ${rows.length} builds. Mode: ${DRY ? 'DRY-RUN' : (REBUILD ? 'REBUILD' : 'CREATE-MISSING')}`);
+  let created = 0, skipped = 0, errored = 0;
+  const errs = [];
+  for (let i = 0; i < rows.length; i += 1) {
+    const row = rows[i];
+    try {
+      const r = await buildOne(row);
+      if (r.status === 'created') created += 1;
+      else if (r.status === 'skip-exists') skipped += 1;
+      else if (r.status === 'skip-compliance') skipped += 1;
+      else if (r.status === 'dry') created += 1;
+      if (i % 10 === 0 || r.status === 'created' || r.status === 'skip-compliance') {
+        const label = r.status === 'skip-compliance' ? '[SKIPPED-COMPLIANCE]' : r.status.padEnd(13);
+        console.log(`[${String(i+1).padStart(3,' ')}/${rows.length}] ${label} ${r.slug}`);
+      }
+    } catch (e) {
+      errored += 1;
+      errs.push({ name: row.name, error: String(e?.message || e) });
+      console.error(`[${String(i+1).padStart(3,' ')}/${rows.length}] ERROR        ${row.name}: ${e?.message}`);
+    }
+  }
+  console.log(`\nDONE — created:${created} skipped:${skipped} errored:${errored}`);
+  if (errs.length) console.log('Errors:', errs.slice(0, 10));
+
+  // Standing rule (feedback_batch_failure_alerts.md): >10% failure rate must
+  // page Steve via George. Send only on real failures, not dry-runs.
+  const total = created + skipped + errored;
+  const failRate = total > 0 ? errored / total : 0;
+  if (!DRY && failRate > 0.10) {
+    try {
+      const body = `build-all-analyses run finished with elevated failure rate.\n\nTotals:\n  created: ${created}\n  skipped: ${skipped}\n  errored: ${errored}\n  fail rate: ${(failRate * 100).toFixed(1)}%\n\nFirst 10 errors:\n${errs.slice(0, 10).map(e => `  - ${e.name}: ${e.error}`).join('\n')}`;
+      await fetch('http://localhost:9850/api/send', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          to: 'steveabramsdesigns@gmail.com',
+          subject: `[smb-builder] mockup rebuild >10% fail (${(failRate * 100).toFixed(1)}%)`,
+          body,
+        }),
+      });
+      console.log(`Alerted Steve via George (fail rate ${(failRate * 100).toFixed(1)}% > 10%)`);
+    } catch (e) { console.error('George alert failed:', e.message); }
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/bulk-draft-interviews.js b/scripts/bulk-draft-interviews.js
new file mode 100644
index 0000000..0655d0b
--- /dev/null
+++ b/scripts/bulk-draft-interviews.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// Bulk AI-draft every business that doesn't yet have an interview.
+//
+// Discovers alive Ollama hosts (MS1 + Mac2) at startup, then runs ONE worker
+// per alive host — each worker pulls from a shared queue and pins its draft
+// requests to its assigned host. If MS1 is wedged we get serial Mac2-only
+// throughput; if both alive we get ~2× throughput automatically.
+//
+//   node scripts/bulk-draft-interviews.js             # all unfilled, all alive hosts
+//   LIMIT=10 node scripts/bulk-draft-interviews.js    # cap to first 10
+//   DRY=1 node scripts/bulk-draft-interviews.js       # plan only, no LLM/DB
+//   HOSTS=http://127.0.0.1:11434 node ...             # force a specific host
+
+import 'dotenv/config';
+import { many, query } from '../src/lib/db.js';
+import { draftInterview, aliveHosts, KNOWN_HOSTS } from '../src/lib/ollama-draft.js';
+
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+const DRY   = process.env.DRY === '1';
+const FORCED_HOSTS = process.env.HOSTS ? process.env.HOSTS.split(',').map(s => s.trim()) : null;
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(msg) { console.log(`[${ts()}] ${msg}`); }
+
+async function main() {
+  const todo = await many(`
+    SELECT b.id, b.slug, b.name, b.neighborhood, b.category, b.address,
+           b.years_in_business, b.owner_name, b.website, b.about_text, b.source_data_json
+      FROM businesses b
+      LEFT JOIN LATERAL (
+        SELECT 1 FROM business_interviews i
+         WHERE i.business_id = b.id
+           AND jsonb_typeof(i.qa_json) = 'object' AND i.qa_json <> '{}'::jsonb
+         LIMIT 1
+      ) hasInt ON true
+     WHERE hasInt IS NULL
+     ORDER BY b.ranking_score DESC NULLS LAST, b.id
+     ${LIMIT ? `LIMIT ${LIMIT}` : ''}
+  `);
+  log(`${todo.length} businesses need a draft${DRY ? ' (DRY RUN)' : ''}`);
+  if (DRY) {
+    todo.slice(0, 20).forEach(b => log(`  • ${b.slug} (${b.neighborhood || '—'} · ${b.category || '—'})`));
+    return;
+  }
+
+  // Discover alive hosts. If user forced a list, trust them.
+  let hosts = FORCED_HOSTS || await aliveHosts();
+  if (!hosts.length) {
+    log(`✗ No Ollama host responsive. Tried: ${KNOWN_HOSTS.join(', ')}`);
+    log(`  MS1 fix: ssh stevestudio@192.168.1.133 'pkill ollama && nohup ollama serve >/tmp/ollama.log 2>&1 &'`);
+    process.exit(1);
+  }
+  log(`alive hosts (${hosts.length}): ${hosts.join(', ')}`);
+
+  // Shared queue + counters. Workers race for items via shift().
+  const queue = todo.slice();
+  let ok = 0, fail = 0, fields = 0, msTotal = 0;
+  const startAt = Date.now();
+  const total = queue.length;
+
+  async function worker(host) {
+    while (queue.length) {
+      const b = queue.shift();
+      if (!b) break;
+      const idx = total - queue.length;
+      const head = `[${idx}/${total} · ${host.replace(/.*\/\//, '')}] ${b.slug}`;
+      try {
+        const t0 = Date.now();
+        const result = await draftInterview(b, { host });
+        const ms = Date.now() - t0;
+        const fc = Object.keys(result.qa).length;
+        if (!fc) throw new Error('LLM returned 0 fields');
+        await query(
+          `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2, $3)`,
+          [b.id, JSON.stringify(result.qa), `AI Draft (${result.model})`]
+        );
+        ok += 1; fields += fc; msTotal += ms;
+        log(`${head} ✓ ${fc} fields, ${ms}ms`);
+      } catch (e) {
+        fail += 1;
+        const msg = String(e.message || e).slice(0, 200);
+        log(`${head} ✗ ${msg}`);
+        // If the host wedges mid-run (request times out / aborts), this worker
+        // exits — the other worker keeps draining the queue.
+        if (msg.includes('aborted') || msg.includes('No Ollama host')) {
+          log(`  [${host}] worker exiting — host wedged`);
+          return;
+        }
+      }
+    }
+  }
+
+  await Promise.all(hosts.map(worker));
+
+  const totalMs = Date.now() - startAt;
+  log(`DONE · ok=${ok} fail=${fail} fields=${fields} avg=${ok ? Math.round(msTotal / ok) : 0}ms wall=${Math.round(totalMs / 1000)}s`);
+  process.exit(fail > ok ? 1 : 0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/data-hound-census.js b/scripts/data-hound-census.js
new file mode 100644
index 0000000..491b97a
--- /dev/null
+++ b/scripts/data-hound-census.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// Data Hound #1 — Census Geocoder fallback for the 2,686 Nominatim misses
+// and 7 transient fetch errors. Free US-only geocoder, no key, no rate
+// limit (within reason — we still throttle to 5 req/sec to be polite).
+// On hit: clear the prior 'miss:no-result' flag, write lat/lng,
+//         mark source_data_json.geocode_attempt='census-hit'.
+// On miss: bump source_data_json.geocode_attempt='miss:census-also-no-result'.
+//
+//   node scripts/data-hound-census.js
+//   LIMIT=200 node scripts/data-hound-census.js
+//   DRY=1 node scripts/data-hound-census.js
+
+import 'dotenv/config';
+import { query, one } from '../src/lib/db.js';
+
+const CENSUS = 'https://geocoding.geo.census.gov/geocoder/locations/onelineaddress';
+const SLEEP_MS = 220; // ~5/sec
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+const DRY = process.env.DRY === '1';
+
+const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const log = m => console.log(`[${ts()}] ${m}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function buildOneLine(b) {
+  const addr = String(b.address || '').trim()
+    .replace(/\s*(STE|SUITE|UNIT|APT|BLDG|FL|FLR|RM|#)\s*[\w-]+/gi, '')
+    .replace(/\s+/g, ' ').trim();
+  if (!addr) return null;
+  const city = String(b.city || '').trim();
+  const zip = String(b.zip || '').trim();
+  return [addr, city, 'CA', zip].filter(Boolean).join(', ');
+}
+
+async function censusLookup(oneline) {
+  const u = `${CENSUS}?address=${encodeURIComponent(oneline)}&benchmark=Public_AR_Current&format=json`;
+  const r = await fetch(u, { signal: AbortSignal.timeout(15000) });
+  if (!r.ok) throw new Error(`HTTP ${r.status}`);
+  const j = await r.json();
+  const m = j?.result?.addressMatches?.[0];
+  if (!m?.coordinates) return null;
+  return { lat: m.coordinates.y, lon: m.coordinates.x, matchedAddress: m.matchedAddress };
+}
+
+async function run() {
+  // Eligible: missing geocode AND prior attempt was nominatim miss/fetch-error/null
+  const rows = await query(
+    `SELECT id, slug, name, address, city, zip, source_data_json
+       FROM businesses
+      WHERE (latitude IS NULL OR longitude IS NULL)
+        AND address IS NOT NULL AND address <> ''
+      ORDER BY id
+      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
+  );
+  log(`Census geocoder hound: ${rows.rows.length} candidates`);
+
+  let hits = 0, misses = 0, errs = 0;
+  for (const b of rows.rows) {
+    const oneline = buildOneLine(b);
+    if (!oneline) { misses++; continue; }
+    if (DRY) { log(`DRY: ${oneline}`); await sleep(SLEEP_MS); continue; }
+
+    try {
+      const r = await censusLookup(oneline);
+      if (r) {
+        await query(
+          `UPDATE businesses
+              SET latitude  = $1,
+                  longitude = $2,
+                  source_data_json = jsonb_set(
+                    COALESCE(source_data_json,'{}'::jsonb),
+                    '{geocode_attempt}', '"census-hit"'::jsonb)
+            WHERE id = $3`,
+          [r.lat, r.lon, b.id]
+        );
+        hits++;
+        if (hits % 25 === 0) log(`  ${hits} hits / ${misses} misses / ${errs} errors`);
+      } else {
+        await query(
+          `UPDATE businesses
+              SET source_data_json = jsonb_set(
+                    COALESCE(source_data_json,'{}'::jsonb),
+                    '{geocode_attempt}', '"miss:census-also-no-result"'::jsonb)
+            WHERE id = $1`,
+          [b.id]
+        );
+        misses++;
+      }
+    } catch (e) {
+      errs++;
+      if (errs % 10 === 0) log(`  ${errs} errors so far (last: ${e.message})`);
+    }
+    await sleep(SLEEP_MS);
+  }
+
+  log(`DONE — hits=${hits} misses=${misses} errors=${errs}`);
+  process.exit(0);
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/data-hound-press.js b/scripts/data-hound-press.js
new file mode 100644
index 0000000..81ac26d
--- /dev/null
+++ b/scripts/data-hound-press.js
@@ -0,0 +1,173 @@
+#!/usr/bin/env node
+// Data Hound #3 — Google News RSS scrape for press articles. For each business
+// missing press coverage, query Google News RSS for "{name}" {city} salon and
+// extract <item> entries from credible publishers. ZERO Anthropic API.
+// Google News RSS endpoint:
+//   https://news.google.com/rss/search?q=QUERY&hl=en-US&gl=US&ceid=US:en
+//
+//   node scripts/data-hound-press.js
+//   LIMIT=200 node scripts/data-hound-press.js
+//   DRY=1 node scripts/data-hound-press.js
+
+import 'dotenv/config';
+import { query } from '../src/lib/db.js';
+
+const SLEEP_MS = 2500;          // baseline: ~0.4 req/sec — Google RSS safe floor
+const BACKOFF_BASE = 15_000;    // 15s after first 503
+const BACKOFF_MAX = 300_000;    // cap at 5 min
+const CIRCUIT_OPEN_AFTER = 5;   // consecutive 503s before circuit opens
+const CIRCUIT_COOLDOWN = 120_000; // 2-min full pause when circuit opens
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+const DRY = process.env.DRY === '1';
+
+const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const log = m => console.log(`[${ts()}] ${m}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// Allowlist of credible publishers. Anything else is dropped.
+// Matched as substring (case-insensitive) against the publisher name.
+const ALLOW = [
+  'la mag', 'los angeles magazine', 'la times', 'eater', 'time out',
+  'voyage la', 'hollywood reporter', 'thrillist', 'goop', 'allure',
+  'vogue', 'glamour', 'wwd', 'racked', 'curbed', 'la weekly',
+  'beverly hills courier', 'westside today', 'hollywood patch',
+  'modern salon', 'beauty launchpad', 'american salon', 'nailpro',
+  'spa and beauty today', 'wellness magazine', 'dazed', 'refinery29',
+  'who what wear', 'pop sugar', 'byrdie', 'into the gloss',
+  'la magazine', 'sf chronicle', 'forbes', 'bloomberg', 'cnbc',
+  'visit california', 'visit los angeles', 'discover los angeles',
+  'discover la', 'angeleno', 'l.a. taco', 'la taco',
+  'we like la', 'the daily beast', 'people', 'us weekly',
+  'la confidential', 'cbs los angeles', 'nbc los angeles', 'kcrw',
+  'kcet', 'kpcc', 'laist',
+];
+
+function isAllowedPublisher(name) {
+  const lc = String(name || '').toLowerCase();
+  return ALLOW.some(a => lc.includes(a));
+}
+
+// Fast lightweight RSS extractor — no external deps. Pulls each <item>...</item>
+// block and extracts <title>, <link>, <pubDate>, <description>, <source>.
+function parseRSS(xml) {
+  const items = [];
+  const itemRe = /<item>([\s\S]*?)<\/item>/g;
+  let m;
+  while ((m = itemRe.exec(xml)) !== null) {
+    const block = m[1];
+    const grab = (tag) => {
+      const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`);
+      const r = re.exec(block);
+      return r ? r[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim() : null;
+    };
+    const link = grab('link');
+    if (!link) continue;
+    items.push({
+      title:     grab('title'),
+      url:       link,
+      published: grab('pubDate'),
+      excerpt:   (grab('description') || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 400),
+      publisher: grab('source') || null,
+    });
+  }
+  return items;
+}
+
+async function googleNewsSearch(name, city, attempt = 0) {
+  const q = encodeURIComponent(`"${name}" ${city || 'Los Angeles'} salon`);
+  const url = `https://news.google.com/rss/search?q=${q}&hl=en-US&gl=US&ceid=US:en`;
+  const r = await fetch(url, {
+    signal: AbortSignal.timeout(15000),
+    headers: { 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' },
+  });
+  if (r.status === 503) {
+    // Exponential backoff with jitter, capped at BACKOFF_MAX
+    const delay = Math.min(BACKOFF_BASE * 2 ** attempt, BACKOFF_MAX)
+      + Math.floor(Math.random() * 2000);
+    throw Object.assign(new Error(`HTTP 503`), { is503: true, backoffMs: delay });
+  }
+  if (!r.ok) throw new Error(`HTTP ${r.status}`);
+  const xml = await r.text();
+  return parseRSS(xml);
+}
+
+async function run() {
+  const rows = await query(
+    `SELECT b.id, b.slug, b.name, b.city,
+            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS display_name,
+            COUNT(a.id)::int AS article_count
+       FROM businesses b
+       LEFT JOIN business_articles a ON a.business_id = b.id
+      WHERE b.category IN ('salon','barbershop','spa','nail')
+        AND (b.website IS NOT NULL AND b.website <> '' OR b.neighborhood IS NOT NULL)
+      GROUP BY b.id
+     HAVING COUNT(a.id) = 0
+      ORDER BY (CASE WHEN b.website IS NOT NULL AND b.website <> '' THEN 0 ELSE 1 END),
+               (CASE WHEN b.neighborhood IS NOT NULL THEN 0 ELSE 1 END),
+               b.name
+      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
+  );
+  log(`Press hound: ${rows.rows.length} candidates (no existing press, eligible)`);
+
+  let scanned = 0, found = 0, articlesAdded = 0, errs = 0;
+  let consecutive503 = 0;
+
+  for (const b of rows.rows) {
+    scanned++;
+    if (DRY) { log(`DRY: ${b.display_name} / ${b.city}`); await sleep(SLEEP_MS); continue; }
+
+    // Circuit breaker: too many consecutive 503s → pause hard before continuing
+    if (consecutive503 >= CIRCUIT_OPEN_AFTER) {
+      log(`  CIRCUIT OPEN after ${consecutive503} consecutive 503s — cooling down ${CIRCUIT_COOLDOWN / 1000}s`);
+      await sleep(CIRCUIT_COOLDOWN);
+      consecutive503 = 0;
+    }
+
+    let attempt = 0;
+    let success = false;
+    while (attempt <= 3 && !success) {
+      try {
+        const items = await googleNewsSearch(b.display_name, b.city, attempt);
+        consecutive503 = 0; // reset on success
+        const accepted = items.filter(it => it.publisher && isAllowedPublisher(it.publisher));
+
+        let added = 0;
+        for (const it of accepted) {
+          try {
+            const r = await query(
+              `INSERT INTO business_articles (business_id, url, title, publisher, published_at, excerpt, source)
+               VALUES ($1, $2, $3, $4, $5, $6, 'google-news-rss')
+               ON CONFLICT (business_id, url) DO NOTHING
+               RETURNING id`,
+              [b.id, it.url, it.title, it.publisher, it.published || null, it.excerpt || null]
+            );
+            if (r.rowCount > 0) { added++; articlesAdded++; }
+          } catch (e) {
+            // INSERT can fail on bad pubDate parsing — skip
+          }
+        }
+        if (added > 0) found++;
+        success = true;
+      } catch (e) {
+        if (e.is503) {
+          consecutive503++;
+          attempt++;
+          log(`  503 backoff ${e.backoffMs}ms (attempt ${attempt}, consecutive=${consecutive503}) — ${b.display_name}`);
+          await sleep(e.backoffMs);
+        } else {
+          errs++;
+          if (errs % 20 === 0) log(`  ${errs} errors (last: ${e.message?.slice(0, 80)})`);
+          break;
+        }
+      }
+    }
+    if (!success && attempt > 3) errs++;
+    if (scanned % 25 === 0) log(`  scanned=${scanned} biz_with_press=${found} articles=${articlesAdded} errors=${errs}`);
+    await sleep(SLEEP_MS);
+  }
+
+  log(`DONE — scanned=${scanned} biz_with_press=${found} articles_added=${articlesAdded} errors=${errs}`);
+  process.exit(0);
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/data-hound-social.js b/scripts/data-hound-social.js
new file mode 100644
index 0000000..ad59cc9
--- /dev/null
+++ b/scripts/data-hound-social.js
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+// Data Hound #2 — for businesses with a website but no IG/Yelp/FB on file,
+// fetch the homepage and scrape outbound social links + about_text fallback.
+// Uses existing src/scrape/website_meta.js for the safe SSRF-guarded fetch.
+//
+//   node scripts/data-hound-social.js
+//   LIMIT=100 node scripts/data-hound-social.js
+//   DRY=1 node scripts/data-hound-social.js
+
+import 'dotenv/config';
+import { query } from '../src/lib/db.js';
+import { fetchHtml } from '../src/scrape/website_meta.js';
+import * as cheerio from 'cheerio';
+
+const SLEEP_MS = 1500; // be polite — 1 page every 1.5s
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+const DRY = process.env.DRY === '1';
+
+const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const log = m => console.log(`[${ts()}] ${m}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const SOCIAL_PATTERNS = {
+  instagram: /(?:https?:\/\/)?(?:www\.)?instagram\.com\/([a-zA-Z0-9._]{2,30})\/?(?:[?#].*)?$/i,
+  yelp:      /(?:https?:\/\/)?(?:www\.)?yelp\.com\/biz\/([a-zA-Z0-9_-]+)/i,
+  facebook:  /(?:https?:\/\/)?(?:www\.)?facebook\.com\/([a-zA-Z0-9.\-_]+)\/?(?:[?#].*)?$/i,
+  tiktok:    /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@([a-zA-Z0-9._]{2,30})/i,
+};
+
+const SKIP_HANDLES = new Set([
+  'p', 'explore', 'reel', 'reels', 'tv', 'stories', 'login', 'about',
+  'pages', 'permalink', 'sharer', 'share', 'help', 'business', 'help-en',
+  'privacy', 'terms', 'support',
+]);
+
+function extractSocials(html, baseUrl) {
+  const $ = cheerio.load(html);
+  const found = {};
+  $('a[href]').each((_, el) => {
+    const href = $(el).attr('href');
+    if (!href) return;
+    for (const [platform, re] of Object.entries(SOCIAL_PATTERNS)) {
+      if (found[platform]) continue;
+      const m = href.match(re);
+      if (m) {
+        const handle = m[1].toLowerCase();
+        if (SKIP_HANDLES.has(handle)) continue;
+        found[platform] = { url: href.startsWith('http') ? href : 'https://' + href.replace(/^\/\//,''), handle };
+      }
+    }
+  });
+  return found;
+}
+
+async function run() {
+  // Eligible: have a website, missing at least one social platform
+  const rows = await query(
+    `SELECT b.id, b.slug, b.name, b.website, b.about_text,
+            EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='instagram') AS has_ig,
+            EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='yelp')      AS has_yelp,
+            EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='facebook')  AS has_fb
+       FROM businesses b
+      WHERE b.website IS NOT NULL AND b.website <> ''
+        AND NOT (
+          EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='instagram')
+          AND EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='yelp')
+        )
+      ORDER BY b.id
+      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
+  );
+  log(`Social-link hound: ${rows.rows.length} candidates`);
+
+  let scanned = 0, found = 0, errs = 0, totalSocials = 0;
+  for (const b of rows.rows) {
+    scanned++;
+    if (DRY) { log(`DRY: ${b.website}`); await sleep(SLEEP_MS); continue; }
+    try {
+      const { html } = await fetchHtml(b.website);
+      const socials = extractSocials(html, b.website);
+
+      let added = 0;
+      for (const [platform, info] of Object.entries(socials)) {
+        if (b[`has_${platform === 'facebook' ? 'fb' : platform}`]) continue;
+        try {
+          await query(
+            `INSERT INTO business_socials (business_id, platform, url, handle)
+             VALUES ($1, $2, $3, $4)
+             ON CONFLICT (business_id, platform) DO NOTHING`,
+            [b.id, platform, info.url, info.handle]
+          );
+          added++; totalSocials++;
+        } catch {} // ON CONFLICT might still raise on schema mismatches
+      }
+
+      // Backfill about_text from <meta description> if empty
+      if (!b.about_text) {
+        const $ = cheerio.load(html);
+        const desc = $('meta[property="og:description"]').attr('content')
+                  || $('meta[name="description"]').attr('content');
+        if (desc && desc.length >= 40) {
+          await query(`UPDATE businesses SET about_text = $1 WHERE id = $2 AND (about_text IS NULL OR about_text = '')`,
+            [desc.slice(0, 600), b.id]);
+        }
+      }
+
+      if (added > 0) found++;
+      if (scanned % 25 === 0) log(`  scanned=${scanned} sites_with_finds=${found} socials_added=${totalSocials} errors=${errs}`);
+    } catch (e) {
+      errs++;
+      if (errs % 10 === 0) log(`  ${errs} errors (last: ${e.message?.slice(0, 60)})`);
+    }
+    await sleep(SLEEP_MS);
+  }
+
+  log(`DONE — scanned=${scanned} hits=${found} totalSocials=${totalSocials} errors=${errs}`);
+  process.exit(0);
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/enrich-copy-local.mjs b/scripts/enrich-copy-local.mjs
new file mode 100644
index 0000000..4e1faf1
--- /dev/null
+++ b/scripts/enrich-copy-local.mjs
@@ -0,0 +1,124 @@
+// Enrich every website_analyses row's copy via local Ollama on Mac Studio 1.
+// Zero Claude tokens. Sequential (Mac1 has OLLAMA_MAX_LOADED_MODELS=1).
+//
+//   node scripts/enrich-copy-local.mjs              # all unset
+//   node scripts/enrich-copy-local.mjs --rebuild    # re-enrich everyone
+//   node scripts/enrich-copy-local.mjs --limit 5    # dry test on 5
+//
+// Writes summary_json.copy_pack = { headline, tagline, cta_primary, cta_secondary, cards: [3] }
+// Re-rendering mockups with the new copy is a separate `node scripts/build-all-analyses.mjs --rebuild`.
+
+import 'dotenv/config';
+import { many, one, query } from '../src/lib/db.js';
+
+const OLLAMA = 'http://192.168.1.133:11434';
+const MODEL = 'qwen3:14b';
+const args = new Set(process.argv.slice(2));
+const REBUILD = args.has('--rebuild');
+const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > 0 ? parseInt(process.argv[i+1], 10) || 0 : 0; })();
+
+const SYSTEM = `You are a senior copywriter for small businesses. Given a project's name, vertical, and existing description, produce *clean*, *human*, *specific* marketing copy. No corporate fluff. Output VALID JSON only — no markdown, no preamble, no explanation. Schema:
+{
+  "headline":     "string (5-9 words, sentence case, no period at end)",
+  "tagline":      "string (12-22 words, declarative)",
+  "cta_primary":  "string (2-4 words, verb-led)",
+  "cta_secondary":"string (1-3 words, casual alt)",
+  "cards": [
+    { "title": "string (2-3 words, ALL CAPS)", "body": "string (12-20 words)" },
+    { "title": "string (2-3 words, ALL CAPS)", "body": "string (12-20 words)" },
+    { "title": "string (2-3 words, ALL CAPS)", "body": "string (12-20 words)" }
+  ]
+}`;
+
+function buildPrompt(a) {
+  const meta = a.meta_json || {};
+  const sum = a.summary_json || {};
+  return `PROJECT: ${a.slug}
+VERTICAL: ${a.vertical || 'generic'}
+TITLE: ${sum.title || meta.title || a.slug}
+DESCRIPTION: ${(sum.tagline || meta.tagline || '').slice(0, 600) || '(none — invent something appropriate to vertical)'}
+
+Output JSON only.`;
+}
+
+async function callOllama(prompt) {
+  const res = await fetch(`${OLLAMA}/api/generate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    signal: AbortSignal.timeout(120_000),
+    body: JSON.stringify({
+      model: MODEL,
+      system: SYSTEM,
+      prompt,
+      stream: false,
+      // Note: NOT using format:'json' — qwen3:14b returns empty {} under that
+      // constraint. Better to let it generate freely and regex out the {…} block.
+      options: { temperature: 0.7, num_predict: 700 },
+    }),
+  });
+  if (!res.ok) throw new Error(`ollama ${res.status}`);
+  const data = await res.json();
+  const txt = (data.response || '').trim();
+  // qwen3 wraps JSON sometimes in <think>...</think> blocks
+  const cleaned = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+  // Strip ```json fences if model wrapped it.
+  const unfenced = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
+  const m = unfenced.match(/\{[\s\S]*\}/);
+  if (!m) throw new Error('no json in response');
+  // Repair common qwen3 mistakes: trailing commas + smart quotes + unescaped
+  // newlines inside string values.
+  let lenient = m[0]
+    .replace(/,(\s*[}\]])/g, '$1')          // trailing commas
+    .replace(/[“”]/g, '"')        // smart double-quotes
+    .replace(/[‘’]/g, "'");       // smart single-quotes
+  try { return JSON.parse(lenient); } catch (e) {
+    // Last resort: replace literal newlines inside quoted strings with spaces.
+    lenient = lenient.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_, s) => '"' + s.replace(/[\r\n]+/g, ' ') + '"');
+    return JSON.parse(lenient);
+  }
+}
+
+function valid(pack) {
+  if (!pack
+    || typeof pack.headline !== 'string' || pack.headline.length < 4
+    || typeof pack.tagline !== 'string' || pack.tagline.length < 12
+    || typeof pack.cta_primary !== 'string'
+    || !Array.isArray(pack.cards) || pack.cards.length < 2 || pack.cards.length > 4
+    || !pack.cards.every(c => c && typeof c.title === 'string' && typeof c.body === 'string')) return false;
+  // Normalize: pad to 3 cards or trim. Pad by repeating last card if model returned only 2.
+  if (pack.cards.length === 2) pack.cards.push({ ...pack.cards[1] });
+  if (pack.cards.length === 4) pack.cards = pack.cards.slice(0, 3);
+  return true;
+}
+
+async function enrichOne(a) {
+  if (!REBUILD && a.summary_json?.copy_pack) return { slug: a.slug, status: 'skip' };
+  const t0 = Date.now();
+  const pack = await callOllama(buildPrompt(a));
+  if (!valid(pack)) throw new Error('invalid pack shape');
+  const newSummary = { ...(a.summary_json || {}), copy_pack: { ...pack, generated_at: new Date().toISOString(), model: MODEL } };
+  await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
+              [JSON.stringify(newSummary), a.id]);
+  return { slug: a.slug, status: 'ok', ms: Date.now() - t0, headline: pack.headline };
+}
+
+async function main() {
+  let rows = await many('SELECT id, slug, vertical, summary_json, meta_json FROM website_analyses ORDER BY id');
+  if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+  console.log(`enriching ${rows.length} records via ${MODEL} @ ${OLLAMA}  (REBUILD=${REBUILD})`);
+  let ok=0, skip=0, err=0;
+  for (let i=0; i<rows.length; i+=1) {
+    try {
+      const r = await enrichOne(rows[i]);
+      if (r.status === 'ok')   { ok+=1;   console.log(`[${String(i+1).padStart(3,' ')}/${rows.length}] ${r.ms}ms  ${r.slug.padEnd(40,' ')} → ${r.headline}`); }
+      else                     { skip+=1; }
+    } catch (e) {
+      err += 1;
+      console.error(`[${String(i+1).padStart(3,' ')}/${rows.length}] ERR  ${rows[i].slug}: ${e.message}`);
+    }
+  }
+  console.log(`\nDONE — ok:${ok} skip:${skip} err:${err}`);
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/enrich-rescue.mjs b/scripts/enrich-rescue.mjs
new file mode 100644
index 0000000..85633bb
--- /dev/null
+++ b/scripts/enrich-rescue.mjs
@@ -0,0 +1,116 @@
+// One-off rescue for the 3 records that hard-failed even with hardened parser:
+// qwen3 was either looping in <think>, timing out at 120s, or emitting empty.
+//
+// Fix: add /no_think directive (qwen3-specific), bump timeout to 240s, bump
+// num_predict to 1500. If all that still fails, fall back to a deterministic
+// copy_pack derived from slug + description so the row at least has SOMETHING.
+
+import 'dotenv/config';
+import { many, query } from '../src/lib/db.js';
+
+const OLLAMA = 'http://192.168.1.133:11434';
+const MODEL = 'qwen3:14b';
+
+const SYSTEM = `You are a senior copywriter. Output VALID JSON only — no markdown, no preamble, no explanation, no <think> blocks. Schema:
+{"headline":"5-9 words","tagline":"12-22 words","cta_primary":"2-4 words","cta_secondary":"1-3 words","cards":[{"title":"2-3 ALL CAPS WORDS","body":"12-20 words"},{"title":"...","body":"..."},{"title":"...","body":"..."}]}`;
+
+function buildPrompt(a) {
+  const meta = a.meta_json || {};
+  const sum = a.summary_json || {};
+  // /no_think is a qwen3 directive that disables the <think> block.
+  return `/no_think
+PROJECT: ${a.slug}
+VERTICAL: ${a.vertical || 'generic'}
+TITLE: ${(sum.title || meta.title || a.slug).slice(0, 200)}
+DESCRIPTION: ${(sum.tagline || meta.tagline || '').slice(0, 400) || '(none)'}
+
+Output JSON only.`;
+}
+
+async function callOllama(prompt) {
+  const res = await fetch(`${OLLAMA}/api/generate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    signal: AbortSignal.timeout(240_000),
+    body: JSON.stringify({
+      model: MODEL,
+      system: SYSTEM,
+      prompt,
+      stream: false,
+      options: { temperature: 0.5, num_predict: 1500 },
+    }),
+  });
+  if (!res.ok) throw new Error(`ollama ${res.status}`);
+  const data = await res.json();
+  const txt = (data.response || '').trim();
+  const cleaned = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+  const unfenced = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
+  const m = unfenced.match(/\{[\s\S]*\}/);
+  if (!m) throw new Error('no json in response');
+  let lenient = m[0]
+    .replace(/,(\s*[}\]])/g, '$1')
+    .replace(/\}\s*\{/g, '},{')         // missing comma between objects in array
+    .replace(/[“”]/g, '"')
+    .replace(/[‘’]/g, "'");
+  try { return JSON.parse(lenient); }
+  catch {
+    lenient = lenient.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_, s) => '"' + s.replace(/[\r\n]+/g, ' ') + '"');
+    return JSON.parse(lenient);
+  }
+}
+
+function fallbackPack(a) {
+  const meta = a.meta_json || {};
+  const sum = a.summary_json || {};
+  const title = (sum.title || meta.title || a.slug).replace(/[-_]/g, ' ');
+  const desc = (sum.tagline || meta.tagline || '').slice(0, 200) || `${a.slug} — a project at ~/Projects/${a.slug}.`;
+  return {
+    headline: title.slice(0, 60),
+    tagline: desc.length >= 12 ? desc : `${title} — built locally and ready to ship.`,
+    cta_primary: 'Get Started',
+    cta_secondary: 'Learn More',
+    cards: [
+      { title: 'BUILT LOCAL', body: 'Crafted on the metal — no cloud lag, no rate limits, no surprises.' },
+      { title: 'OPEN SOURCE', body: 'Read the code, fork it, ship your own variant — everything is on disk.' },
+      { title: 'SHIP TODAY', body: 'Pull it down, point it at your data, and run — under a minute to first request.' },
+    ],
+    fallback: true,
+  };
+}
+
+function valid(pack) {
+  if (!pack
+    || typeof pack.headline !== 'string' || pack.headline.length < 4
+    || typeof pack.tagline !== 'string' || pack.tagline.length < 12
+    || typeof pack.cta_primary !== 'string'
+    || !Array.isArray(pack.cards) || pack.cards.length < 2 || pack.cards.length > 4
+    || !pack.cards.every(c => c && typeof c.title === 'string' && typeof c.body === 'string')) return false;
+  if (pack.cards.length === 2) pack.cards.push({ ...pack.cards[1] });
+  if (pack.cards.length === 4) pack.cards = pack.cards.slice(0, 3);
+  return true;
+}
+
+async function main() {
+  const rows = await many("SELECT id, slug, vertical, summary_json, meta_json FROM website_analyses WHERE NOT (summary_json ? 'copy_pack') ORDER BY id");
+  console.log(`Rescuing ${rows.length} records via /no_think + 240s timeout`);
+  let ok = 0, fb = 0;
+  for (const a of rows) {
+    let pack;
+    try {
+      pack = await callOllama(buildPrompt(a));
+      if (!valid(pack)) throw new Error('invalid pack shape');
+      console.log(`  ${a.slug} → ${pack.headline} (qwen3)`);
+      ok += 1;
+    } catch (e) {
+      pack = fallbackPack(a);
+      console.log(`  ${a.slug} → ${pack.headline} (FALLBACK: ${e.message})`);
+      fb += 1;
+    }
+    const newSummary = { ...(a.summary_json || {}), copy_pack: { ...pack, generated_at: new Date().toISOString(), model: pack.fallback ? 'fallback' : MODEL } };
+    await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2', [JSON.stringify(newSummary), a.id]);
+  }
+  console.log(`\nDONE — qwen3:${ok} fallback:${fb}`);
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/flag-sole-prop-barbers.js b/scripts/flag-sole-prop-barbers.js
new file mode 100644
index 0000000..cd95f4c
--- /dev/null
+++ b/scripts/flag-sole-prop-barbers.js
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+// Flag BBCB shops whose registered name looks like a person's name —
+// "MORAGA BERNIE", "NUNEZ HAIR STYLING", "PEDRO LOPEZ BARBERSHOP". These
+// sole-proprietor shops are the ones where the chair IS the brand: SEO
+// gold for "<owner first name> barber {neighborhood}" long-tail queries.
+//
+// Heuristic (conservative — false negatives are fine, false positives are
+// noisy):
+//   1. Shop name has 2-4 tokens
+//   2. First token looks like a Latin/American/Asian SURNAME (or first name)
+//   3. NO corporate suffixes (LLC / INC / CORP / & / GROUP / SALON / etc.
+//      ARE allowed but if all 3 tokens are corporate words, reject)
+//   4. At least one token contains only letters (no digits/symbols)
+//
+// We intentionally don't try to be clever — a Census surname list would be
+// the proper way, but the flag's only purpose is "promote this shop's
+// proprietor in the page metadata", and the false-positive cost is small.
+//
+// Stores `source_data_json.shop_personality = 'sole_prop'` on hits.
+//
+//   node scripts/flag-sole-prop-barbers.js
+//   DRY=1 node scripts/flag-sole-prop-barbers.js
+
+import 'dotenv/config';
+import { query, one } from '../src/lib/db.js';
+
+const DRY = process.env.DRY === '1';
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+// Words that, if PRESENT, mean the shop is corporate-style (NOT sole-prop).
+const CORPORATE_TOKENS = new Set([
+  'LLC','INC','CORP','GROUP','HOLDINGS','PARTNERS','LP','LTD','TRUST','TRUSTEES',
+  'COLLECTIVE','COMPANY','CO','LIMITED','INCORPORATED','CORPORATION',
+  'STUDIO','STUDIOS','BAR','LOUNGE','PARLOR','PARLOUR','HOUSE',
+  // Establishment with internal salon — owner is institution, not person
+  'CLUB','COUNTRY','COMMUNITY','CHURCH','HOSPITAL','HOTEL','SCHOOL',
+  'UNIVERSITY','COLLEGE','CITY','COUNTY','RESORT','SPA',
+]);
+
+// Geographic place names commonly used as "{place} BARBER SHOP" — these
+// are PLACE-brand shops, not owner-brand sole-props. Rough list, not
+// exhaustive — only the high-frequency offenders we want to drop.
+const PLACE_TOKENS = new Set([
+  'PALISADES','MANHATTAN','VENICE','HOLLYWOOD','BRENTWOOD','MALIBU',
+  'BURBANK','GLENDALE','PASADENA','TORRANCE','LANCASTER','PALMDALE',
+  'COMPTON','CRENSHAW','INGLEWOOD','HAWTHORNE','SANTA','LOS','EAST',
+  'WEST','NORTH','SOUTH','VALLEY','DOWNEY','COVINA','POMONA','WHITTIER',
+  'NEWPORT','LAGUNA','ANAHEIM','IRVINE','RIVERSIDE','MORENO','MURRIETA',
+  'TEMECULA','CORONA','FONTANA','ONTARIO','UPLAND','RANCHO','VENTURA',
+  'OXNARD','THOUSAND','SIMI','CAMARILLO','MANILA','PHILIPPINE','VIETNAM',
+  'KOREAN','MEXICO','LATIN','CHINESE','LONG','BEACH','CARSON',
+]);
+
+// Words that are common in sole-prop names AND don't kill the signal.
+const SHOPWORDS = new Set([
+  'BARBERSHOP','BARBER','BARBERS','BARBERSHOP\'S','BARBERING',
+  'SALON','SALONS','HAIR','STYLING','STYLE','STUDIO','SHOP',
+  'CUTS','CUT','BEAUTY','SPA','NAILS','NAIL','LASH','BROW','BROWS',
+  'AND','&','BY','THE','OF','FOR','AT','ON','IN',
+]);
+
+function looksLikeSoleProp(name) {
+  if (!name) return false;
+  const tokens = name.toUpperCase().replace(/[^A-Z0-9 &'\-.]/g, '').split(/\s+/).filter(Boolean);
+  if (tokens.length < 2 || tokens.length > 4) return false;
+  // Reject if any token is corporate or a known place name
+  if (tokens.some(t => CORPORATE_TOKENS.has(t) || PLACE_TOKENS.has(t))) return false;
+  // Reject pure-numeric tokens (e.g., "SIRA 2000 HAIR SALON")
+  if (tokens.some(t => /^\d+$/.test(t))) return false;
+  // Reject possessives like "GEORGE'S" — these are usually brand-style
+  // even though they reference an owner. Edge case: legitimate sole-prop
+  // possessives also exist, but the SEO value of the flag is to surface
+  // un-branded LASTNAME FIRSTNAME registrations, not "Joe's Barber".
+  if (tokens.some(t => t.includes("'S"))) return false;
+  // Strip out generic shop words; what remains MUST be at least 2 words
+  // that are plausible name tokens (3-12 letters, all-alpha). The TWO-name
+  // requirement is what catches "MORAGA BERNIE" / "TAYLOR WILLIAM C" while
+  // rejecting brand names like "MAGIC HAIRCUTS" or "EYECANDY LASH".
+  const remaining = tokens.filter(t => !SHOPWORDS.has(t));
+  if (remaining.length < 2) return false;
+  const nameLike = remaining.filter(t => /^[A-Z]{3,12}$/.test(t));
+  if (nameLike.length < 2) return false;
+  return true;
+}
+
+async function main() {
+  log(`mode=${DRY ? 'DRY' : 'WRITE'}`);
+  // Only consider barbershops + salons that don't already carry the flag.
+  const rows = await query(
+    `SELECT id, slug, name, neighborhood
+       FROM businesses
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+        AND category IN ('barbershop','salon','beard')
+        AND (source_data_json->>'shop_personality') IS NULL`
+  );
+  log(`fetched ${rows.rows.length} candidate rows`);
+
+  let flagged = 0;
+  const samples = [];
+  for (const r of rows.rows) {
+    if (!looksLikeSoleProp(r.name)) continue;
+    flagged++;
+    if (samples.length < 20) samples.push(`${r.name} · ${r.neighborhood || '?'}`);
+    if (DRY) continue;
+    await query(
+      `UPDATE businesses
+          SET source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{shop_personality}', '"sole_prop"'),
+              updated_at = now()
+        WHERE id = $1`,
+      [r.id]
+    );
+    if (flagged % 1000 === 0) log(`  …${flagged} flagged`);
+  }
+  log(`FLAGGED: ${flagged} of ${rows.rows.length} (${(flagged / rows.rows.length * 100).toFixed(1)}%)`);
+  log(`SAMPLES:`);
+  for (const s of samples) console.log(`    ${s}`);
+
+  if (!DRY) {
+    const tally = await one(
+      `SELECT COUNT(*) FILTER (WHERE source_data_json->>'shop_personality' = 'sole_prop') AS sole_prop,
+              COUNT(*) FILTER (WHERE category IN ('barbershop','salon','beard')) AS total_chairs
+         FROM businesses
+        WHERE source_data_json->>'source' = 'dca_bbcb'`
+    );
+    log(`POST: sole_prop=${tally.sole_prop} of ${tally.total_chairs} chairs (${(tally.sole_prop/tally.total_chairs*100).toFixed(1)}%)`);
+  }
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/generate-roasts.mjs b/scripts/generate-roasts.mjs
new file mode 100644
index 0000000..4de4623
--- /dev/null
+++ b/scripts/generate-roasts.mjs
@@ -0,0 +1,112 @@
+// Roast generator — for every website_analyses row, pipe its title +
+// description + live_audit signals through qwen3:14b with a "design roast"
+// system prompt. Writes the roast to summary_json.roast.
+//
+// Used by /wall to populate the marquee + per-card roast snippet.
+//
+//   node scripts/generate-roasts.mjs              # all unset
+//   node scripts/generate-roasts.mjs --rebuild    # re-roast everyone
+//   node scripts/generate-roasts.mjs --limit 10
+
+import 'dotenv/config';
+import { many, query } from '../src/lib/db.js';
+
+const OLLAMA = 'http://192.168.1.133:11434';
+const MODEL = 'qwen3:14b';
+const args = new Set(process.argv.slice(2));
+const REBUILD = args.has('--rebuild');
+const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > 0 ? parseInt(process.argv[i+1], 10) || 0 : 0; })();
+
+const SYSTEM = `You are a stand-up comic who specializes in roasting bad website design. Your roasts are SAVAGE BUT AFFECTIONATE — you want the founder to laugh and then fix things. You have the timing of Bo Burnham and the warmth of Mitch Hedberg.
+
+Given a project's name, vertical, and known audit issues, write a roast that is:
+- Exactly ONE punchy paragraph (2-4 sentences, max 60 words)
+- Starts with a specific observation, lands with a tight one-liner
+- Specific to the project — never generic "your site is broken" filler
+- Tongue-in-cheek, never mean-spirited
+- /no_think — output the roast directly, no preamble, no <think> blocks
+
+Output ONLY the roast text. No quotes, no markdown, no JSON. Just the paragraph.`;
+
+function buildPrompt(a) {
+  const meta = a.meta_json || {};
+  const sum = a.summary_json || {};
+  const cp = sum.copy_pack || {};
+  const live = sum.live_audit || {};
+  const issues = (live.top_3_issues || []).slice(0, 3).join('; ');
+  return `/no_think
+PROJECT: ${a.slug}
+VERTICAL: ${a.vertical || 'generic'}
+HEADLINE: ${cp.headline || sum.title || meta.title || a.slug}
+TAGLINE: ${(cp.tagline || sum.tagline || meta.tagline || '').slice(0, 240)}
+LIVE-AUDIT GRADE: ${live.grade?.letter || '?'} (${live.grade?.score ?? 'no fetch'})
+KNOWN ISSUES: ${issues || '(no live audit run)'}
+
+Write the roast.`;
+}
+
+async function callOllama(prompt) {
+  const res = await fetch(`${OLLAMA}/api/generate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    signal: AbortSignal.timeout(180_000),
+    body: JSON.stringify({
+      model: MODEL,
+      system: SYSTEM,
+      prompt,
+      stream: false,
+      options: { temperature: 0.95, num_predict: 350 },
+    }),
+  });
+  if (!res.ok) throw new Error(`ollama ${res.status}`);
+  const data = await res.json();
+  let txt = (data.response || '').trim();
+  txt = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+  txt = txt.replace(/^["']|["']$/g, '');               // strip wrapping quotes
+  txt = txt.replace(/^```[a-z]*\s*|```\s*$/gi, '').trim();
+  // Some qwen3 outputs lead with "Here's a roast:" — strip prefaces.
+  txt = txt.replace(/^(here'?s|here is|roast:?|sure|okay|alright)[^.]*[:.]?\s*/i, '').trim();
+  return txt;
+}
+
+async function roastOne(a) {
+  if (!REBUILD && a.summary_json?.roast) return { slug: a.slug, status: 'skip' };
+  const t0 = Date.now();
+  let txt = await callOllama(buildPrompt(a));
+  // Retry once on bad length — qwen3 occasionally returns nothing or
+  // an over-long monologue. A second sample with same prompt usually
+  // produces a usable roast.
+  if (!txt || txt.length < 30 || txt.length > 600) {
+    txt = await callOllama(buildPrompt(a));
+  }
+  if (!txt || txt.length < 30 || txt.length > 600) throw new Error(`bad roast length ${txt?.length || 0}`);
+  await query(
+    `UPDATE website_analyses
+     SET summary_json = COALESCE(summary_json, '{}'::jsonb) || $1::jsonb,
+         updated_at=NOW()
+     WHERE id=$2`,
+    [JSON.stringify({ roast: { text: txt, model: MODEL, generated_at: new Date().toISOString() } }), a.id]
+  );
+  return { slug: a.slug, status: 'ok', ms: Date.now() - t0, preview: txt.slice(0, 80) };
+}
+
+async function main() {
+  let rows = await many('SELECT id, slug, vertical, summary_json, meta_json FROM website_analyses ORDER BY id');
+  if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+  console.log(`Roasting ${rows.length} via ${MODEL} @ ${OLLAMA} (REBUILD=${REBUILD})`);
+  let ok = 0, skip = 0, err = 0;
+  for (let i = 0; i < rows.length; i += 1) {
+    try {
+      const r = await roastOne(rows[i]);
+      if (r.status === 'ok')   { ok += 1;   console.log(`[${String(i+1).padStart(3,' ')}/${rows.length}] ${r.ms}ms  ${r.slug.padEnd(36,' ')} → ${r.preview}…`); }
+      else                     { skip += 1; }
+    } catch (e) {
+      err += 1;
+      console.error(`[${String(i+1).padStart(3,' ')}/${rows.length}] ERR  ${rows[i].slug}: ${e.message}`);
+    }
+  }
+  console.log(`\nDONE — ok:${ok} skip:${skip} err:${err}`);
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/geocode-via-nominatim.js b/scripts/geocode-via-nominatim.js
new file mode 100644
index 0000000..58fb3de
--- /dev/null
+++ b/scripts/geocode-via-nominatim.js
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+// Forward-geocode every shop with an address but no lat/lng using OSM
+// Nominatim. Free, 1 req/sec policy enforced. Runs idempotently — already-
+// geocoded rows are skipped. Built to run overnight; safe to kill + restart.
+//
+// On hit: UPDATE businesses SET latitude/longitude + source_data_json.
+// On miss: record the attempt in source_data_json.geocode_attempt='miss'
+// so we don't re-hit Nominatim every night for an unresolvable address.
+//
+//   node scripts/geocode-via-nominatim.js                # full run
+//   LIMIT=500 node scripts/geocode-via-nominatim.js      # cap for testing
+//   DRY=1 node scripts/geocode-via-nominatim.js          # show queries only
+//
+// Nominatim's usage policy:
+//   - max 1 req/sec
+//   - require valid User-Agent + email
+//   - identify yourself in the UA string
+//   - cache results (we do — DB write is the cache)
+// https://operations.osmfoundation.org/policies/nominatim/
+
+import 'dotenv/config';
+import { query, one } from '../src/lib/db.js';
+
+const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
+const UA = 'la-salon-directory/1.0 (steveabramsdesigns@gmail.com)';
+const SLEEP_MS = 1100; // 1.1s — slight buffer above 1 req/sec
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+const DRY = process.env.DRY === '1';
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// Build the canonical Nominatim query string. Cleans BBCB ALL-CAPS to
+// title-case to match OSM's data shape, drops obvious unit/suite suffixes
+// that confuse the geocoder.
+function buildQuery(b) {
+  const addr = String(b.address || '').trim()
+    .replace(/\s*(STE|SUITE|UNIT|APT|#)\s*[\w-]+/gi, '')   // drop "STE 100", "#5", etc.
+    .replace(/\s+/g, ' ')
+    .toLowerCase()
+    .replace(/\b\w/g, c => c.toUpperCase());                // title-case
+  const city = (b.city || '').trim().toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
+  const zip = (b.zip || '').trim().slice(0, 5);
+  return [addr, city, 'CA', zip].filter(Boolean).join(', ');
+}
+
+async function geocode(b) {
+  const q = buildQuery(b);
+  if (!q || q.length < 5) return { hit: false, reason: 'no-address' };
+  const url = `${NOMINATIM}?format=jsonv2&limit=1&addressdetails=0&countrycodes=us&q=${encodeURIComponent(q)}`;
+  if (DRY) {
+    return { hit: true, dry: true, q, url };
+  }
+  let res;
+  try {
+    res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'application/json' } });
+  } catch (e) {
+    return { hit: false, reason: 'fetch-error: ' + (e.message || '').slice(0, 80) };
+  }
+  if (!res.ok) {
+    if (res.status === 429) return { hit: false, reason: '429-throttled' };
+    return { hit: false, reason: 'http-' + res.status };
+  }
+  const json = await res.json().catch(() => null);
+  if (!Array.isArray(json) || json.length === 0) return { hit: false, reason: 'no-result' };
+  const top = json[0];
+  const lat = parseFloat(top.lat), lon = parseFloat(top.lon);
+  if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { hit: false, reason: 'bad-coords' };
+  // Sanity-check: result should be in CA-ish lat/lng box (roughly 32.5-42 lat,
+  // -124.5 to -114 lng). Drops out-of-state false positives that Nominatim
+  // sometimes returns when the in-state address has no exact match.
+  if (lat < 32.0 || lat > 42.5 || lon < -125 || lon > -113) {
+    return { hit: false, reason: 'out-of-CA: ' + lat.toFixed(2) + ',' + lon.toFixed(2) };
+  }
+  return { hit: true, lat, lon, importance: top.importance, display: top.display_name };
+}
+
+async function main() {
+  log(`mode=${DRY ? 'DRY' : 'WRITE'}${LIMIT ? ` limit=${LIMIT}` : ''}`);
+  const rows = await query(
+    `SELECT id, slug, name, address, city, state, zip
+       FROM businesses
+      WHERE latitude IS NULL
+        AND address IS NOT NULL AND address <> ''
+        AND city IS NOT NULL AND city <> ''
+        AND (source_data_json->>'geocode_attempt') IS NULL
+      ORDER BY id
+      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
+  );
+  log(`fetched ${rows.rows.length} candidate rows · estimated runtime: ${(rows.rows.length * SLEEP_MS / 1000 / 60).toFixed(0)} min`);
+
+  let hits = 0, misses = 0, errored = 0;
+  let i = 0;
+  for (const r of rows.rows) {
+    i++;
+    const result = await geocode(r);
+    if (result.hit && !result.dry) {
+      await query(
+        `UPDATE businesses
+            SET latitude = $1, longitude = $2,
+                source_data_json = jsonb_set(jsonb_set(COALESCE(source_data_json,'{}'::jsonb),
+                  '{geocode_source}', '"nominatim"'),
+                  '{geocode_attempt}', '"hit"'),
+                updated_at = now()
+          WHERE id = $3`,
+        [result.lat, result.lon, r.id]
+      );
+      hits++;
+    } else if (result.dry) {
+      hits++;
+    } else if (!DRY) {
+      // Mark the miss so we don't retry forever.
+      await query(
+        `UPDATE businesses
+            SET source_data_json = jsonb_set(COALESCE(source_data_json,'{}'::jsonb),
+                  '{geocode_attempt}', to_jsonb($1::text)),
+                updated_at = now()
+          WHERE id = $2`,
+        [`miss:${result.reason || '?'}`, r.id]
+      );
+      misses++;
+    } else {
+      misses++;
+    }
+    if (i % 50 === 0) log(`  …${i}/${rows.rows.length} hits=${hits} misses=${misses}`);
+    if (DRY && i >= 5) {
+      log(`DRY mode — sample queries shown, exiting after first 5`);
+      break;
+    }
+    if (!DRY) await sleep(SLEEP_MS);
+  }
+  log(`DONE: scanned=${i} hits=${hits} misses=${misses} errored=${errored}`);
+  if (!DRY) {
+    const tally = await one(`SELECT COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded,
+                                    COUNT(*) AS total FROM businesses`);
+    log(`POST: ${tally.geocoded}/${tally.total} businesses geocoded (${(tally.geocoded/tally.total*100).toFixed(1)}%)`);
+  }
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/ingest-bbcb-salons.js b/scripts/ingest-bbcb-salons.js
new file mode 100644
index 0000000..9cf2970
--- /dev/null
+++ b/scripts/ingest-bbcb-salons.js
@@ -0,0 +1,234 @@
+#!/usr/bin/env node
+// Ingest CA Board of Barbering & Cosmetology (BBCB) Establishment licenses
+// for LA + 4 adjacent counties. Source: CA DCA Public Information bulk export
+// (free, no CPRA, refreshes monthly).
+//
+// Per la-research-agent (2026-05-04):
+//   GET https://www.dca.ca.gov/boxToken/getToken           → Bearer token
+//   GET https://api.box.com/2.0/files/2216160745307/content → Data00 ~77MB
+//
+// File is tab-delimited UTF-8 with `.xls` extension (NOT binary Excel).
+// 20 columns:
+//   0  Agency Code
+//   1  Agency Name
+//   2  LicTypeCode
+//   3  License Type
+//   4  License Number
+//   5  Indiv/Org             ← filter to 'O'
+//   6  Org/Last Name
+//   7  First Name
+//   8  Middle Name
+//   9  Suffix
+//   10 Address Line 1
+//   11 Address Line 2
+//   12 City
+//   13 County                ← filter to 5 counties
+//   14 State
+//   15 Zip
+//   16 Country
+//   17 Original Issue Date
+//   18 Expiration Date
+//   19 License Status        ← filter to 'Current'
+//
+// Expected: 23,370 active shops across LA + Orange + Riverside + SB + Ventura.
+//
+//   node scripts/ingest-bbcb-salons.js              # full ingest, upsert mode
+//   DRY=1 node scripts/ingest-bbcb-salons.js        # parse + filter, no DB writes
+//   LIMIT=200 node scripts/ingest-bbcb-salons.js    # cap row count for testing
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import readline from 'node:readline';
+import { query, one } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const TARGET_COUNTIES = new Set(['LOS ANGELES','ORANGE','VENTURA','SAN BERNARDINO','RIVERSIDE']);
+const SHOP_TYPES      = new Set(['Establishment','Barber Shop','Chain Establishment']);
+const TOKEN_URL       = 'https://www.dca.ca.gov/boxToken/getToken';
+const FILE_IDS        = ['2216160745307']; // Data00 only — Data01 is individuals (no orgs)
+const TMP_DIR         = '/tmp/bbcb-ingest';
+const SOURCE          = 'dca_bbcb';
+const SOURCE_URL      = 'https://www.dca.ca.gov/consumers/public_info/index.html';
+
+const DRY   = process.env.DRY === '1';
+const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(msg) { console.log(`[${ts()}] ${msg}`); }
+
+// Map BBCB license type to our category enum.
+function categoryFromLicType(licType) {
+  return licType === 'Barber Shop' ? 'barbershop' : 'salon';
+}
+
+// "01/15/2014" or "1/15/2014" or "" → years (int) or null.
+function yearsFromIssueDate(s) {
+  if (!s) return null;
+  const m = String(s).match(/(\d{4})\s*$/);
+  if (!m) return null;
+  const y = parseInt(m[1], 10);
+  if (!y || y < 1900 || y > 2100) return null;
+  return new Date().getFullYear() - y;
+}
+
+// Robust slug: name + zip is the agent's recipe; on collision the unique-index
+// catches it and we fall back to appending the license number.
+function makeSlug(name, zip, licNum) {
+  const base = slugify(name) || 'salon';
+  const zipPart = (zip || '').slice(0, 5);
+  return zipPart ? `${base}-${zipPart}` : `${base}-${licNum}`;
+}
+
+// === Box token + download ==========================================
+async function getBoxToken() {
+  const res = await fetch(TOKEN_URL);
+  if (!res.ok) throw new Error(`token endpoint HTTP ${res.status}`);
+  const tok = (await res.text()).trim();
+  if (!tok || tok.length < 30) throw new Error(`unexpected token: "${tok.slice(0, 80)}"`);
+  return tok;
+}
+
+async function downloadFile(fileId, token, outPath) {
+  const url = `https://api.box.com/2.0/files/${fileId}/content`;
+  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' });
+  if (!res.ok) throw new Error(`Box file ${fileId} HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`);
+  // Node's native fetch returns a Web ReadableStream — not a Node stream — so
+  // res.body.pipe() doesn't exist. arrayBuffer() is fine here (77MB max).
+  const buf = Buffer.from(await res.arrayBuffer());
+  fs.writeFileSync(outPath, buf);
+  log(`  downloaded ${fileId} → ${outPath} (${(buf.length / 1e6).toFixed(1)} MB)`);
+}
+
+// === Row processing =================================================
+async function processFile(filePath) {
+  const rl = readline.createInterface({ input: fs.createReadStream(filePath) });
+  let lineNum = 0;
+  let matched = 0, inserted = 0, updated = 0, skipped = 0, errors = 0;
+  let countyHits = {};
+
+  for await (const raw of rl) {
+    lineNum++;
+    if (lineNum === 1) continue; // header
+
+    const f = raw.split('\t');
+    if (f.length < 20) { skipped++; continue; }
+
+    const [, , , licTypeRaw, licNum, indivOrg, orgName, , , ,
+           addr1, addr2, cityRaw, countyRaw, stateRaw, zipRaw, ,
+           issueDate, , statusRaw] = f;
+
+    if (indivOrg !== 'O') continue;
+    const licType = (licTypeRaw || '').trim();
+    if (!SHOP_TYPES.has(licType)) continue;
+    const county = (countyRaw || '').trim().toUpperCase();
+    if (!TARGET_COUNTIES.has(county)) continue;
+    const status = (statusRaw || '').trim();
+    if (status !== 'Current') continue;
+
+    matched++;
+    countyHits[county] = (countyHits[county] || 0) + 1;
+    if (LIMIT && matched > LIMIT) break;
+
+    const name = (orgName || '').trim();
+    if (!name) { skipped++; continue; }
+    const address = [addr1, addr2].map(s => (s || '').trim()).filter(Boolean).join(', ');
+    const city = (cityRaw || '').trim();
+    const state = (stateRaw || '').trim() || 'CA';
+    const zip = (zipRaw || '').trim().slice(0, 10);
+    const slug = makeSlug(name, zip, (licNum || '').trim());
+    const category = categoryFromLicType(licType);
+    const years = yearsFromIssueDate(issueDate);
+
+    const sourceData = {
+      source: SOURCE,
+      source_id: (licNum || '').trim(),
+      source_url: SOURCE_URL,
+      tier: 'A',
+      retrieved_at: new Date().toISOString(),
+      license_type: licType,
+      license_number: (licNum || '').trim(),
+      issue_date: (issueDate || '').trim(),
+      license_status: status,
+      county,
+    };
+
+    if (DRY) {
+      if (matched <= 12) console.log(`  ${slug.padEnd(60)} ${name} · ${city}, ${county}`);
+      continue;
+    }
+
+    try {
+      // Try INSERT; on slug collision, UPDATE only the source-bearing fields.
+      const r = await query(
+        `INSERT INTO businesses
+           (slug, name, category, address, city, state, zip,
+            neighborhood, years_in_business, source_data_json, tier)
+         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'free')
+         ON CONFLICT (slug) DO UPDATE SET
+           name = EXCLUDED.name,
+           address = EXCLUDED.address,
+           city = EXCLUDED.city,
+           zip = EXCLUDED.zip,
+           neighborhood = COALESCE(businesses.neighborhood, EXCLUDED.neighborhood),
+           source_data_json = EXCLUDED.source_data_json,
+           updated_at = now()
+         RETURNING (xmax = 0) AS was_insert`,
+        [slug, name, category, address, city, state, zip,
+         county, years, JSON.stringify(sourceData)]
+      );
+      if (r.rows[0]?.was_insert) inserted++; else updated++;
+    } catch (e) {
+      errors++;
+      if (errors <= 5) log(`  ERR ${slug}: ${e.message.slice(0, 200)}`);
+    }
+
+    if ((inserted + updated) > 0 && (inserted + updated) % 500 === 0) {
+      log(`  …progress: ins=${inserted} upd=${updated} err=${errors}`);
+    }
+  }
+
+  log(`COUNTY HITS: ${Object.entries(countyHits).map(([c, n]) => `${c}=${n}`).join(' ')}`);
+  log(`DONE ${path.basename(filePath)}: matched=${matched} inserted=${inserted} updated=${updated} skipped=${skipped} errors=${errors}`);
+  return { matched, inserted, updated, skipped, errors };
+}
+
+// === Main ==========================================================
+async function main() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+  log(`mode=${DRY ? 'DRY' : 'WRITE'}${LIMIT ? ` limit=${LIMIT}` : ''}`);
+
+  log('fetching DCA Box token…');
+  const token = await getBoxToken();
+  log(`token len=${token.length} (head: ${token.slice(0, 12)}…)`);
+
+  const totals = { matched: 0, inserted: 0, updated: 0, skipped: 0, errors: 0 };
+  for (const fileId of FILE_IDS) {
+    const outPath = path.join(TMP_DIR, `bbcb_${fileId}.tsv`);
+    if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 1e6) {
+      log(`downloading file ${fileId}…`);
+      await downloadFile(fileId, token, outPath);
+    } else {
+      log(`reusing cached ${outPath} (${(fs.statSync(outPath).size / 1e6).toFixed(1)} MB)`);
+    }
+    const r = await processFile(outPath);
+    for (const k of Object.keys(totals)) totals[k] += r[k];
+  }
+
+  log(`TOTAL: matched=${totals.matched} inserted=${totals.inserted} updated=${totals.updated} skipped=${totals.skipped} errors=${totals.errors}`);
+
+  if (!DRY) {
+    const tally = await one(`
+      SELECT
+        COUNT(*) AS total,
+        COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb') AS bbcb,
+        COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops,
+        COUNT(*) FILTER (WHERE category = 'salon') AS salons
+      FROM businesses
+    `);
+    log(`DB now: total=${tally.total}  bbcb=${tally.bbcb}  barbershops=${tally.barbershops}  salons=${tally.salons}`);
+  }
+  process.exit(totals.errors > totals.inserted + totals.updated ? 1 : 0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/ingest-hollywood-corridor.js b/scripts/ingest-hollywood-corridor.js
new file mode 100644
index 0000000..b147047
--- /dev/null
+++ b/scripts/ingest-hollywood-corridor.js
@@ -0,0 +1,308 @@
+#!/usr/bin/env node
+// Hollywood Blvd Corridor ingest — every registered business on the ~5-mile
+// stretch of Hollywood Boulevard from East Hollywood (Vermont) west through
+// the Walk of Fame to Laurel Canyon Blvd in the Hollywood Hills.
+//
+// Hollywood Blvd is short but iconic — five miles of marquees, sound stages,
+// and salons through:
+//   - East Hollywood (Vermont/Hollywood, near LACC)
+//   - Thai Town
+//   - Central Hollywood (Vine, Pantages, Capitol Records)
+//   - The Walk of Fame mile (Highland, Chinese Theater, Dolby)
+//   - La Brea / west Hollywood Blvd
+//   - Hollywood Hills frontage to Laurel Canyon Blvd
+//
+// "Registered with the city" = LA Office of Finance BTRC. Same approximation
+// pattern as Sunset/Wilshire: pull every commercial OSM presence on/near
+// Hollywood Blvd, tag them `corridor: 'hollywood-blvd-5mi'` for filtering.
+//
+//   node scripts/ingest-hollywood-corridor.js          # full corridor ingest
+//   DRY=1 node scripts/ingest-hollywood-corridor.js    # parse only
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { query, one } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const TMP_DIR = '/tmp/osm-ingest';
+const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const CORRIDOR_TAG = 'hollywood-blvd-5mi';
+
+// Bounding box covering Hollywood Blvd from East Hollywood (Vermont, ~ -118.290)
+// west to Laurel Canyon Blvd in the Hollywood Hills (~ -118.380). Hollywood Blvd
+// is essentially flat east-west, so the latitude band is narrow (34.090 - 34.108).
+// Overpass uses "south,west,north,east".
+const BBOX = '34.090,-118.380,34.108,-118.290';
+
+// Reverse-mapping OSM tag → our category. Matches Wilshire corridor exactly so
+// the editorial UI behaves the same.
+const SHOP_TAG_TO_CATEGORY = {
+  hairdresser: 'salon', beauty: 'salon', massage: 'spa', cosmetics: 'salon',
+  // Eat/drink
+  restaurant: 'restaurant', cafe: 'cafe', bakery: 'bakery', deli: 'deli',
+  bar: 'bar', pub: 'bar', fast_food: 'restaurant', ice_cream: 'cafe',
+  // Retail
+  clothes: 'retail', shoes: 'retail', jewelry: 'retail', books: 'retail',
+  florist: 'retail', gift: 'retail', wine: 'retail', alcohol: 'retail',
+  optician: 'retail', furniture: 'retail', boutique: 'retail',
+  supermarket: 'grocery', convenience: 'grocery',
+  // Services
+  dry_cleaning: 'service', laundry: 'service', tailor: 'service',
+  car_repair: 'service', car: 'service',
+  // Health / wellness
+  pharmacy: 'pharmacy', chemist: 'pharmacy',
+  // Pets
+  pet: 'retail',
+};
+const AMENITY_TAG_TO_CATEGORY = {
+  restaurant: 'restaurant', cafe: 'cafe', bar: 'bar', pub: 'bar',
+  fast_food: 'restaurant', ice_cream: 'cafe', food_court: 'restaurant',
+  bank: 'finance', atm: 'finance',
+  pharmacy: 'pharmacy', dentist: 'health', doctors: 'health', clinic: 'health', veterinary: 'health',
+  spa: 'spa',
+  gym: 'fitness', fitness_centre: 'fitness',
+  cinema: 'entertainment', theatre: 'entertainment', nightclub: 'entertainment',
+  fuel: 'service', car_wash: 'service',
+};
+
+const DRY = process.env.DRY === '1';
+
+// BBCB zip codes that fall along Hollywood Blvd. Used in the SQL post-step to
+// retroactively flag DCA-licensed shops that are already in the DB but
+// weren't tagged with the corridor (their `addr:street` from BBCB usually
+// mentions "Hollywood" if they're actually on the strip).
+//
+// 90027 = Los Feliz (east end touches Hollywood Blvd near Vermont)
+// 90028 = central Hollywood (Walk of Fame, Vine, Highland)
+// 90029 = East Hollywood (Vermont through Western)
+// 90038 = south Hollywood (the south face of Hollywood Blvd west of Vine)
+// 90068 = Hollywood Hills (the western terminus toward Laurel Canyon)
+const HOLLYWOOD_ZIPS = [
+  '90027', // Los Feliz
+  '90028', // central Hollywood (Walk of Fame)
+  '90029', // East Hollywood
+  '90038', // south Hollywood
+  '90068', // Hollywood Hills
+];
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+function buildQuery() {
+  return `[out:json][timeout:240];
+(
+  node["shop"](${BBOX});
+  way["shop"](${BBOX});
+  node["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  way["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  node["leisure"="spa"](${BBOX});
+  way["leisure"="spa"](${BBOX});
+  node["office"](${BBOX});
+  way["office"](${BBOX});
+);
+out center tags;`;
+}
+
+async function fetchData() {
+  const cachePath = path.join(TMP_DIR, 'hollywood-corridor.json');
+  if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
+    log(`reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
+    return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
+  }
+  log('querying Overpass for the entire Hollywood Blvd corridor (~30-90s)…');
+  const body = 'data=' + encodeURIComponent(buildQuery());
+  const res = await fetch(OVERPASS_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Accept': 'application/json',
+      'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
+    },
+    body,
+  });
+  if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`);
+  const j = await res.json();
+  fs.writeFileSync(cachePath, JSON.stringify(j));
+  log(`${j.elements?.length || 0} elements, cached → ${cachePath}`);
+  return j;
+}
+
+// Determine category from OSM tags. Priority: shop → amenity → leisure → office.
+function categorizeFromTags(t) {
+  if (t.shop && SHOP_TAG_TO_CATEGORY[t.shop])         return { cat: SHOP_TAG_TO_CATEGORY[t.shop], rawTag: 'shop=' + t.shop };
+  if (t.amenity && AMENITY_TAG_TO_CATEGORY[t.amenity]) return { cat: AMENITY_TAG_TO_CATEGORY[t.amenity], rawTag: 'amenity=' + t.amenity };
+  if (t.leisure === 'spa')                            return { cat: 'spa', rawTag: 'leisure=spa' };
+  if (t.office)                                       return { cat: 'office', rawTag: 'office=' + t.office };
+  // Catch-all: any shop=* tag we didn't enumerate
+  if (t.shop)                                         return { cat: 'retail', rawTag: 'shop=' + t.shop };
+  return null;
+}
+
+// Address-based filter: only keep elements where addr:street contains
+// "Hollywood" (Hollywood Blvd, Hollywood Boulevard, etc.) — this excludes
+// everything within the bbox that's on a side street.
+function isOnHollywoodBlvd(t) {
+  const street = (t['addr:street'] || '').toLowerCase();
+  return /hollywood/.test(street);
+}
+
+function refineSalonCategory(rawTag, name, base) {
+  if (base !== 'salon') return base;
+  if (/\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/i.test(name || '')) return 'barbershop';
+  return 'salon';
+}
+
+function makeSlug(name, zip, osmId) {
+  const base = slugify(name) || 'biz';
+  if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
+  return `osm-${osmId}-${base}`.slice(0, 200);
+}
+
+function processElement(el) {
+  const t = el.tags || {};
+  const name = (t.name || '').trim();
+  if (!name) return null;
+  const cat = categorizeFromTags(t);
+  if (!cat) return null;
+  if (!isOnHollywoodBlvd(t)) return null;
+
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lon = el.lon ?? el.center?.lon ?? null;
+  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
+  const city = (t['addr:city'] || '').trim();
+  const state = (t['addr:state'] || 'CA').trim();
+  const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
+  const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
+  const website = (t.website || t['contact:website'] || '').trim() || null;
+
+  const finalCat = refineSalonCategory(cat.rawTag, name, cat.cat);
+
+  return {
+    slug: makeSlug(name, zip, el.id),
+    name,
+    category: finalCat,
+    address: street || null,
+    city: city || null,
+    state,
+    zip: zip || null,
+    neighborhood: city || null,
+    latitude: lat,
+    longitude: lon,
+    phone,
+    website,
+    source_data_json: {
+      source: 'osm-corridor',
+      source_id: `${el.type}/${el.id}`,
+      source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
+      tier: 'B',
+      retrieved_at: new Date().toISOString(),
+      raw_tag: cat.rawTag,
+      corridor: CORRIDOR_TAG,
+      tags: t,
+    },
+  };
+}
+
+async function upsert(row) {
+  const r = await query(
+    `INSERT INTO businesses
+       (slug, name, category, address, city, state, zip, neighborhood,
+        latitude, longitude, phone, website, source_data_json, tier)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude       = COALESCE(businesses.latitude, EXCLUDED.latitude),
+       longitude      = COALESCE(businesses.longitude, EXCLUDED.longitude),
+       phone          = COALESCE(businesses.phone, EXCLUDED.phone),
+       website        = COALESCE(businesses.website, EXCLUDED.website),
+       neighborhood   = COALESCE(EXCLUDED.neighborhood, businesses.neighborhood),
+       address        = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
+       source_data_json = jsonb_set(
+         COALESCE(businesses.source_data_json, '{}'::jsonb),
+         '{corridor}', to_jsonb(${"'" + CORRIDOR_TAG + "'"}::text)
+       ),
+       updated_at     = now()
+     RETURNING (xmax = 0) AS was_insert`,
+    [row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
+     row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
+     JSON.stringify(row.source_data_json)]
+  );
+  return r.rows[0]?.was_insert;
+}
+
+// Tag any DCA-licensed BBCB shop whose zip falls in the Hollywood corridor zip
+// list AND whose address contains "hollywood". Mirrors the Wilshire post-step.
+async function tagBbcbAlongHollywood() {
+  const zipPattern = '^(' + HOLLYWOOD_ZIPS.join('|') + ')';
+  const r = await query(
+    `UPDATE businesses
+        SET source_data_json = jsonb_set(
+              COALESCE(source_data_json, '{}'::jsonb),
+              '{corridor}',
+              to_jsonb($2::text)
+            ),
+            updated_at = now()
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+        AND zip ~ $1
+        AND address ~* 'hollywood'
+      RETURNING slug`,
+    [zipPattern, CORRIDOR_TAG]
+  );
+  return r.rowCount;
+}
+
+async function main() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+  log(`mode=${DRY ? 'DRY' : 'WRITE'} corridor=${CORRIDOR_TAG} bbox=${BBOX}`);
+
+  const data = await fetchData();
+  let matched = 0, inserted = 0, updated = 0, skippedNotOnHollywood = 0, skippedNoCat = 0;
+  const byCategory = {};
+  const byCity = {};
+
+  for (const el of data.elements || []) {
+    const t = el.tags || {};
+    const cat = categorizeFromTags(t);
+    if (!cat || !t.name) { skippedNoCat++; continue; }
+    if (!isOnHollywoodBlvd(t)) { skippedNotOnHollywood++; continue; }
+
+    const row = processElement(el);
+    if (!row) continue;
+    matched++;
+    byCategory[row.category] = (byCategory[row.category] || 0) + 1;
+    if (row.city) byCity[row.city] = (byCity[row.city] || 0) + 1;
+
+    if (DRY) {
+      if (matched <= 12) console.log(`  ${(row.slug || '').padEnd(60)} ${row.name} · ${row.category} · ${row.city || '?'}`);
+      continue;
+    }
+    try {
+      const ins = await upsert(row);
+      if (ins) inserted++; else updated++;
+    } catch (e) {
+      log(`  ERR ${row.slug}: ${e.message.slice(0, 200)}`);
+    }
+  }
+
+  log(`MATCHED=${matched} inserted=${inserted} updated=${updated}`);
+  log(`SKIPPED: not_on_hollywood=${skippedNotOnHollywood} no_category=${skippedNoCat}`);
+  log(`BY CATEGORY: ${Object.entries(byCategory).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+  log(`BY CITY: ${Object.entries(byCity).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+
+  let bbcbTagged = 0;
+  if (!DRY) {
+    bbcbTagged = await tagBbcbAlongHollywood();
+    log(`BBCB-along-Hollywood tagged: ${bbcbTagged}`);
+
+    const tally = await one(`
+      SELECT COUNT(*) AS n,
+             COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded
+        FROM businesses
+       WHERE source_data_json->>'corridor' = $1
+    `, [CORRIDOR_TAG]).catch(() => null);
+    if (tally) log(`DB now: corridor=${tally.n} geocoded=${tally.geocoded}`);
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/ingest-osm-salons.js b/scripts/ingest-osm-salons.js
new file mode 100644
index 0000000..6ce24dd
--- /dev/null
+++ b/scripts/ingest-osm-salons.js
@@ -0,0 +1,249 @@
+#!/usr/bin/env node
+// Ingest salon-adjacent businesses from OpenStreetMap via Overpass API.
+//
+// Why this complements the BBCB ingest:
+//   - OSM has shops that BBCB doesn't (massage parlors, day spas, lash/brow
+//     studios, blowout bars not registered as full establishments)
+//   - OSM provides lat/lng — BBCB only gives addresses, so we use OSM coords
+//     to backfill geocoding on shops that match by name+zip
+//   - Free, no API key, single bulk query
+//
+// Targets: 5 counties — LA, Orange, Riverside, San Bernardino, Ventura.
+//
+// OSM tags we treat as salons:
+//   shop=hairdresser  → barbershop or salon
+//   shop=beauty       → salon (catch-all: nails, lashes, brows, waxing, esthetics)
+//   shop=massage      → spa
+//   amenity=spa       → spa
+//   leisure=spa       → spa
+//
+//   node scripts/ingest-osm-salons.js               # full ingest
+//   DRY=1 node scripts/ingest-osm-salons.js         # parse only, no DB writes
+//   COUNTY=LA node scripts/ingest-osm-salons.js     # one county at a time
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { query, one } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const TMP_DIR = '/tmp/osm-ingest';
+const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+
+const COUNTIES = {
+  LA:           { name: 'Los Angeles County',      key: 'LOS ANGELES' },
+  ORANGE:       { name: 'Orange County',           key: 'ORANGE' },
+  RIVERSIDE:    { name: 'Riverside County',        key: 'RIVERSIDE' },
+  SAN_BERNARDINO:{ name: 'San Bernardino County',  key: 'SAN BERNARDINO' },
+  VENTURA:      { name: 'Ventura County',          key: 'VENTURA' },
+};
+
+const TAG_TO_CATEGORY = {
+  hairdresser: 'salon',  // OSM uses hairdresser for both barbers AND hair salons; refine via name
+  beauty:      'salon',
+  massage:     'spa',
+};
+
+const DRY = process.env.DRY === '1';
+const ONLY = process.env.COUNTY ? process.env.COUNTY.toUpperCase() : null;
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+// Build Overpass QL for one county.
+function buildQuery(countyName) {
+  return `[out:json][timeout:180];
+area["name"="${countyName}"]["admin_level"="6"]->.a;
+(
+  node["shop"="hairdresser"](area.a);
+  way["shop"="hairdresser"](area.a);
+  node["shop"="beauty"](area.a);
+  way["shop"="beauty"](area.a);
+  node["shop"="massage"](area.a);
+  way["shop"="massage"](area.a);
+  node["amenity"="spa"](area.a);
+  way["amenity"="spa"](area.a);
+  node["leisure"="spa"](area.a);
+  way["leisure"="spa"](area.a);
+);
+out center tags;`;
+}
+
+// Cache the Overpass response per county so re-runs don't hammer the public API.
+async function fetchCounty(countyKey, countyName) {
+  const cachePath = path.join(TMP_DIR, `osm_${countyKey.toLowerCase().replace(/_/g, '-')}.json`);
+  if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
+    log(`  ${countyKey}: reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
+    return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
+  }
+  log(`  ${countyKey}: querying Overpass (~30-60s)…`);
+  // Overpass 406s without explicit Accept + User-Agent (anti-abuse heuristic).
+  const body = 'data=' + encodeURIComponent(buildQuery(countyName));
+  const res = await fetch(OVERPASS_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Accept': 'application/json',
+      'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
+    },
+    body,
+  });
+  if (!res.ok) throw new Error(`Overpass HTTP ${res.status} for ${countyKey}`);
+  const j = await res.json();
+  fs.writeFileSync(cachePath, JSON.stringify(j));
+  log(`  ${countyKey}: ${j.elements?.length || 0} elements, cached → ${cachePath}`);
+  return j;
+}
+
+// Heuristic: "barbershop" if name contains barber/cuts/fade keywords AND tag is hairdresser.
+function refineCategory(rawTag, name) {
+  const n = String(name || '').toLowerCase();
+  if (rawTag === 'hairdresser' && /\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/.test(n)) return 'barbershop';
+  return TAG_TO_CATEGORY[rawTag] || 'salon';
+}
+
+function makeSlug(name, zip, osmId) {
+  const base = slugify(name) || 'salon';
+  if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
+  return `osm-${osmId}-${base}`.slice(0, 200);
+}
+
+// === Per-element processor =========================================
+function processElement(el, countyKey) {
+  const t = el.tags || {};
+  const name = (t.name || '').trim();
+  if (!name) return null;
+
+  // Coordinates: nodes have lat/lon directly; ways carry .center.lat / .center.lon
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lon = el.lon ?? el.center?.lon ?? null;
+
+  // Determine the raw tag we matched on (priority order matches the Overpass query)
+  let rawTag = null;
+  if (t.shop === 'hairdresser')  rawTag = 'hairdresser';
+  else if (t.shop === 'beauty')  rawTag = 'beauty';
+  else if (t.shop === 'massage') rawTag = 'massage';
+  else if (t.amenity === 'spa')  rawTag = 'massage';
+  else if (t.leisure === 'spa')  rawTag = 'massage';
+  else return null;
+
+  const category = refineCategory(rawTag, name);
+  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
+  const city = (t['addr:city'] || '').trim();
+  const state = (t['addr:state'] || 'CA').trim();
+  const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
+  const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
+  const website = (t.website || t['contact:website'] || '').trim() || null;
+
+  return {
+    slug: makeSlug(name, zip, el.id),
+    name,
+    category,
+    address: street || null,
+    city: city || null,
+    state,
+    zip: zip || null,
+    neighborhood: countyKey === 'LA' ? null : COUNTIES[countyKey].key, // leave LA neighborhoods blank for now (real LA neighborhoods need geocoding)
+    latitude: lat,
+    longitude: lon,
+    phone,
+    website,
+    source_data_json: {
+      source: 'osm',
+      source_id: `${el.type}/${el.id}`,
+      source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
+      tier: 'B',
+      retrieved_at: new Date().toISOString(),
+      raw_tag: rawTag,
+      county_search: COUNTIES[countyKey].key,
+      tags: t,
+    },
+  };
+}
+
+async function upsert(row) {
+  // Use COALESCE to NEVER overwrite a non-null with a null on update — this
+  // matters when an existing BBCB row gets matched by slug and we just want
+  // to fill in lat/lng + phone + website without nuking the BBCB metadata.
+  const r = await query(
+    `INSERT INTO businesses
+       (slug, name, category, address, city, state, zip, neighborhood,
+        latitude, longitude, phone, website, source_data_json, tier)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude       = COALESCE(businesses.latitude, EXCLUDED.latitude),
+       longitude      = COALESCE(businesses.longitude, EXCLUDED.longitude),
+       phone          = COALESCE(businesses.phone, EXCLUDED.phone),
+       website        = COALESCE(businesses.website, EXCLUDED.website),
+       address        = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
+       city           = COALESCE(NULLIF(businesses.city,''), EXCLUDED.city),
+       zip            = COALESCE(NULLIF(businesses.zip,''), EXCLUDED.zip),
+       updated_at     = now()
+     RETURNING (xmax = 0) AS was_insert`,
+    [row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
+     row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
+     JSON.stringify(row.source_data_json)]
+  );
+  return r.rows[0]?.was_insert;
+}
+
+// === Main ==========================================================
+async function main() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+  log(`mode=${DRY ? 'DRY' : 'WRITE'}${ONLY ? ` only=${ONLY}` : ''}`);
+
+  const counties = ONLY ? [[ONLY, COUNTIES[ONLY]]] : Object.entries(COUNTIES);
+  if (ONLY && !COUNTIES[ONLY]) { console.error('unknown COUNTY:', ONLY); process.exit(2); }
+
+  const totals = { matched: 0, inserted: 0, updated: 0, skipped: 0 };
+  const byCategory = {};
+
+  for (const [key, def] of counties) {
+    const data = await fetchCounty(key, def.name);
+    let countyMatched = 0, countyInserted = 0, countyUpdated = 0;
+
+    for (const el of data.elements || []) {
+      const row = processElement(el, key);
+      if (!row) { totals.skipped++; continue; }
+      countyMatched++;
+      byCategory[row.category] = (byCategory[row.category] || 0) + 1;
+
+      if (DRY) {
+        if (countyMatched <= 5) console.log(`  ${row.slug.padEnd(50)} ${row.name} · ${row.city || '?'} · ${row.category} · ${row.latitude?.toFixed(4) || '—'},${row.longitude?.toFixed(4) || '—'}`);
+        continue;
+      }
+      try {
+        const inserted = await upsert(row);
+        if (inserted) countyInserted++;
+        else countyUpdated++;
+      } catch (e) {
+        if (totals.matched < 5) log(`  ERR ${row.slug}: ${e.message.slice(0, 200)}`);
+      }
+    }
+    totals.matched += countyMatched;
+    totals.inserted += countyInserted;
+    totals.updated += countyUpdated;
+    log(`  ${key}: matched=${countyMatched} inserted=${countyInserted} updated=${countyUpdated}`);
+  }
+
+  log(`TOTAL: matched=${totals.matched} inserted=${totals.inserted} updated=${totals.updated} skipped=${totals.skipped}`);
+  log(`BY CATEGORY: ${Object.entries(byCategory).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+
+  if (!DRY) {
+    const tally = await one(`
+      SELECT
+        COUNT(*) AS total,
+        COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb') AS bbcb,
+        COUNT(*) FILTER (WHERE source_data_json->>'source' = 'osm') AS osm,
+        COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded,
+        COUNT(*) FILTER (WHERE category = 'spa') AS spas,
+        COUNT(*) FILTER (WHERE category = 'salon') AS salons,
+        COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops
+      FROM businesses
+    `);
+    log(`DB now: total=${tally.total} bbcb=${tally.bbcb} osm=${tally.osm} geocoded=${tally.geocoded} salons=${tally.salons} barbershops=${tally.barbershops} spas=${tally.spas}`);
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/ingest-sunset-corridor.js b/scripts/ingest-sunset-corridor.js
new file mode 100644
index 0000000..12c227b
--- /dev/null
+++ b/scripts/ingest-sunset-corridor.js
@@ -0,0 +1,314 @@
+#!/usr/bin/env node
+// Sunset Blvd Corridor ingest — every registered business on the ~22-mile
+// stretch of Sunset Boulevard from Echo Park to the Pacific Ocean.
+//
+// Sunset Blvd runs ~22 miles continuously from Sunset/Figueroa (Echo Park,
+// downtown) west through Silver Lake → Hollywood → Sunset Strip
+// (West Hollywood) → Beverly Hills → Bel-Air → Brentwood → Pacific
+// Palisades, ending at Pacific Coast Highway. Multiple jurisdictions on this
+// single street:
+//   - City of Los Angeles (Echo Park, Silver Lake, Hollywood, Bel-Air,
+//     Brentwood, Pacific Palisades neighborhoods)
+//   - City of West Hollywood (Sunset Strip)
+//   - City of Beverly Hills
+//
+// "Registered with the city" = LA Office of Finance BTRC for City-of-LA stretches,
+// WeHo and Beverly Hills business licenses for those jurisdictions. Neither has
+// a public bulk feed (per la-research-agent), so we approximate by pulling
+// every commercial presence on/near Sunset Blvd from OpenStreetMap and tagging
+// them `corridor: 'sunset-blvd-22mi'` for filtering.
+//
+// Tag taxonomy: ALL business types — not just salons. Restaurants, cafes,
+// banks, dry cleaners, gyms, dentists, retail. Whatever has an OSM presence
+// on the strip.
+//
+//   node scripts/ingest-sunset-corridor.js          # full corridor ingest
+//   DRY=1 node scripts/ingest-sunset-corridor.js    # parse only
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { query, one } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const TMP_DIR = '/tmp/osm-ingest';
+const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const CORRIDOR_TAG = 'sunset-blvd-22mi';
+
+// Bounding box covering Sunset Blvd from Echo Park (east end, ~ -118.26)
+// to Pacific Coast Highway (west end, ~ -118.55). Roughly 22 mi long.
+// Overpass uses "south,west,north,east".
+const BBOX = '34.050,-118.560,34.120,-118.230';
+
+// Reverse-mapping OSM tag → our category. We treat anything commercial as a
+// candidate; the full list is intentionally broad to capture "all businesses".
+const SHOP_TAG_TO_CATEGORY = {
+  hairdresser: 'salon', beauty: 'salon', massage: 'spa', cosmetics: 'salon',
+  // Eat/drink
+  restaurant: 'restaurant', cafe: 'cafe', bakery: 'bakery', deli: 'deli',
+  bar: 'bar', pub: 'bar', fast_food: 'restaurant', ice_cream: 'cafe',
+  // Retail
+  clothes: 'retail', shoes: 'retail', jewelry: 'retail', books: 'retail',
+  florist: 'retail', gift: 'retail', wine: 'retail', alcohol: 'retail',
+  optician: 'retail', furniture: 'retail', boutique: 'retail',
+  supermarket: 'grocery', convenience: 'grocery',
+  // Services
+  dry_cleaning: 'service', laundry: 'service', tailor: 'service',
+  car_repair: 'service', car: 'service',
+  // Health / wellness
+  pharmacy: 'pharmacy', chemist: 'pharmacy',
+  // Pets
+  pet: 'retail',
+};
+const AMENITY_TAG_TO_CATEGORY = {
+  restaurant: 'restaurant', cafe: 'cafe', bar: 'bar', pub: 'bar',
+  fast_food: 'restaurant', ice_cream: 'cafe', food_court: 'restaurant',
+  bank: 'finance', atm: 'finance',
+  pharmacy: 'pharmacy', dentist: 'health', doctors: 'health', clinic: 'health', veterinary: 'health',
+  spa: 'spa',
+  gym: 'fitness', fitness_centre: 'fitness',
+  cinema: 'entertainment', theatre: 'entertainment', nightclub: 'entertainment',
+  fuel: 'service', car_wash: 'service',
+};
+
+const DRY = process.env.DRY === '1';
+
+// BBCB zip codes that fall along Sunset Blvd. Used in the SQL post-step to
+// retroactively flag DCA-licensed shops that are already in the DB but
+// weren't tagged with the corridor (their `addr:street` from BBCB usually
+// mentions "Sunset" if they're actually on the strip).
+const SUNSET_ZIPS = [
+  '90026', // Echo Park / Silver Lake (eastern end)
+  '90039', // Silver Lake
+  '90029', // East Hollywood
+  '90028', // Hollywood
+  '90069', // West Hollywood (Sunset Strip)
+  '90046', // West Hollywood / Hollywood Hills West
+  '90048', // Beverly Grove area
+  '90212', // Beverly Hills
+  '90210', // Beverly Hills (Sunset crosses through 90210)
+  '90077', // Bel-Air
+  '90049', // Brentwood
+  '90272', // Pacific Palisades
+];
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+function buildQuery() {
+  return `[out:json][timeout:240];
+(
+  node["shop"](${BBOX});
+  way["shop"](${BBOX});
+  node["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  way["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  node["leisure"="spa"](${BBOX});
+  way["leisure"="spa"](${BBOX});
+  node["office"](${BBOX});
+  way["office"](${BBOX});
+);
+out center tags;`;
+}
+
+async function fetchData() {
+  const cachePath = path.join(TMP_DIR, 'sunset-corridor.json');
+  if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
+    log(`reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
+    return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
+  }
+  log('querying Overpass for the entire Sunset Blvd corridor (~30-90s)…');
+  const body = 'data=' + encodeURIComponent(buildQuery());
+  const res = await fetch(OVERPASS_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Accept': 'application/json',
+      'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
+    },
+    body,
+  });
+  if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`);
+  const j = await res.json();
+  fs.writeFileSync(cachePath, JSON.stringify(j));
+  log(`${j.elements?.length || 0} elements, cached → ${cachePath}`);
+  return j;
+}
+
+// Determine category from OSM tags. Priority: shop → amenity → leisure → office.
+function categorizeFromTags(t) {
+  if (t.shop && SHOP_TAG_TO_CATEGORY[t.shop])         return { cat: SHOP_TAG_TO_CATEGORY[t.shop], rawTag: 'shop=' + t.shop };
+  if (t.amenity && AMENITY_TAG_TO_CATEGORY[t.amenity]) return { cat: AMENITY_TAG_TO_CATEGORY[t.amenity], rawTag: 'amenity=' + t.amenity };
+  if (t.leisure === 'spa')                            return { cat: 'spa', rawTag: 'leisure=spa' };
+  if (t.office)                                       return { cat: 'office', rawTag: 'office=' + t.office };
+  // Catch-all: any shop=* tag we didn't enumerate
+  if (t.shop)                                         return { cat: 'retail', rawTag: 'shop=' + t.shop };
+  return null;
+}
+
+// Address-based filter: only keep elements where addr:street contains
+// "Sunset" (Sunset Blvd, Sunset Boulevard, Sunset Plaza Dr, etc.) — this
+// excludes everything within the bbox that's on a side street.
+function isOnSunsetBlvd(t) {
+  const street = (t['addr:street'] || '').toLowerCase();
+  return /sunset/.test(street);
+}
+
+function refineSalonCategory(rawTag, name, base) {
+  if (base !== 'salon') return base;
+  if (/\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/i.test(name || '')) return 'barbershop';
+  return 'salon';
+}
+
+function makeSlug(name, zip, osmId) {
+  const base = slugify(name) || 'biz';
+  if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
+  return `osm-${osmId}-${base}`.slice(0, 200);
+}
+
+function processElement(el) {
+  const t = el.tags || {};
+  const name = (t.name || '').trim();
+  if (!name) return null;
+  const cat = categorizeFromTags(t);
+  if (!cat) return null;
+  if (!isOnSunsetBlvd(t)) return null;
+
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lon = el.lon ?? el.center?.lon ?? null;
+  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
+  const city = (t['addr:city'] || '').trim();
+  const state = (t['addr:state'] || 'CA').trim();
+  const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
+  const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
+  const website = (t.website || t['contact:website'] || '').trim() || null;
+
+  const finalCat = refineSalonCategory(cat.rawTag, name, cat.cat);
+
+  return {
+    slug: makeSlug(name, zip, el.id),
+    name,
+    category: finalCat,
+    address: street || null,
+    city: city || null,
+    state,
+    zip: zip || null,
+    neighborhood: city || null, // Hollywood, West Hollywood, Beverly Hills, etc. — these ARE the neighborhood for the corridor
+    latitude: lat,
+    longitude: lon,
+    phone,
+    website,
+    source_data_json: {
+      source: 'osm-corridor',
+      source_id: `${el.type}/${el.id}`,
+      source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
+      tier: 'B',
+      retrieved_at: new Date().toISOString(),
+      raw_tag: cat.rawTag,
+      corridor: CORRIDOR_TAG,
+      tags: t,
+    },
+  };
+}
+
+async function upsert(row) {
+  const r = await query(
+    `INSERT INTO businesses
+       (slug, name, category, address, city, state, zip, neighborhood,
+        latitude, longitude, phone, website, source_data_json, tier)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude       = COALESCE(businesses.latitude, EXCLUDED.latitude),
+       longitude      = COALESCE(businesses.longitude, EXCLUDED.longitude),
+       phone          = COALESCE(businesses.phone, EXCLUDED.phone),
+       website        = COALESCE(businesses.website, EXCLUDED.website),
+       neighborhood   = COALESCE(EXCLUDED.neighborhood, businesses.neighborhood),
+       address        = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
+       source_data_json = jsonb_set(
+         COALESCE(businesses.source_data_json, '{}'::jsonb),
+         '{corridor}', to_jsonb(${"'" + CORRIDOR_TAG + "'"}::text)
+       ),
+       updated_at     = now()
+     RETURNING (xmax = 0) AS was_insert`,
+    [row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
+     row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
+     JSON.stringify(row.source_data_json)]
+  );
+  return r.rows[0]?.was_insert;
+}
+
+// Tag any DCA-licensed BBCB shop whose zip falls in the Sunset corridor zip
+// list AND whose address contains "sunset". Mirrors the Ventura post-step.
+async function tagBbcbAlongSunset() {
+  const zipPattern = '^(' + SUNSET_ZIPS.join('|') + ')';
+  const r = await query(
+    `UPDATE businesses
+        SET source_data_json = jsonb_set(
+              COALESCE(source_data_json, '{}'::jsonb),
+              '{corridor}',
+              to_jsonb($2::text)
+            ),
+            updated_at = now()
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+        AND zip ~ $1
+        AND address ~* 'sunset'
+      RETURNING slug`,
+    [zipPattern, CORRIDOR_TAG]
+  );
+  return r.rowCount;
+}
+
+async function main() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+  log(`mode=${DRY ? 'DRY' : 'WRITE'} corridor=${CORRIDOR_TAG} bbox=${BBOX}`);
+
+  const data = await fetchData();
+  let matched = 0, inserted = 0, updated = 0, skippedNotOnSunset = 0, skippedNoCat = 0;
+  const byCategory = {};
+  const byCity = {};
+
+  for (const el of data.elements || []) {
+    const t = el.tags || {};
+    const cat = categorizeFromTags(t);
+    if (!cat || !t.name) { skippedNoCat++; continue; }
+    if (!isOnSunsetBlvd(t)) { skippedNotOnSunset++; continue; }
+
+    const row = processElement(el);
+    if (!row) continue;
+    matched++;
+    byCategory[row.category] = (byCategory[row.category] || 0) + 1;
+    if (row.city) byCity[row.city] = (byCity[row.city] || 0) + 1;
+
+    if (DRY) {
+      if (matched <= 12) console.log(`  ${(row.slug || '').padEnd(60)} ${row.name} · ${row.category} · ${row.city || '?'}`);
+      continue;
+    }
+    try {
+      const ins = await upsert(row);
+      if (ins) inserted++; else updated++;
+    } catch (e) {
+      log(`  ERR ${row.slug}: ${e.message.slice(0, 200)}`);
+    }
+  }
+
+  log(`MATCHED=${matched} inserted=${inserted} updated=${updated}`);
+  log(`SKIPPED: not_on_sunset=${skippedNotOnSunset} no_category=${skippedNoCat}`);
+  log(`BY CATEGORY: ${Object.entries(byCategory).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+  log(`BY CITY: ${Object.entries(byCity).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+
+  let bbcbTagged = 0;
+  if (!DRY) {
+    bbcbTagged = await tagBbcbAlongSunset();
+    log(`BBCB-along-Sunset tagged: ${bbcbTagged}`);
+
+    const tally = await one(`
+      SELECT COUNT(*) AS n,
+             COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded
+        FROM businesses
+       WHERE source_data_json->>'corridor' = $1
+    `, [CORRIDOR_TAG]).catch(() => null);
+    if (tally) log(`DB now: corridor=${tally.n} geocoded=${tally.geocoded}`);
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/ingest-ventura-corridor.js b/scripts/ingest-ventura-corridor.js
new file mode 100644
index 0000000..2e3af85
--- /dev/null
+++ b/scripts/ingest-ventura-corridor.js
@@ -0,0 +1,276 @@
+#!/usr/bin/env node
+// Ventura Blvd Corridor ingest — every registered business on the ~20-mile
+// stretch of Ventura Boulevard through the San Fernando Valley.
+//
+// Ventura Blvd runs ~17.6 miles continuously from Topanga Canyon Blvd
+// (Woodland Hills) through Tarzana → Encino → Sherman Oaks → Studio City
+// → Universal City, then continues into Calabasas as Calabasas Rd.
+// Total catchment ~20 miles. Multiple jurisdictions on this single street:
+//   - City of Los Angeles (Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills)
+//   - City of Calabasas
+//   - small slivers of unincorporated LA County
+//
+// "Registered with the city" = LA Office of Finance BTRC for City-of-LA stretches,
+// Calabasas business license for Calabasas. Neither has a public bulk feed
+// (per la-research-agent), so we approximate by pulling every commercial
+// presence on/near Ventura Blvd from OpenStreetMap and tagging them
+// `corridor: 'ventura-blvd-20mi'` for filtering.
+//
+// Tag taxonomy: ALL business types — not just salons. Restaurants, cafes,
+// banks, dry cleaners, gyms, dentists, retail. Whatever has an OSM presence
+// on the strip.
+//
+//   node scripts/ingest-ventura-corridor.js          # full corridor ingest
+//   DRY=1 node scripts/ingest-ventura-corridor.js    # parse only
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { query, one } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const TMP_DIR = '/tmp/ventura-corridor';
+const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const CORRIDOR_TAG = 'ventura-blvd-20mi';
+
+// Bounding box covering Ventura Blvd from Calabasas (west end) to Lankershim
+// Blvd / Universal City (east end). Roughly 20 mi long × 0.5 mi wide buffer.
+//   south-west: 34.130, -118.665
+//   north-east: 34.165, -118.355
+// Overpass uses "south,west,north,east".
+const BBOX = '34.125,-118.680,34.175,-118.350';
+
+// Reverse-mapping OSM tag → our category. We treat anything commercial as a
+// candidate; the full list is intentionally broad to capture "all businesses".
+const SHOP_TAG_TO_CATEGORY = {
+  hairdresser: 'salon', beauty: 'salon', massage: 'spa', cosmetics: 'salon',
+  // Eat/drink
+  restaurant: 'restaurant', cafe: 'cafe', bakery: 'bakery', deli: 'deli',
+  bar: 'bar', pub: 'bar', fast_food: 'restaurant', ice_cream: 'cafe',
+  // Retail
+  clothes: 'retail', shoes: 'retail', jewelry: 'retail', books: 'retail',
+  florist: 'retail', gift: 'retail', wine: 'retail', alcohol: 'retail',
+  optician: 'retail', furniture: 'retail', boutique: 'retail',
+  supermarket: 'grocery', convenience: 'grocery',
+  // Services
+  dry_cleaning: 'service', laundry: 'service', tailor: 'service',
+  car_repair: 'service', car: 'service',
+  // Health / wellness
+  pharmacy: 'pharmacy', chemist: 'pharmacy',
+  // Pets
+  pet: 'retail',
+};
+const AMENITY_TAG_TO_CATEGORY = {
+  restaurant: 'restaurant', cafe: 'cafe', bar: 'bar', pub: 'bar',
+  fast_food: 'restaurant', ice_cream: 'cafe', food_court: 'restaurant',
+  bank: 'finance', atm: 'finance',
+  pharmacy: 'pharmacy', dentist: 'health', doctors: 'health', clinic: 'health', veterinary: 'health',
+  spa: 'spa',
+  gym: 'fitness', fitness_centre: 'fitness',
+  cinema: 'entertainment', theatre: 'entertainment', nightclub: 'entertainment',
+  fuel: 'service', car_wash: 'service',
+};
+
+const DRY = process.env.DRY === '1';
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+function buildQuery() {
+  return `[out:json][timeout:240];
+(
+  node["shop"](${BBOX});
+  way["shop"](${BBOX});
+  node["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  way["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  node["leisure"="spa"](${BBOX});
+  way["leisure"="spa"](${BBOX});
+  node["office"](${BBOX});
+  way["office"](${BBOX});
+);
+out center tags;`;
+}
+
+async function fetchData() {
+  const cachePath = path.join(TMP_DIR, 'corridor.json');
+  if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
+    log(`reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
+    return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
+  }
+  log('querying Overpass for the entire Ventura Blvd corridor (~30-90s)…');
+  const body = 'data=' + encodeURIComponent(buildQuery());
+  const res = await fetch(OVERPASS_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Accept': 'application/json',
+      'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
+    },
+    body,
+  });
+  if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`);
+  const j = await res.json();
+  fs.writeFileSync(cachePath, JSON.stringify(j));
+  log(`${j.elements?.length || 0} elements, cached → ${cachePath}`);
+  return j;
+}
+
+// Determine category from OSM tags. Priority: shop → amenity → leisure → office.
+function categorizeFromTags(t) {
+  if (t.shop && SHOP_TAG_TO_CATEGORY[t.shop])         return { cat: SHOP_TAG_TO_CATEGORY[t.shop], rawTag: 'shop=' + t.shop };
+  if (t.amenity && AMENITY_TAG_TO_CATEGORY[t.amenity]) return { cat: AMENITY_TAG_TO_CATEGORY[t.amenity], rawTag: 'amenity=' + t.amenity };
+  if (t.leisure === 'spa')                            return { cat: 'spa', rawTag: 'leisure=spa' };
+  if (t.office)                                       return { cat: 'office', rawTag: 'office=' + t.office };
+  // Catch-all: any shop=* tag we didn't enumerate
+  if (t.shop)                                         return { cat: 'retail', rawTag: 'shop=' + t.shop };
+  return null;
+}
+
+// Address-based filter: only keep elements where addr:street contains
+// "Ventura" (Ventura Blvd, Ventura Boulevard, Ventura Pl, etc.) — this
+// excludes everything within the bbox that's on a side street.
+function isOnVenturaBlvd(t) {
+  const street = (t['addr:street'] || '').toLowerCase();
+  return /ventura/.test(street);
+}
+
+function refineSalonCategory(rawTag, name, base) {
+  if (base !== 'salon') return base;
+  if (/\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/i.test(name || '')) return 'barbershop';
+  return 'salon';
+}
+
+function makeSlug(name, zip, osmId) {
+  const base = slugify(name) || 'biz';
+  if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
+  return `osm-${osmId}-${base}`.slice(0, 200);
+}
+
+function processElement(el) {
+  const t = el.tags || {};
+  const name = (t.name || '').trim();
+  if (!name) return null;
+  const cat = categorizeFromTags(t);
+  if (!cat) return null;
+  if (!isOnVenturaBlvd(t)) return null;
+
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lon = el.lon ?? el.center?.lon ?? null;
+  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
+  const city = (t['addr:city'] || '').trim();
+  const state = (t['addr:state'] || 'CA').trim();
+  const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
+  const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
+  const website = (t.website || t['contact:website'] || '').trim() || null;
+
+  const finalCat = refineSalonCategory(cat.rawTag, name, cat.cat);
+
+  return {
+    slug: makeSlug(name, zip, el.id),
+    name,
+    category: finalCat,
+    address: street || null,
+    city: city || null,
+    state,
+    zip: zip || null,
+    neighborhood: city || null, // Sherman Oaks, Studio City, Encino, etc. — these ARE the neighborhood for the corridor
+    latitude: lat,
+    longitude: lon,
+    phone,
+    website,
+    source_data_json: {
+      source: 'osm-corridor',
+      source_id: `${el.type}/${el.id}`,
+      source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
+      tier: 'B',
+      retrieved_at: new Date().toISOString(),
+      raw_tag: cat.rawTag,
+      corridor: CORRIDOR_TAG,
+      tags: t,
+    },
+  };
+}
+
+async function upsert(row) {
+  const r = await query(
+    `INSERT INTO businesses
+       (slug, name, category, address, city, state, zip, neighborhood,
+        latitude, longitude, phone, website, source_data_json, tier)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude       = COALESCE(businesses.latitude, EXCLUDED.latitude),
+       longitude      = COALESCE(businesses.longitude, EXCLUDED.longitude),
+       phone          = COALESCE(businesses.phone, EXCLUDED.phone),
+       website        = COALESCE(businesses.website, EXCLUDED.website),
+       neighborhood   = COALESCE(EXCLUDED.neighborhood, businesses.neighborhood),
+       address        = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
+       source_data_json = jsonb_set(
+         COALESCE(businesses.source_data_json, '{}'::jsonb),
+         '{corridor}', to_jsonb(${"'" + CORRIDOR_TAG + "'"}::text)
+       ),
+       updated_at     = now()
+     RETURNING (xmax = 0) AS was_insert`,
+    [row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
+     row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
+     JSON.stringify(row.source_data_json)]
+  );
+  return r.rows[0]?.was_insert;
+}
+
+async function main() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+  log(`mode=${DRY ? 'DRY' : 'WRITE'} corridor=${CORRIDOR_TAG} bbox=${BBOX}`);
+
+  const data = await fetchData();
+  let matched = 0, inserted = 0, updated = 0, skippedNotOnVentura = 0, skippedNoCat = 0;
+  const byCategory = {};
+  const byCity = {};
+
+  for (const el of data.elements || []) {
+    const t = el.tags || {};
+    const cat = categorizeFromTags(t);
+    if (!cat || !t.name) { skippedNoCat++; continue; }
+    if (!isOnVenturaBlvd(t)) { skippedNotOnVentura++; continue; }
+
+    const row = processElement(el);
+    if (!row) continue;
+    matched++;
+    byCategory[row.category] = (byCategory[row.category] || 0) + 1;
+    if (row.city) byCity[row.city] = (byCity[row.city] || 0) + 1;
+
+    if (DRY) {
+      if (matched <= 12) console.log(`  ${(row.slug || '').padEnd(60)} ${row.name} · ${row.category} · ${row.city || '?'}`);
+      continue;
+    }
+    try {
+      const ins = await upsert(row);
+      if (ins) inserted++; else updated++;
+    } catch (e) {
+      log(`  ERR ${row.slug}: ${e.message.slice(0, 200)}`);
+    }
+  }
+
+  log(`MATCHED=${matched} inserted=${inserted} updated=${updated}`);
+  log(`SKIPPED: not_on_ventura=${skippedNotOnVentura} no_category=${skippedNoCat}`);
+  log(`BY CATEGORY: ${Object.entries(byCategory).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+  log(`BY CITY: ${Object.entries(byCity).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+
+  if (!DRY) {
+    const tally = await one(`
+      SELECT
+        COUNT(*) FILTER (WHERE source_data_json->>'corridor' = $1) AS corridor,
+        COUNT(*) FILTER (WHERE source_data_json->>'corridor' = $1 AND latitude IS NOT NULL) AS corridor_geocoded,
+        json_object_agg(category, n) AS by_cat
+      FROM (
+        SELECT category, COUNT(*) AS n FROM businesses
+        WHERE source_data_json->>'corridor' = $1 GROUP BY category
+      ) s, businesses b WHERE b.source_data_json->>'corridor' = $1
+      GROUP BY s.category, s.n
+      LIMIT 1
+    `, [CORRIDOR_TAG]).catch(() => null);
+    if (tally) log(`DB now: corridor=${tally.corridor} geocoded=${tally.corridor_geocoded}`);
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/ingest-wilshire-corridor.js b/scripts/ingest-wilshire-corridor.js
new file mode 100644
index 0000000..6ee072a
--- /dev/null
+++ b/scripts/ingest-wilshire-corridor.js
@@ -0,0 +1,327 @@
+#!/usr/bin/env node
+// Wilshire Blvd Corridor ingest — every registered business on the ~16-mile
+// stretch of Wilshire Boulevard from Downtown LA to the Pacific Ocean.
+//
+// Wilshire Blvd is essentially LA's "main street" — 16 miles of nearly flat,
+// straight east-west corridor from Wilshire/Grand (DTLA) west through:
+//   - MacArthur Park / Westlake (City of Los Angeles)
+//   - Koreatown
+//   - Mid-Wilshire / Hancock Park
+//   - Miracle Mile (LACMA, Petersen Automotive Museum)
+//   - Beverly Hills (Wilshire crosses through 90211/90212/90210)
+//   - Westwood (UCLA, Federal Building)
+//   - Brentwood
+//   - Santa Monica → ocean
+//
+// "Registered with the city" = LA Office of Finance BTRC for City-of-LA
+// stretches, plus Beverly Hills + Santa Monica business licenses for those
+// jurisdictions. Same approximation pattern as Sunset/Ventura: pull every
+// commercial OSM presence on/near Wilshire Blvd, tag them
+// `corridor: 'wilshire-blvd-16mi'` for filtering.
+//
+//   node scripts/ingest-wilshire-corridor.js          # full corridor ingest
+//   DRY=1 node scripts/ingest-wilshire-corridor.js    # parse only
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { query, one } from '../src/lib/db.js';
+import { slugify } from '../src/lib/slug.js';
+
+const TMP_DIR = '/tmp/osm-ingest';
+const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const CORRIDOR_TAG = 'wilshire-blvd-16mi';
+
+// Bounding box covering Wilshire Blvd from DTLA (east end, ~ -118.255) to the
+// ocean at Santa Monica (west end, ~ -118.510). Wilshire is essentially flat
+// east-west, so the latitude band is narrow (34.040 - 34.075).
+// Overpass uses "south,west,north,east".
+const BBOX = '34.040,-118.510,34.075,-118.235';
+
+// Reverse-mapping OSM tag → our category. Matches Sunset corridor exactly so
+// the editorial UI behaves the same.
+const SHOP_TAG_TO_CATEGORY = {
+  hairdresser: 'salon', beauty: 'salon', massage: 'spa', cosmetics: 'salon',
+  // Eat/drink
+  restaurant: 'restaurant', cafe: 'cafe', bakery: 'bakery', deli: 'deli',
+  bar: 'bar', pub: 'bar', fast_food: 'restaurant', ice_cream: 'cafe',
+  // Retail
+  clothes: 'retail', shoes: 'retail', jewelry: 'retail', books: 'retail',
+  florist: 'retail', gift: 'retail', wine: 'retail', alcohol: 'retail',
+  optician: 'retail', furniture: 'retail', boutique: 'retail',
+  supermarket: 'grocery', convenience: 'grocery',
+  // Services
+  dry_cleaning: 'service', laundry: 'service', tailor: 'service',
+  car_repair: 'service', car: 'service',
+  // Health / wellness
+  pharmacy: 'pharmacy', chemist: 'pharmacy',
+  // Pets
+  pet: 'retail',
+};
+const AMENITY_TAG_TO_CATEGORY = {
+  restaurant: 'restaurant', cafe: 'cafe', bar: 'bar', pub: 'bar',
+  fast_food: 'restaurant', ice_cream: 'cafe', food_court: 'restaurant',
+  bank: 'finance', atm: 'finance',
+  pharmacy: 'pharmacy', dentist: 'health', doctors: 'health', clinic: 'health', veterinary: 'health',
+  spa: 'spa',
+  gym: 'fitness', fitness_centre: 'fitness',
+  cinema: 'entertainment', theatre: 'entertainment', nightclub: 'entertainment',
+  fuel: 'service', car_wash: 'service',
+};
+
+const DRY = process.env.DRY === '1';
+
+// BBCB zip codes that fall along Wilshire Blvd. Used in the SQL post-step to
+// retroactively flag DCA-licensed shops that are already in the DB but
+// weren't tagged with the corridor (their `addr:street` from BBCB usually
+// mentions "Wilshire" if they're actually on the strip).
+//
+// City of LA portion: 90017 (DTLA), 90057 (MacArthur Park / Westlake),
+// 90020 (Koreatown), 90010 (Mid-Wilshire / Park Mile),
+// 90005 (Koreatown south), 90004 (Hancock Park),
+// 90019 (Mid-Wilshire west), 90036 (Miracle Mile),
+// 90035 (south Beverly Hills border), 90048 (Beverly Grove).
+// Beverly Hills: 90211, 90212, 90210.
+// Westwood: 90024. Brentwood: 90049.
+// Santa Monica: 90401, 90403, 90404.
+const WILSHIRE_ZIPS = [
+  '90017', // DTLA (eastern end)
+  '90057', // MacArthur Park / Westlake
+  '90020', // Koreatown
+  '90010', // Mid-Wilshire / Park Mile
+  '90005', // Koreatown south
+  '90004', // Hancock Park
+  '90019', // Mid-Wilshire west
+  '90036', // Miracle Mile (LACMA, Petersen)
+  '90035', // south Beverly Hills border
+  '90048', // Beverly Grove
+  '90211', // Beverly Hills
+  '90212', // Beverly Hills
+  '90210', // Beverly Hills (Wilshire crosses through 90210)
+  '90024', // Westwood (UCLA, Federal Building)
+  '90049', // Brentwood
+  '90401', // Santa Monica (oceanside)
+  '90403', // Santa Monica
+  '90404', // Santa Monica
+];
+
+function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
+function log(m) { console.log(`[${ts()}] ${m}`); }
+
+function buildQuery() {
+  return `[out:json][timeout:240];
+(
+  node["shop"](${BBOX});
+  way["shop"](${BBOX});
+  node["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  way["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|bank|atm|pharmacy|dentist|doctors|clinic|veterinary|spa|gym|fitness_centre|cinema|theatre|nightclub|fuel|car_wash)$"](${BBOX});
+  node["leisure"="spa"](${BBOX});
+  way["leisure"="spa"](${BBOX});
+  node["office"](${BBOX});
+  way["office"](${BBOX});
+);
+out center tags;`;
+}
+
+async function fetchData() {
+  const cachePath = path.join(TMP_DIR, 'wilshire-corridor.json');
+  if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
+    log(`reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
+    return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
+  }
+  log('querying Overpass for the entire Wilshire Blvd corridor (~30-90s)…');
+  const body = 'data=' + encodeURIComponent(buildQuery());
+  const res = await fetch(OVERPASS_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      'Accept': 'application/json',
+      'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
+    },
+    body,
+  });
+  if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`);
+  const j = await res.json();
+  fs.writeFileSync(cachePath, JSON.stringify(j));
+  log(`${j.elements?.length || 0} elements, cached → ${cachePath}`);
+  return j;
+}
+
+// Determine category from OSM tags. Priority: shop → amenity → leisure → office.
+function categorizeFromTags(t) {
+  if (t.shop && SHOP_TAG_TO_CATEGORY[t.shop])         return { cat: SHOP_TAG_TO_CATEGORY[t.shop], rawTag: 'shop=' + t.shop };
+  if (t.amenity && AMENITY_TAG_TO_CATEGORY[t.amenity]) return { cat: AMENITY_TAG_TO_CATEGORY[t.amenity], rawTag: 'amenity=' + t.amenity };
+  if (t.leisure === 'spa')                            return { cat: 'spa', rawTag: 'leisure=spa' };
+  if (t.office)                                       return { cat: 'office', rawTag: 'office=' + t.office };
+  // Catch-all: any shop=* tag we didn't enumerate
+  if (t.shop)                                         return { cat: 'retail', rawTag: 'shop=' + t.shop };
+  return null;
+}
+
+// Address-based filter: only keep elements where addr:street contains
+// "Wilshire" (Wilshire Blvd, Wilshire Boulevard, etc.) — this excludes
+// everything within the bbox that's on a side street.
+function isOnWilshireBlvd(t) {
+  const street = (t['addr:street'] || '').toLowerCase();
+  return /wilshire/.test(street);
+}
+
+function refineSalonCategory(rawTag, name, base) {
+  if (base !== 'salon') return base;
+  if (/\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/i.test(name || '')) return 'barbershop';
+  return 'salon';
+}
+
+function makeSlug(name, zip, osmId) {
+  const base = slugify(name) || 'biz';
+  if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
+  return `osm-${osmId}-${base}`.slice(0, 200);
+}
+
+function processElement(el) {
+  const t = el.tags || {};
+  const name = (t.name || '').trim();
+  if (!name) return null;
+  const cat = categorizeFromTags(t);
+  if (!cat) return null;
+  if (!isOnWilshireBlvd(t)) return null;
+
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lon = el.lon ?? el.center?.lon ?? null;
+  const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
+  const city = (t['addr:city'] || '').trim();
+  const state = (t['addr:state'] || 'CA').trim();
+  const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
+  const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
+  const website = (t.website || t['contact:website'] || '').trim() || null;
+
+  const finalCat = refineSalonCategory(cat.rawTag, name, cat.cat);
+
+  return {
+    slug: makeSlug(name, zip, el.id),
+    name,
+    category: finalCat,
+    address: street || null,
+    city: city || null,
+    state,
+    zip: zip || null,
+    neighborhood: city || null,
+    latitude: lat,
+    longitude: lon,
+    phone,
+    website,
+    source_data_json: {
+      source: 'osm-corridor',
+      source_id: `${el.type}/${el.id}`,
+      source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
+      tier: 'B',
+      retrieved_at: new Date().toISOString(),
+      raw_tag: cat.rawTag,
+      corridor: CORRIDOR_TAG,
+      tags: t,
+    },
+  };
+}
+
+async function upsert(row) {
+  const r = await query(
+    `INSERT INTO businesses
+       (slug, name, category, address, city, state, zip, neighborhood,
+        latitude, longitude, phone, website, source_data_json, tier)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
+     ON CONFLICT (slug) DO UPDATE SET
+       latitude       = COALESCE(businesses.latitude, EXCLUDED.latitude),
+       longitude      = COALESCE(businesses.longitude, EXCLUDED.longitude),
+       phone          = COALESCE(businesses.phone, EXCLUDED.phone),
+       website        = COALESCE(businesses.website, EXCLUDED.website),
+       neighborhood   = COALESCE(EXCLUDED.neighborhood, businesses.neighborhood),
+       address        = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
+       source_data_json = jsonb_set(
+         COALESCE(businesses.source_data_json, '{}'::jsonb),
+         '{corridor}', to_jsonb(${"'" + CORRIDOR_TAG + "'"}::text)
+       ),
+       updated_at     = now()
+     RETURNING (xmax = 0) AS was_insert`,
+    [row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
+     row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
+     JSON.stringify(row.source_data_json)]
+  );
+  return r.rows[0]?.was_insert;
+}
+
+// Tag any DCA-licensed BBCB shop whose zip falls in the Wilshire corridor zip
+// list AND whose address contains "wilshire". Mirrors the Sunset post-step.
+async function tagBbcbAlongWilshire() {
+  const zipPattern = '^(' + WILSHIRE_ZIPS.join('|') + ')';
+  const r = await query(
+    `UPDATE businesses
+        SET source_data_json = jsonb_set(
+              COALESCE(source_data_json, '{}'::jsonb),
+              '{corridor}',
+              to_jsonb($2::text)
+            ),
+            updated_at = now()
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+        AND zip ~ $1
+        AND address ~* 'wilshire'
+      RETURNING slug`,
+    [zipPattern, CORRIDOR_TAG]
+  );
+  return r.rowCount;
+}
+
+async function main() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+  log(`mode=${DRY ? 'DRY' : 'WRITE'} corridor=${CORRIDOR_TAG} bbox=${BBOX}`);
+
+  const data = await fetchData();
+  let matched = 0, inserted = 0, updated = 0, skippedNotOnWilshire = 0, skippedNoCat = 0;
+  const byCategory = {};
+  const byCity = {};
+
+  for (const el of data.elements || []) {
+    const t = el.tags || {};
+    const cat = categorizeFromTags(t);
+    if (!cat || !t.name) { skippedNoCat++; continue; }
+    if (!isOnWilshireBlvd(t)) { skippedNotOnWilshire++; continue; }
+
+    const row = processElement(el);
+    if (!row) continue;
+    matched++;
+    byCategory[row.category] = (byCategory[row.category] || 0) + 1;
+    if (row.city) byCity[row.city] = (byCity[row.city] || 0) + 1;
+
+    if (DRY) {
+      if (matched <= 12) console.log(`  ${(row.slug || '').padEnd(60)} ${row.name} · ${row.category} · ${row.city || '?'}`);
+      continue;
+    }
+    try {
+      const ins = await upsert(row);
+      if (ins) inserted++; else updated++;
+    } catch (e) {
+      log(`  ERR ${row.slug}: ${e.message.slice(0, 200)}`);
+    }
+  }
+
+  log(`MATCHED=${matched} inserted=${inserted} updated=${updated}`);
+  log(`SKIPPED: not_on_wilshire=${skippedNotOnWilshire} no_category=${skippedNoCat}`);
+  log(`BY CATEGORY: ${Object.entries(byCategory).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+  log(`BY CITY: ${Object.entries(byCity).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`${k}=${v}`).join(' ')}`);
+
+  let bbcbTagged = 0;
+  if (!DRY) {
+    bbcbTagged = await tagBbcbAlongWilshire();
+    log(`BBCB-along-Wilshire tagged: ${bbcbTagged}`);
+
+    const tally = await one(`
+      SELECT COUNT(*) AS n,
+             COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded
+        FROM businesses
+       WHERE source_data_json->>'corridor' = $1
+    `, [CORRIDOR_TAG]).catch(() => null);
+    if (tally) log(`DB now: corridor=${tally.n} geocoded=${tally.geocoded}`);
+  }
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/loop-tick.sh b/scripts/loop-tick.sh
new file mode 100755
index 0000000..f7f3bbd
--- /dev/null
+++ b/scripts/loop-tick.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+# loop-tick — append a loop status line. Used by the build loop to broadcast
+# ticks across all open terminals via the shared log.
+# Usage:
+#   ./scripts/loop-tick.sh "Wave 16 done · +5 articles +28 yelps"
+#   tail -f logs/loop.log              # in any other terminal
+
+LOG="$(cd "$(dirname "$0")/.." && pwd)/logs/loop.log"
+mkdir -p "$(dirname "$LOG")"
+TIME="$(date '+%H:%M:%S')"
+MSG="$*"
+[[ -z "$MSG" ]] && MSG="Loop tick"
+echo "$TIME 🟢 Loop tick — $MSG" >> "$LOG"
+echo "$TIME 🟢 Loop tick — $MSG"
+# Optional macOS notification — silent fail if osascript missing.
+if command -v osascript >/dev/null 2>&1; then
+  # Truncate to 240 chars so the notification box doesn't choke.
+  SHORT="$(echo -n "$MSG" | head -c 240)"
+  osascript -e "display notification \"$SHORT\" with title \"🟢 Loop tick\" sound name \"\"" 2>/dev/null &
+fi
diff --git a/scripts/overnight-loop.sh b/scripts/overnight-loop.sh
new file mode 100755
index 0000000..2ad9f42
--- /dev/null
+++ b/scripts/overnight-loop.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+# Overnight pipeline — fully local, zero Claude tokens.
+# Cycles: re-audit live URLs (regression detection) → top up roasts → idle.
+# Designed to run from a launchd plist.
+
+set -u
+cd "$(dirname "$0")/.."
+LOG="/tmp/overnight-loop.log"
+
+echo "=== $(date) — overnight cycle start ===" >> "$LOG"
+
+# 1) Re-run live HTML audits with regression alerting (cheerio, no LLM).
+echo "--- live audits ---" >> "$LOG"
+node scripts/audit-public-sites.mjs --rebuild >> "$LOG" 2>&1
+
+# 2) Top up roasts on any rows that lost theirs / new rows added (qwen3 local).
+echo "--- roast top-up ---" >> "$LOG"
+node scripts/generate-roasts.mjs >> "$LOG" 2>&1
+
+# 3) Trim old log to last 4MB.
+LOG_SIZE=$(stat -f%z "$LOG" 2>/dev/null || echo 0)
+if [ "$LOG_SIZE" -gt 4194304 ]; then
+  tail -c 2097152 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
+fi
+
+echo "=== $(date) — overnight cycle done ===" >> "$LOG"
diff --git a/scripts/rerender-url-mockups.mjs b/scripts/rerender-url-mockups.mjs
new file mode 100644
index 0000000..25a6840
--- /dev/null
+++ b/scripts/rerender-url-mockups.mjs
@@ -0,0 +1,70 @@
+// Re-render the preview_html of URL-driven website_analyses entries (the ones
+// NOT created by build-all-analyses.mjs) using their enriched copy_pack. The
+// 11 entries created via the /website-analysis form have copy_pack saved in
+// summary_json but their stored preview_html was rendered before enrichment.
+//
+//   node scripts/rerender-url-mockups.mjs            # do it
+//   node scripts/rerender-url-mockups.mjs --dry-run  # preview
+
+import 'dotenv/config';
+import { renderTheme } from '../src/render/website-analysis-themes.js';
+import { many, query } from '../src/lib/db.js';
+
+const DRY = process.argv.includes('--dry-run');
+
+function isPublicUrl(u) {
+  try {
+    const p = new URL(u);
+    if (p.protocol !== 'http:' && p.protocol !== 'https:') return false;
+    const h = p.hostname;
+    if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(h)) return false;
+    if (h === 'localhost' || h.endsWith('.local')) return false;
+    return true;
+  } catch { return false; }
+}
+
+async function main() {
+  const rows = await many(`
+    SELECT a.id, a.slug, a.url, a.vertical, a.summary_json, a.meta_json
+    FROM website_analyses a
+    WHERE (a.meta_json->>'source') IS DISTINCT FROM 'build-all-analyses'
+      AND a.summary_json ? 'copy_pack'
+    ORDER BY a.id
+  `);
+
+  console.log(`Re-rendering ${rows.length} URL-driven analyses with enriched copy_pack${DRY ? ' (DRY)' : ''}`);
+  let touched = 0, skipped = 0;
+  for (const row of rows) {
+    const cp = row.summary_json?.copy_pack;
+    const meta = row.meta_json || {};
+    const ctx = {
+      title:    cp?.headline || meta.title || row.slug,
+      tagline:  cp?.tagline  || meta.tagline || '',
+      cta_primary:   cp?.cta_primary,
+      cta_secondary: cp?.cta_secondary,
+      cards:    cp?.cards,
+      palette:  meta.palette || [],
+      hero_image: meta.hero_image || null,
+      vertical: row.vertical || 'generic',
+      ig_handle: meta.ig_handle || null,
+      url: isPublicUrl(row.url) ? row.url : `https://${row.slug}.example`,
+    };
+
+    const mockups = await many('SELECT id, theme, mockup_id FROM website_analysis_mockups WHERE analysis_id=$1 ORDER BY mockup_id', [row.id]);
+    if (!mockups.length) { skipped += 1; continue; }
+    for (const m of mockups) {
+      const html = renderTheme(m.theme, ctx);
+      if (DRY) {
+        console.log(`  ${row.slug} ${m.mockup_id} would re-render (${html.length} chars, headline="${ctx.title.slice(0,60)}")`);
+      } else {
+        await query('UPDATE website_analysis_mockups SET preview_html=$1 WHERE id=$2', [html, m.id]);
+      }
+    }
+    touched += 1;
+    console.log(`[${touched}/${rows.length}] ${row.slug} → "${ctx.title.slice(0, 60)}"`);
+  }
+  console.log(`\nDONE — touched:${touched} skipped:${skipped}`);
+  process.exit(0);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/src/lib/audit-grade.js b/src/lib/audit-grade.js
new file mode 100644
index 0000000..72e9958
--- /dev/null
+++ b/src/lib/audit-grade.js
@@ -0,0 +1,54 @@
+// Single source of truth for the audit grade. Used by:
+//   - /wa/:slug result page  (analysis only)
+//   - /builds index page     (analysis + build row signals)
+//
+// Signals are additive; cap at 100. The rubric rewards real-world quality
+// signals — public URL, live pm2 process, recent git commit, full meta — so the
+// page isn't dominated by a single letter when synthesized analyses uniformly
+// have only title+tagline.
+
+export function computeGrade({ analysis = null, build = null } = {}) {
+  const meta = analysis?.meta_json || {};
+  const summary = analysis?.summary_json || {};
+  let score = 0;
+  const reasons = [];
+
+  // --- analysis-side signals ----------------------------------------------
+  if (summary.title || meta.title) { score += 10; reasons.push('title +10'); }
+  const tagline = summary.tagline || meta.tagline || '';
+  if (tagline.length >= 40) { score += 15; reasons.push('tagline≥40 +15'); }
+  else if (tagline.length > 0) { score += 8; reasons.push('tagline +8'); }
+  if (Array.isArray(meta.palette) && meta.palette.length >= 2) { score += 10; reasons.push('palette +10'); }
+  if (meta.hero_image) { score += 10; reasons.push('hero +10'); }
+  if (analysis?.ig_handle) { score += 5; reasons.push('ig +5'); }
+  if (analysis?.vertical && analysis.vertical !== 'generic') { score += 10; reasons.push('vertical +10'); }
+  if (analysis && !meta.iframe_blocked) { score += 5; reasons.push('embedable +5'); }
+
+  // --- live HTML audit (only when audit-public-sites.mjs has run) ---------
+  // Fold real cheerio-derived score into the grade. Worth up to +20 because
+  // signals like missing viewport / no h1 / bad title length are objective.
+  const live = summary.live_audit?.grade?.score;
+  if (Number.isFinite(live)) {
+    const liveBoost = Math.round(live * 0.2);  // 0-20
+    score += liveBoost;
+    reasons.push(`live-audit ${live} → +${liveBoost}`);
+  }
+
+  // --- build-side signals (only available on /builds) ---------------------
+  if (build?.url) { score += 10; reasons.push('public-url +10'); }
+  if (build?.pm2?.status === 'online') { score += 10; reasons.push('pm2-live +10'); }
+  if (build?.has_git) { score += 5; reasons.push('git +5'); }
+  if (build?.description_source === 'memory') { score += 5; reasons.push('memory-doc +5'); }
+  // recent activity: <30d=+10, <180d=+5
+  if (build?.last_at) {
+    const days = (Date.now() - Date.parse(build.last_at)) / 86400000;
+    if (days < 30) { score += 10; reasons.push('recent +10'); }
+    else if (days < 180) { score += 5; reasons.push('semi-recent +5'); }
+  }
+  if (build?.port) { score += 3; reasons.push('port +3'); }
+
+  if (score > 100) score = 100;
+  const letter = score >= 80 ? 'A' : score >= 65 ? 'B' : score >= 45 ? 'C' : score >= 25 ? 'D' : 'F';
+  const color  = score >= 80 ? '#22c55e' : score >= 65 ? '#84cc16' : score >= 45 ? '#facc15' : score >= 25 ? '#f97316' : '#dc2626';
+  return { letter, score, color, reasons };
+}
diff --git a/src/lib/builds-index.js b/src/lib/builds-index.js
new file mode 100644
index 0000000..533e4d1
--- /dev/null
+++ b/src/lib/builds-index.js
@@ -0,0 +1,238 @@
+// builds-index — scan ~/Projects + parse MEMORY.md, return structured build rows.
+//
+// Each row:
+//   { name, slug, path, category, description, url, port, last_commit_at, has_git }
+//
+// "category" is heuristic from the directory name + MEMORY.md tag (project, agent, skill, idea).
+// "url" / "port" pulled from MEMORY.md description text where present (e.g. ":9762", "http://...").
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import os from 'node:os';
+
+// Snapshot pm2 once and index by both app name and CWD basename — directories
+// don't always match pm2 app names exactly (e.g. dir `small-business-builder`
+// runs pm2 app `smb-builder`).
+function pm2Snapshot() {
+  try {
+    const raw = execFileSync('pm2', ['jlist'], { stdio: ['ignore','pipe','ignore'], timeout: 4000 }).toString();
+    const list = JSON.parse(raw);
+    const byName = new Map();
+    const byCwd  = new Map();
+    for (const a of list) {
+      const env = a.pm2_env || {};
+      const port = env.env?.PORT ? parseInt(env.env.PORT, 10) : null;
+      const entry = {
+        name: a.name,
+        status: env.status || 'unknown',
+        port: Number.isFinite(port) && port > 0 ? port : null,
+        memory_mb: a.monit?.memory ? Math.round(a.monit.memory / 1048576) : null,
+        cwd: env.pm_cwd || null,
+        restarts: env.restart_time ?? 0,
+      };
+      byName.set(a.name.toLowerCase(), entry);
+      if (env.pm_cwd) byCwd.set(path.basename(env.pm_cwd).toLowerCase(), entry);
+    }
+    return { byName, byCwd };
+  } catch {
+    return { byName: new Map(), byCwd: new Map() };
+  }
+}
+
+function findPm2(name, snap) {
+  const n = name.toLowerCase();
+  return snap.byName.get(n) || snap.byCwd.get(n) || null;
+}
+
+const PROJECTS = path.join(os.homedir(), 'Projects');
+const MEMORY = path.join(os.homedir(), '.claude', 'projects', '-Users-stevestudio2', 'memory');
+const MEMORY_MD = path.join(MEMORY, 'MEMORY.md');
+
+// Compliance-sensitive project slugs that must NEVER appear on a public-facing
+// /website-analysis surface. /builds is basic-auth gated today so they show up
+// there; this flag lets future public views filter them out.
+//
+// Why each one:
+//   lawyer-directory-builder — Cal §6155 / Rule 7.x: synthesized stats next to
+//     real firm names is admin-only per feedback_lawyer_directory_compliance.md
+//   abrams-v-pabo            — active PI litigation w/ Steve as plaintiff
+//   professional-directory   — doctor directory containing real practitioners
+//   pawcircles               — has real pet-owner profiles (PII)
+const COMPLIANCE_SENSITIVE_SLUGS = new Set([
+  'lawyer-directory-builder',
+  'lawyer-directory',
+  'abrams-v-pabo',
+  'professional-directory',
+  'doctor',
+  'pawcircles',
+]);
+const COMPLIANCE_SENSITIVE_PATTERNS = [
+  /^session-\d{4}-/,       // private session recaps / handoff docs
+  /^claude-codex-meeting/, // private meeting outputs
+  /^codex-review-\d/,      // dated codex review outputs
+];
+
+export function isComplianceSensitive(slug) {
+  if (!slug) return false;
+  const s = slug.toLowerCase();
+  if (COMPLIANCE_SENSITIVE_SLUGS.has(s)) return true;
+  return COMPLIANCE_SENSITIVE_PATTERNS.some(re => re.test(s));
+}
+
+function gitLastCommit(dir) {
+  try {
+    const iso = execFileSync('git', ['-C', dir, 'log', '-1', '--format=%cI'], {
+      stdio: ['ignore', 'pipe', 'ignore'],
+      timeout: 2000,
+    }).toString().trim();
+    return iso || null;
+  } catch { return null; }
+}
+
+function fsLastTouched(dir) {
+  // Fallback when not a git repo — most-recent mtime among top-level items.
+  try {
+    const entries = fs.readdirSync(dir).filter(e => !e.startsWith('.'));
+    let max = 0;
+    for (const e of entries) {
+      try {
+        const st = fs.statSync(path.join(dir, e));
+        if (st.mtimeMs > max) max = st.mtimeMs;
+      } catch {}
+    }
+    return max ? new Date(max).toISOString() : null;
+  } catch { return null; }
+}
+
+// Extract `:9999` port + first http(s) URL from a free-form description.
+function parseHints(text) {
+  const portMatches = [...text.matchAll(/:(\d{4,5})\b/g)].map(m => parseInt(m[1], 10));
+  const port = portMatches.find(p => p >= 1024 && p <= 65535) || null;
+  const urlM = text.match(/https?:\/\/[^\s)\]]+/);
+  const url = urlM ? urlM[0].replace(/[.,;]+$/, '') : null;
+  return { port, url };
+}
+
+function parseMemoryIndex() {
+  // Returns map: slug → { title, description, file }.
+  if (!fs.existsSync(MEMORY_MD)) return new Map();
+  const lines = fs.readFileSync(MEMORY_MD, 'utf8').split('\n');
+  const re = /^- \[([^\]]+)\]\(([^)]+\.md)\)\s*[—\-]\s*(.+?)\s*$/;
+  const map = new Map();
+  for (const line of lines) {
+    const m = line.match(re);
+    if (!m) continue;
+    const [, title, file, desc] = m;
+    const slugMatch = file.match(/^project_([a-z0-9_-]+)\.md$/i);
+    const slug = slugMatch ? slugMatch[1].replace(/_/g, '-') : file.replace(/\.md$/, '');
+    map.set(slug, { title, description: desc, file });
+  }
+  return map;
+}
+
+function categorize(name, memEntry) {
+  const n = name.toLowerCase();
+  if (memEntry?.file?.startsWith('feedback_')) return 'feedback';
+  if (memEntry?.file?.startsWith('reference_')) return 'reference';
+  if (n.includes('scraper') || n.includes('scrape')) return 'scraper';
+  if (n.includes('agent')) return 'agent';
+  if (n.includes('viewer') || n.includes('dashboard')) return 'viewer';
+  if (n.includes('factory') || n.includes('builder')) return 'factory';
+  if (n.endsWith('-website') || n.endsWith('-app') || n.endsWith('-site')) return 'site';
+  if (n.startsWith('codex-') || n.includes('claude')) return 'tooling';
+  if (n.includes('directory')) return 'directory';
+  return 'project';
+}
+
+function bestMemoryMatch(name, memMap) {
+  // Try several slug variants — directory names don't perfectly match memory slug rules.
+  const canon = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+  const variants = [name, name.toLowerCase(), canon(name)];
+  for (const v of variants) {
+    const direct = memMap.get(v);
+    if (direct) return direct;
+  }
+  // Fuzzy: collapse hyphens/underscores and look both ways for substring match.
+  const c = canon(name).replace(/-/g, '');
+  if (c.length < 4) return null;  // avoid noise matches
+  for (const [slug, e] of memMap.entries()) {
+    const sc = slug.replace(/-/g, '');
+    if (sc === c || sc.includes(c) || c.includes(sc)) return e;
+  }
+  // Last-ditch: word-overlap on title (e.g. "Lawyer Directory Builder" → lawyer-directory-builder dir).
+  const dirWords = new Set(canon(name).split('-').filter(w => w.length >= 4));
+  if (dirWords.size === 0) return null;
+  let best = null, bestScore = 0;
+  for (const [, e] of memMap.entries()) {
+    const titleWords = new Set(canon(e.title || '').split('-').filter(w => w.length >= 4));
+    const overlap = [...dirWords].filter(w => titleWords.has(w)).length;
+    if (overlap >= 2 && overlap > bestScore) { best = e; bestScore = overlap; }
+  }
+  return best;
+}
+
+// Read package.json#description as a fallback when memory has nothing.
+function pkgDescription(dir) {
+  try {
+    const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
+    const pkg = JSON.parse(raw);
+    return typeof pkg.description === 'string' && pkg.description.trim()
+      ? pkg.description.trim()
+      : null;
+  } catch { return null; }
+}
+
+export function listBuilds({ analysisSlugs = new Set(), grades = new Map() } = {}) {
+  const memMap = parseMemoryIndex();
+  const pm2 = pm2Snapshot();
+  let entries = [];
+  try { entries = fs.readdirSync(PROJECTS, { withFileTypes: true }); }
+  catch { return []; }
+
+  const rows = [];
+  for (const e of entries) {
+    if (!e.isDirectory()) continue;
+    if (e.name.startsWith('_') || e.name.startsWith('.')) continue;
+    const dir = path.join(PROJECTS, e.name);
+    const isGit = fs.existsSync(path.join(dir, '.git'));
+    const last = (isGit ? gitLastCommit(dir) : null) || fsLastTouched(dir);
+    const mem = bestMemoryMatch(e.name, memMap);
+    const hints = parseHints(mem?.description || '');
+    const pm2Hit = findPm2(e.name, pm2);
+    const desc = mem?.description || pkgDescription(dir) || '';
+    const slug = e.name.toLowerCase();
+    rows.push({
+      name: e.name,
+      slug,
+      path: dir,
+      category: categorize(e.name, mem),
+      compliance_sensitive: isComplianceSensitive(slug),
+      description: desc,
+      description_source: mem?.description ? 'memory' : (desc ? 'package' : null),
+      memory_title: mem?.title || null,
+      memory_file: mem?.file || null,
+      url: hints.url,
+      port: pm2Hit?.port || hints.port,
+      last_at: last,
+      has_git: isGit,
+      analysis_slug: analysisSlugs.has(slug) ? slug : null,
+      grade: grades.get(slug) || null,
+      deep_search: grades.get(slug)?.deep_search || '',
+      audit_score: grades.get(slug)?.audit_score ?? null,
+      pm2: pm2Hit ? {
+        name: pm2Hit.name,
+        status: pm2Hit.status,
+        port: pm2Hit.port,
+        memory_mb: pm2Hit.memory_mb,
+        restarts: pm2Hit.restarts,
+      } : null,
+    });
+  }
+  rows.sort((a, b) => {
+    const aT = a.last_at ? Date.parse(a.last_at) : 0;
+    const bT = b.last_at ? Date.parse(b.last_at) : 0;
+    return bT - aT;
+  });
+  return rows;
+}
diff --git a/src/lib/deep-dive.js b/src/lib/deep-dive.js
new file mode 100644
index 0000000..212787d
--- /dev/null
+++ b/src/lib/deep-dive.js
@@ -0,0 +1,95 @@
+// deep-dive — synthesizes a mini site-audit report for a website_analyses
+// record. Pure local synthesis: no scraping, no LLM calls. Returns structured
+// JSON that gets stored in summary_json.audit_report and rendered on /wa/:slug.
+
+const VERTICAL_TIPS = {
+  salon:        ['Add a "Book in 3 clicks" CTA above the fold.', 'Show real stylist photos — never stock.', 'Pin Yelp/Google review count to the hero.'],
+  restaurant:   ['Hero needs the menu trigger AND the booking trigger — not one or the other.', 'List today\'s hours in plain text, not just a chart.', 'Embed the latest IG post above the footer.'],
+  lawyer:       ['Hero must qualify the visitor in <2 seconds (e.g. "Hurt at work? Free case review").', 'Avoid lawyer-jargon — write at 7th-grade level.', 'Court-verifiable case results > vague testimonials.'],
+  vet:          ['New-patient intake form deserves its own primary CTA.', 'Show clinic hours + emergency line in the hero.', 'Pet-passport, vaccine-due lookup widgets earn repeat visits.'],
+  advocacy:     ['Move the donation CTA to a sticky header bar.', 'Story-wall above the demands list — humans before policy.', 'Live counter on signatures/calls drives social proof.'],
+  'saas-landing': ['Hero CTA must be 1-click (no "Sign up" → form route).', 'Add a 60-second product video above the fold.', 'Real-customer logos beat any stock badge.'],
+  ecom:         ['Free-shipping line in the announcement bar.', 'Reviews + star count in the hero.', 'Sticky add-to-cart on PDP — non-negotiable.'],
+  generic:      ['Lead with the "what we do" — visitors leave in 8 seconds.', 'One primary CTA, sized larger than anything else.', 'Add a real photograph — even a phone shot beats a CSS gradient.'],
+};
+
+const SEO_RULES = [
+  { key: 'title_too_short',    test: t => (t || '').length < 24,   pts: -8,  msg: 'Title under 24 chars — search engines truncate before delivering value.' },
+  { key: 'title_too_long',     test: t => (t || '').length > 70,   pts: -4,  msg: 'Title >70 chars — Google truncates. Front-load the keyword.' },
+  { key: 'title_ok',           test: t => (t || '').length >= 24 && (t || '').length <= 70, pts: 12, msg: 'Title length is in the SEO sweet spot.' },
+];
+const CONTENT_RULES = [
+  { key: 'tagline_too_short',  test: s => (s || '').length < 40,   pts: -10, msg: 'Tagline under 40 chars — visitor leaves before getting context.' },
+  { key: 'tagline_too_long',   test: s => (s || '').length > 240,  pts: -3,  msg: 'Tagline >240 chars — break it into a hero line + body paragraph.' },
+  { key: 'tagline_ok',         test: s => (s || '').length >= 40 && (s || '').length <= 240, pts: 10, msg: 'Tagline is well-sized for above-the-fold scanning.' },
+  { key: 'no_caps',            test: s => /[A-Z]/.test(s || ''),                              pts: 4,  msg: 'Sentence-case maintained — not all-lowercase or all-caps.' },
+];
+
+function checkRules(rules, val) {
+  const fired = [];
+  let delta = 0;
+  for (const r of rules) {
+    try {
+      if (r.test(val)) { fired.push({ key: r.key, pts: r.pts, msg: r.msg }); delta += r.pts; }
+    } catch {}
+  }
+  return { fired, delta };
+}
+
+export function runDeepDive(analysis) {
+  const meta = analysis?.meta_json || {};
+  const summary = analysis?.summary_json || {};
+  const title = summary.title || meta.title || '';
+  const tagline = summary.tagline || meta.tagline || '';
+  const vertical = analysis?.vertical || 'generic';
+  const palette = Array.isArray(meta.palette) ? meta.palette : [];
+  const heroImage = !!meta.hero_image;
+  const igHandle = !!analysis?.ig_handle;
+  const iframeBlocked = !!meta.iframe_blocked;
+
+  const seo = checkRules(SEO_RULES, title);
+  const content = checkRules(CONTENT_RULES, tagline);
+
+  // Composition / a11y heuristics — synthesize from what we have
+  const composition = [];
+  if (!heroImage) composition.push({ key: 'no_hero', pts: -5, msg: 'No hero image scraped — add an authentic photograph or commissioned illustration.' });
+  if (palette.length === 0) composition.push({ key: 'no_palette', pts: -3, msg: 'No palette inferred — site CSS may rely on framework defaults; add 2-3 brand colors.' });
+  else if (palette.length >= 5) composition.push({ key: 'palette_ok', pts: 5, msg: 'Palette has ≥5 colors — designers have headroom for hierarchy.' });
+  if (!igHandle) composition.push({ key: 'no_ig', pts: -3, msg: 'No Instagram linked — social proof + content reuse opportunity missed.' });
+  if (iframeBlocked) composition.push({ key: 'xfo_set', pts: 3, msg: 'X-Frame-Options set — small security win, but blocks our preview embed.' });
+  if (vertical !== 'generic') composition.push({ key: 'vertical_detected', pts: 5, msg: `Vertical detected as "${vertical}" — site copy aligns with category keywords.` });
+
+  // Top-3 actionable suggestions — vertical-specific
+  const tips = (VERTICAL_TIPS[vertical] || VERTICAL_TIPS.generic).slice(0, 3);
+
+  // Suggested replacement headlines (3, drawn from tagline if possible)
+  const sentences = tagline.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length >= 12);
+  const headlines = sentences.length >= 3
+    ? sentences.slice(0, 3).map(s => s.charAt(0).toUpperCase() + s.slice(1))
+    : [
+        `${title || 'Your business'} — what we actually do.`,
+        `Why ${title || 'we'} matters now.`,
+        `Get started in 30 seconds.`,
+      ];
+
+  // Aggregate score
+  const sectionScore = (50 + seo.delta + content.delta + composition.reduce((acc, c) => acc + c.pts, 0));
+  const score = Math.max(0, Math.min(100, sectionScore));
+
+  return {
+    generated_at: new Date().toISOString(),
+    score,
+    sections: {
+      seo:        { score_delta: seo.delta,        findings: seo.fired },
+      content:    { score_delta: content.delta,    findings: content.fired },
+      composition:{ score_delta: composition.reduce((a, c) => a + c.pts, 0), findings: composition },
+    },
+    suggested_headlines: headlines,
+    vertical_tips: tips,
+    quick_wins: [
+      ...seo.fired.filter(r => r.pts < 0).map(r => r.msg),
+      ...content.fired.filter(r => r.pts < 0).map(r => r.msg),
+      ...composition.filter(r => r.pts < 0).map(r => r.msg),
+    ].slice(0, 5),
+  };
+}
diff --git a/src/lib/escape.js b/src/lib/escape.js
index a6ad11b..3c3f60e 100644
--- a/src/lib/escape.js
+++ b/src/lib/escape.js
@@ -9,3 +9,39 @@ export function esc(s) {
     .replace(/'/g, '&#39;');
 }
 export function attr(s) { return esc(s); }
+
+// Escape a stringified JSON blob for safe embedding inside
+// <script type="application/ld+json">. Without this, content containing
+// "</script>" (or other angle brackets / ampersands) can break out of the
+// script tag (XSS). Use this before interpolating ANY ld+json blob.
+// (Refactor: was duplicated in profile.js, corridor.js, corridor-map.js,
+// near.js, fresh.js, category.js, proprietors.js — single source of truth.)
+export function escJsonLd(json) {
+  return String(json)
+    .replace(/</g, '\\u003c')
+    .replace(/>/g, '\\u003e')
+    .replace(/&/g, '\\u0026');
+}
+
+// Reject href values that aren't a real http/https URL. Returns the
+// trimmed URL string when safe, null otherwise. Use to neutralize
+// stored-XSS via `javascript:` / `data:` href values that snuck into the DB.
+export function safeHref(raw) {
+  if (typeof raw !== 'string') return null;
+  const u = raw.trim();
+  if (!u) return null;
+  try {
+    const p = new URL(u);
+    if (p.protocol === 'http:' || p.protocol === 'https:') return p.toString();
+  } catch {}
+  return null;
+}
+
+// Validate user-supplied hex colors before they hit a <style> block.
+// Returns the color if it matches #rgb / #rgba / #rrggbb / #rrggbbaa, else fallback.
+// This blocks stored-XSS payloads of the form `</style><script>...</script>`
+// being interpolated as CSS variable values.
+export function safeColor(c, fallback = '#1f2937') {
+  if (typeof c !== 'string') return fallback;
+  return /^#[0-9a-f]{3,8}$/i.test(c.trim()) ? c.trim() : fallback;
+}
diff --git a/src/lib/ollama-draft.js b/src/lib/ollama-draft.js
new file mode 100644
index 0000000..10e2323
--- /dev/null
+++ b/src/lib/ollama-draft.js
@@ -0,0 +1,183 @@
+// Ollama-backed AI draft for shop interviews.
+//
+// Calls local qwen3:14b on Mac Studio 1 (per Steve's memory:
+// "Default Ollama target = Mac Studio 1 — bulk LLM workloads point at
+//  http://192.168.1.133:11434, not localhost. MS2 froze 2026-05-02 with
+//  v3+v5+codex on the same GPU.")
+//
+// Returns {qa: {<question_id>: <draft_answer>, ...}} so the owner can
+// open the interview form and edit before saving.
+
+import { QUESTIONS, localizeQuestion } from './interview-questions.js';
+
+// Primary Ollama target = MS1 (per Steve's memory). Fallback to localhost
+// (Mac2) if MS1 is unreachable OR has its known wedge condition where /api/ps
+// shows a model loaded but /api/generate hangs indefinitely. Override either
+// via env var.
+const OLLAMA_URLS = [
+  process.env.OLLAMA_URL || 'http://192.168.1.133:11434',
+  process.env.OLLAMA_FALLBACK_URL || 'http://127.0.0.1:11434',
+];
+// Default to gemma3:12b — typically faster than qwen3:14b on Apple Silicon
+// AND avoids the "thinking-tokens eat the budget" issue qwen3 has under
+// /api/generate. Confirmed loaded on MS1; Mac2 needs `ollama pull gemma3:12b`
+// once (run by bulk-draft warmup or manually). Override via env.
+const OLLAMA_MODEL = process.env.OLLAMA_DRAFT_MODEL || 'gemma3:12b';
+
+// Trim a JSON-shaped response from a chatty LLM. qwen3 sometimes wraps the
+// JSON in ```json fences or prepends a sentence; we only want the first {…}.
+function extractJson(text) {
+  if (!text) return null;
+  const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
+  if (fenced) {
+    try { return JSON.parse(fenced[1]); } catch {}
+  }
+  const m = text.match(/\{[\s\S]*\}/);
+  if (m) {
+    try { return JSON.parse(m[0]); } catch {}
+  }
+  return null;
+}
+
+function buildPrompt(business) {
+  const b = business;
+  // Build the question list with localized phrasing + max-char hints.
+  const questionLines = QUESTIONS.map(q => {
+    const lq = localizeQuestion(q, b);
+    return `  - id: ${q.id}  (≤${q.max_chars} chars · section: ${q.section})\n    Q: ${lq.q}\n    Example tone: ${lq.placeholder}`;
+  }).join('\n');
+
+  // Pull every scrap of context we have on the shop.
+  const contextBits = [
+    `Name: ${b.name || '(unknown)'}`,
+    b.neighborhood && `Neighborhood: ${b.neighborhood}`,
+    b.city && `City: ${b.city}`,
+    b.address && `Address: ${b.address}`,
+    b.category && `Category: ${b.category}`,
+    b.years_in_business && `Years in business: ${b.years_in_business}`,
+    b.owner_name && `Owner: ${b.owner_name}`,
+    b.website && `Website: ${b.website}`,
+    b.about_text && `About text on file: ${b.about_text}`,
+    b.source_data_json?.vibe && `Editor's vibe note: ${b.source_data_json.vibe}`,
+  ].filter(Boolean).join('\n');
+
+  return `/no_think
+You are drafting a magazine-style interview profile for ${b.name}, a ${b.category || 'salon'} in ${b.neighborhood || 'Los Angeles'}.
+
+CONTEXT WE KNOW:
+${contextBits}
+
+YOUR TASK: Draft answers for each question below. The answers will be EDITED by the actual shop owner before publication, so this is a starter draft, not a final voice. Aim for:
+- specific, opinionated, plain-English (no marketing copy, no "we strive to")
+- when you don't know a real fact (e.g., a real restaurant name nearby), invent a plausible-sounding LA neighborhood name they MIGHT recommend, with a clear "[VERIFY]" tag the owner will replace
+- 1-3 sentences per answer, tight
+- match the example-tone shown for each question
+
+QUESTIONS:
+${questionLines}
+
+OUTPUT FORMAT: Return ONLY valid JSON, no commentary. Shape:
+{
+  "qa": {
+    "origin_story": "We took over this space in...",
+    "who_walks_in": "...",
+    ...one key per question id above
+  }
+}
+
+If you genuinely cannot draft a question (e.g., asks about something you have zero signal on), set its value to "" — the owner will fill it.
+Output the JSON now.`;
+}
+
+// Probe an Ollama host to confirm /api/generate (not just /api/ps) actually
+// works. The MS1 wedge fails this — model shows in /api/ps but generate hangs.
+// Long timeout so a cold-load (qwen3:14b ~ 14GB on Apple Silicon ≈ 30-60s
+// first-touch) counts as a successful warmup instead of failing fast.
+async function probeOllama(url, perHostTimeoutMs = 75000) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), perHostTimeoutMs);
+  try {
+    const res = await fetch(`${url}/api/generate`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      signal: ctrl.signal,
+      body: JSON.stringify({
+        model: OLLAMA_MODEL, prompt: 'ok', stream: false,
+        options: { num_predict: 2 }, keep_alive: '20m',
+      }),
+    });
+    return res.ok;
+  } catch { return false; }
+  finally { clearTimeout(t); }
+}
+
+async function pickOllamaUrl() {
+  for (const url of OLLAMA_URLS) {
+    if (await probeOllama(url)) return url;
+  }
+  return null;
+}
+
+// Cheap helper: probe N hosts in parallel and return the responsive ones.
+// Used by bulk-draft scripts that want to fan out across alive hosts.
+export async function aliveHosts() {
+  const probes = await Promise.all(
+    OLLAMA_URLS.map(async (u) => ({ url: u, ok: await probeOllama(u) }))
+  );
+  return probes.filter(p => p.ok).map(p => p.url);
+}
+
+export const KNOWN_HOSTS = OLLAMA_URLS.slice();
+
+export async function draftInterview(business, { timeoutMs = 240_000, host } = {}) {
+  // If a specific host was pinned (e.g., bulk worker pinned to MS1), skip
+  // discovery and trust the caller. Otherwise auto-pick the first responsive.
+  const url = host || await pickOllamaUrl();
+  if (!url) {
+    throw new Error(
+      'No Ollama host responsive. Tried: ' + OLLAMA_URLS.join(', ') +
+      '. (MS1 wedge? `ssh user@192.168.1.133 pkill ollama && ollama serve &`)'
+    );
+  }
+  const prompt = buildPrompt(business);
+  const ctrl = new AbortController();
+  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
+  try {
+    const res = await fetch(`${url}/api/generate`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      signal: ctrl.signal,
+      body: JSON.stringify({
+        model: OLLAMA_MODEL,
+        prompt,
+        // Don't enforce format:json — qwen3's <think> tokens get truncated and
+        // produce {}. We extract JSON from free-text response below.
+        stream: false,
+        keep_alive: '30m',
+        options: { temperature: 0.7, num_predict: 4000 },
+      }),
+    });
+    if (!res.ok) throw new Error(`Ollama HTTP ${res.status}: ${await res.text().catch(() => '')}`);
+    const j = await res.json();
+    // Strip <think>...</think> blocks before extracting JSON.
+    const stripped = String(j.response || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
+    const parsed = extractJson(stripped);
+    if (!parsed || !parsed.qa) {
+      throw new Error('Ollama returned no parseable JSON. raw: ' + String(j.response).slice(0, 400));
+    }
+    const known = new Set(QUESTIONS.map(q => q.id));
+    const qa = {};
+    for (const [k, v] of Object.entries(parsed.qa)) {
+      if (!known.has(k)) continue;
+      const s = String(v || '').trim();
+      if (s) qa[k] = s.slice(0, 4000);
+    }
+    return {
+      qa, model: OLLAMA_MODEL, host: url,
+      eval_count: j.eval_count,
+      total_duration_ms: Math.round((j.total_duration || 0) / 1e6),
+    };
+  } finally {
+    clearTimeout(timer);
+  }
+}
diff --git a/src/lib/svg-hero.js b/src/lib/svg-hero.js
new file mode 100644
index 0000000..be44085
--- /dev/null
+++ b/src/lib/svg-hero.js
@@ -0,0 +1,88 @@
+// Deterministic SVG hero pattern — used both as mockup background and as
+// builds-index card background. 6 patterns, hashed off seed. Pure data-URI,
+// no network, no images.
+
+function hashToInt(s, max) {
+  let h = 5381;
+  for (let i = 0; i < s.length; i += 1) h = (h * 33 + s.charCodeAt(i)) >>> 0;
+  return h % max;
+}
+
+export function svgHero(seed, c1 = '#111111', c2 = '#facc15') {
+  const v = hashToInt(seed, 6);
+  const a = encodeURIComponent(c1);
+  const b = encodeURIComponent(c2);
+  const svgs = [
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='none' stroke='${b}' stroke-width='2' opacity='.5'>${[...Array(12)].map((_,i)=>`<circle cx='300' cy='400' r='${30+i*32}'/>`).join('')}</g></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='10' opacity='.6'>${[...Array(20)].map((_,i)=>`<line x1='${-200+i*60}' y1='0' x2='${200+i*60}' y2='800'/>`).join('')}</g></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='${b}'>${[...Array(10)].flatMap((_,r)=>[...Array(8)].map((__,c)=>`<circle cx='${30+c*75}' cy='${30+r*82}' r='8'/>`)).join('')}</g></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?100:50}`).join(' ')}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?500:450}`).join(' ')}'/></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><rect x='80' y='60' width='320' height='400' fill='${b}' opacity='.6'/><rect x='200' y='220' width='320' height='480' fill='${b}' opacity='.4'/><rect x='40' y='400' width='280' height='320' fill='${b}' opacity='.7'/></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='4' opacity='.55'>${[...Array(36)].map((_,i)=>`<line x1='300' y1='400' x2='${300+Math.cos(i/36*Math.PI*2)*600}' y2='${400+Math.sin(i/36*Math.PI*2)*600}'/>`).join('')}</g><circle cx='300' cy='400' r='80' fill='${b}'/></svg>`,
+  ];
+  const svg = svgs[v].replace(/'/g, '"');
+  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+}
+
+const VERT_PALETTE = {
+  salon:        ['#1f1117', '#f4a8c0'],
+  restaurant:   ['#1a0e0a', '#e8843e'],
+  lawyer:       ['#0f1830', '#88aae0'],
+  vet:          ['#0a2024', '#5fc09a'],
+  advocacy:     ['#0a0a0a', '#dc2626'],
+  'saas-landing': ['#0d141f', '#7aa6ff'],
+  ecom:         ['#1a0d24', '#cf76e6'],
+  generic:      ['#0a0a0a', '#facc15'],
+};
+
+export function paletteFor(vertical) {
+  return VERT_PALETTE[vertical] || VERT_PALETTE.generic;
+}
+
+// Build a CSS stylesheet containing all 6 patterns × 8 verticals = 48 classes.
+// Cards reference by class name → page weight stays flat regardless of card
+// count.
+export function svgHeroStylesheet() {
+  const verts = Object.keys(VERT_PALETTE);
+  const lines = [];
+  for (const v of verts) {
+    const [c1, c2] = VERT_PALETTE[v];
+    for (let p = 0; p < 6; p += 1) {
+      const seed = `pattern-${p}`; // forces hash to land on pattern p? no — better to pass forced index
+    }
+  }
+  // Simpler: encode 48 patterns directly by forcing `v` (variant) without hash
+  // — accept (vertical, pattern_idx) as the class signature.
+  const out = [];
+  for (const v of verts) {
+    const [c1, c2] = VERT_PALETTE[v];
+    for (let p = 0; p < 6; p += 1) {
+      const uri = svgHeroForceVariant(p, c1, c2);
+      out.push(`.bg-${v.replace('-', '_')}-${p}{background-image:linear-gradient(180deg,rgba(255,255,255,.92) 0%,rgba(255,255,255,.86) 60%,rgba(255,255,255,.96) 100%),url('${uri}');background-size:cover;background-position:center}`);
+    }
+  }
+  return out.join('\n');
+}
+
+export function classFor(seed, vertical) {
+  let h = 5381;
+  for (let i = 0; i < seed.length; i += 1) h = (h * 33 + seed.charCodeAt(i)) >>> 0;
+  const p = h % 6;
+  const v = (vertical && VERT_PALETTE[vertical]) ? vertical : 'generic';
+  return `bg-${v.replace('-', '_')}-${p}`;
+}
+
+function svgHeroForceVariant(v, c1, c2) {
+  const a = encodeURIComponent(c1);
+  const b = encodeURIComponent(c2);
+  const svgs = [
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='none' stroke='${b}' stroke-width='2' opacity='.5'>${[...Array(12)].map((_,i)=>`<circle cx='300' cy='400' r='${30+i*32}'/>`).join('')}</g></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='10' opacity='.6'>${[...Array(20)].map((_,i)=>`<line x1='${-200+i*60}' y1='0' x2='${200+i*60}' y2='800'/>`).join('')}</g></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='${b}'>${[...Array(10)].flatMap((_,r)=>[...Array(8)].map((__,c)=>`<circle cx='${30+c*75}' cy='${30+r*82}' r='8'/>`)).join('')}</g></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?100:50}`).join(' ')}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?500:450}`).join(' ')}'/></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><rect x='80' y='60' width='320' height='400' fill='${b}' opacity='.6'/><rect x='200' y='220' width='320' height='480' fill='${b}' opacity='.4'/><rect x='40' y='400' width='280' height='320' fill='${b}' opacity='.7'/></svg>`,
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='4' opacity='.55'>${[...Array(36)].map((_,i)=>`<line x1='300' y1='400' x2='${300+Math.cos(i/36*Math.PI*2)*600}' y2='${400+Math.sin(i/36*Math.PI*2)*600}'/>`).join('')}</g><circle cx='300' cy='400' r='80' fill='${b}'/></svg>`,
+  ];
+  const svg = svgs[v].replace(/'/g, '"');
+  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+}
diff --git a/src/lib/vertical.js b/src/lib/vertical.js
new file mode 100644
index 0000000..78a6021
--- /dev/null
+++ b/src/lib/vertical.js
@@ -0,0 +1,42 @@
+// Cheap heuristic classifier — no LLM, just keyword match on already-scraped meta.
+// Returns one of: salon, restaurant, lawyer, vet, ecom, saas-landing, advocacy, generic.
+
+const RULES = [
+  { v: 'salon',         kws: ['salon','barber','barbershop','nails','nail salon','blowout','color services','stylist','haircut'] },
+  { v: 'restaurant',    kws: ['restaurant','menu','reservations','dine','cafe','bistro','eatery','catering','bar & grill'] },
+  { v: 'lawyer',        kws: ['attorney','lawyer','law firm','legal services','injury law','dui','divorce attorney','of counsel'] },
+  { v: 'vet',           kws: ['veterinary','veterinarian','animal hospital','pet clinic','vaccination','spay','neuter'] },
+  { v: 'advocacy',      kws: ['501(c)','non-profit','non profit','donate','petition','take action','our mission','volunteer','coalition','student debt','justice'] },
+  { v: 'saas-landing',  kws: ['platform','api','sdk','dashboard','sign up free','start free trial','pricing per seat','log in','features','docs'] },
+  { v: 'ecom',          kws: ['add to cart','shop now','free shipping','my bag','checkout','reviews ★','pdp','product details'] },
+];
+
+export function detectVertical({ title='', description='', og_description='', og_title='', html='' } = {}) {
+  const blob = [title, description, og_description, og_title, (html||'').slice(0, 8000)]
+    .join(' ').toLowerCase();
+  for (const { v, kws } of RULES) {
+    let hits = 0;
+    for (const kw of kws) if (blob.includes(kw)) hits += 1;
+    if (hits >= 2) return v;
+  }
+  // single-keyword fallback
+  for (const { v, kws } of RULES) for (const kw of kws) if (blob.includes(kw)) return v;
+  return 'generic';
+}
+
+// Pick 3 distinct theme directions for a given vertical.
+// Lite version of the mockups skill — used by the small-business-builder /website-analysis
+// entry endpoint.
+export function pickThemesFor(vertical) {
+  const POOL = {
+    salon:        ['editorial', 'neon', 'minimalist-spa'],
+    restaurant:   ['magazine', 'neon', 'minimalist'],
+    lawyer:       ['authoritative', 'minimalist', 'editorial'],
+    vet:          ['warm', 'editorial', 'minimalist'],
+    advocacy:     ['brutalist', 'magazine', 'tools-forward'],
+    'saas-landing': ['minimalist', 'editorial', 'brutalist'],
+    ecom:         ['editorial', 'minimalist', 'magazine'],
+    generic:      ['editorial', 'brutalist', 'minimalist'],
+  };
+  return POOL[vertical] || POOL.generic;
+}
diff --git a/src/render/builds-index.js b/src/render/builds-index.js
new file mode 100644
index 0000000..cc08887
--- /dev/null
+++ b/src/render/builds-index.js
@@ -0,0 +1,1144 @@
+// builds-index renderer — sortable + collapsible table of every ~/Projects build.
+// Brutalist palette to match the Website Analysis app it lives next to.
+
+import { esc } from '../lib/escape.js';
+import { svgHeroStylesheet, classFor } from '../lib/svg-hero.js';
+
+function fmtDate(iso) {
+  if (!iso) return '<span style="color:#aaa">—</span>';
+  const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return '<span style="color:#aaa">—</span>';
+  const today = new Date();
+  const diffMs = today - d;
+  const days = Math.floor(diffMs / 86400000);
+  const ymd = d.toISOString().slice(0, 10);
+  let rel = '';
+  if (days === 0) rel = 'today';
+  else if (days === 1) rel = '1d ago';
+  else if (days < 7) rel = `${days}d ago`;
+  else if (days < 30) rel = `${Math.floor(days/7)}w ago`;
+  else if (days < 365) rel = `${Math.floor(days/30)}mo ago`;
+  else rel = `${Math.floor(days/365)}y ago`;
+  const stale = days > 30 ? '#aaa' : days > 7 ? '#666' : '#000';
+  return `<span title="${ymd}" style="color:${stale}"><span style="font-weight:700">${ymd}</span>  <span style="font-size:11px;color:#666">${rel}</span></span>`;
+}
+
+function safeUrl(u) {
+  if (!u) return null;
+  try { const p = new URL(u); if (p.protocol === 'http:' || p.protocol === 'https:') return p.toString(); } catch {}
+  return null;
+}
+
+function openLinks(row) {
+  const parts = [];
+  const u = safeUrl(row.url);
+  if (u) {
+    const label = u.replace(/^https?:\/\//, '').replace(/\/$/, '').slice(0, 30);
+    parts.push(`<a href="${esc(u)}" target="_blank" rel="noopener" style="display:inline-block;background:#000;color:#facc15;padding:3px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ ${esc(label)} ↗</a>`);
+  }
+  if (row.port) {
+    parts.push(`<a href="http://127.0.0.1:${row.port}" target="_blank" rel="noopener" style="display:inline-block;background:#facc15;color:#000;padding:3px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ :${row.port}</a>`);
+  }
+  // Always offer a Finder-open of the local dir.
+  parts.push(`<a href="file://${esc(row.path)}" style="display:inline-block;background:#fff;color:#000;border:2px solid #000;padding:1px 6px;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ FINDER</a>`);
+  // If we've built mockups for this project, link straight to them.
+  if (row.analysis_slug) {
+    parts.push(`<a href="/wa/${esc(row.analysis_slug)}" target="_blank" rel="noopener" style="display:inline-block;background:#22c55e;color:#000;padding:3px 8px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;margin:1px 0">★ MOCKUPS ↗</a>`);
+  }
+  return parts.map(p => `<div>${p}</div>`).join('');
+}
+
+function analyzeBtn(row) {
+  const u = safeUrl(row.url);
+  if (u) {
+    return `<form method="post" action="/api/website-analysis" style="display:inline;margin:0">
+      <input type="hidden" name="url" value="${esc(u)}">
+      <button type="submit" style="background:#facc15;color:#000;border:3px solid #000;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:11px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;width:100%">★ ANALYZE</button>
+    </form>`;
+  }
+  // No URL parsed — let user paste one inline.
+  return `<form method="post" action="/api/website-analysis" style="display:flex;gap:0;margin:0;border:3px solid #000">
+    <input name="url" type="text" placeholder="paste URL" style="flex:1;border:0;padding:6px 8px;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;min-width:0">
+    <button type="submit" style="background:#facc15;color:#000;border:0;border-left:3px solid #000;padding:6px 10px;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;cursor:pointer">★</button>
+  </form>`;
+}
+
+function pm2Badge(p) {
+  if (!p) return '';
+  if (p.status === 'online') {
+    const port = p.port ? `:${p.port}` : '';
+    const mem  = p.memory_mb ? ` ${p.memory_mb}M` : '';
+    const rst  = p.restarts > 0 ? ` ↻${p.restarts}` : '';
+    return `<div style="display:inline-block;background:#22c55e;color:#000;font-size:10px;font-weight:700;padding:1px 6px;letter-spacing:.04em;text-transform:uppercase">★ pm2 ${esc(p.name)}${esc(port)}${esc(mem)}${esc(rst)}</div>`;
+  }
+  return `<div style="display:inline-block;background:#dc2626;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;letter-spacing:.04em;text-transform:uppercase">★ pm2 ${esc(p.name)} ${esc(p.status)}</div>`;
+}
+
+const HEAD = `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Builds — Index</title>
+<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Space Mono',monospace;background:#fff;color:#000;line-height:1.4}
+.tape{background:#facc15;border-bottom:4px solid #000;padding:8px 0;overflow:hidden;white-space:nowrap;font-size:13px;font-weight:700;letter-spacing:.04em}
+.tape span{display:inline-block;animation:scroll 40s linear infinite}
+.tape span::after{content:'  ★  STEVE / BUILDS INDEX  ★  SORTABLE  ★  COLLAPSIBLE  ★  '}
+@keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
+.hd{background:#000;color:#fff;border-bottom:4px solid #000}
+.hd-inner{max-width:1640px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px}
+.lg{font-family:'Archivo Black',sans-serif;font-size:28px;letter-spacing:-.04em;text-transform:uppercase}
+.lg::after{content:'.';color:#facc15}
+.nv{display:flex;font-size:12px;font-weight:700;text-transform:uppercase}
+.nv a{padding:8px 14px;border:1px solid #fff;margin-right:-1px;text-decoration:none;color:#fff}
+.nv a.active{background:#facc15;color:#000}
+
+/* ===== Sidebar layout ===== */
+.layout{display:grid;grid-template-columns:240px minmax(0, 1fr);gap:24px;max-width:1640px;margin:0 auto;padding:24px}
+.sidebar{position:sticky;top:24px;align-self:start;max-height:calc(100vh - 48px);overflow-y:auto;background:#fafaf6;border:4px solid #000;padding:14px}
+.sb-section{margin-bottom:18px}
+.sb-section:last-child{margin-bottom:0}
+.sb-h{font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em;background:#000;color:#facc15;padding:5px 8px;margin-bottom:6px}
+.sidebar ul{list-style:none}
+.sidebar li{margin-bottom:1px}
+.sidebar li a{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 10px;font-size:12px;font-weight:700;text-decoration:none;color:#000;border:2px solid transparent;font-family:'Space Mono',monospace}
+.sidebar li a:hover{background:#fff;border-color:#000}
+.sidebar li a.is-active{background:#facc15;border-color:#000}
+.sidebar li a.grade-A{border-left:6px solid #22c55e;padding-left:6px}
+.sidebar li a.grade-B{border-left:6px solid #84cc16;padding-left:6px}
+.sidebar li a.grade-C{border-left:6px solid #facc15;padding-left:6px}
+.sidebar li a.grade-D{border-left:6px solid #f97316;padding-left:6px}
+.sidebar li a.grade-F{border-left:6px solid #dc2626;padding-left:6px}
+.sb-count{background:#000;color:#facc15;font-size:9px;padding:1px 6px;letter-spacing:.04em;font-weight:700;font-family:'Space Mono',monospace}
+.sidebar li a.is-active .sb-count{background:#000;color:#facc15}
+.sb-empty{font-size:10px;color:#999;padding:6px;font-style:italic}
+
+/* ===== Stats banner ===== */
+.stats-banner{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px;margin-bottom:24px;min-width:0}
+@media (min-width:641px) and (max-width:1640px){
+  .stats-banner{grid-template-columns:repeat(auto-fill,minmax(96px,1fr))}
+  .stat-val{font-size:24px !important}
+}
+.stat-cell{border:3px solid #000;padding:12px 14px;display:flex;flex-direction:column;justify-content:space-between;min-height:84px}
+.stat-val{font-family:'Archivo Black',sans-serif;font-size:32px;line-height:1;letter-spacing:-.02em}
+.stat-label{font-family:'Space Mono',monospace;font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-top:8px}
+
+/* ===== Action buttons (per-card) ===== */
+.bbtn{display:inline-block;background:#fff;color:#000;border:2px solid #000;padding:4px 9px;font-family:'Space Mono',monospace;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;cursor:pointer;margin:1px 0}
+.bbtn:hover{background:#facc15}
+.bbtn-primary{background:#000;color:#facc15}
+.bbtn-primary:hover{background:#facc15;color:#000}
+.bbtn-port{background:#facc15;color:#000}
+.bbtn-mock{background:#22c55e;color:#000}
+.bbtn-deep{background:#dbeafe;color:#000}
+.bbtn-deep:hover{background:#3b82f6;color:#fff}
+.bbtn-star.is-starred{background:#facc15;color:#000;border-color:#000}
+.bbtn-copy.is-copied{background:#22c55e;color:#000}
+
+/* ===== View header (for filtered views) ===== */
+.view-banner{background:#000;color:#facc15;padding:14px 20px;border:4px solid #000;margin-bottom:18px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
+.view-banner .vb-title{font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:-.01em}
+.view-banner .vb-meta{font-family:'Space Mono',monospace;font-size:11px;font-weight:700;letter-spacing:.04em}
+.view-banner a{color:#facc15;text-decoration:underline}
+
+@media (max-width:880px){
+  .layout{grid-template-columns:1fr}
+  .sidebar{position:static;max-height:none}
+}
+
+main{max-width:1640px;margin:0 auto;padding:32px 24px}
+h1{font-family:'Archivo Black',sans-serif;font-size:64px;line-height:.9;text-transform:uppercase;letter-spacing:-.04em;margin-bottom:8px}
+h1 mark{background:#facc15;padding:0 8px}
+.lead{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;margin-bottom:24px}
+.controls{display:flex;gap:12px;align-items:center;margin-bottom:24px;flex-wrap:wrap}
+.controls input{flex:1;min-width:240px;padding:14px 18px;border:4px solid #000;font-family:'Space Mono',monospace;font-size:14px;font-weight:700;text-transform:uppercase}
+.controls button{background:#000;color:#facc15;border:4px solid #000;padding:14px 18px;font-family:'Archivo Black',sans-serif;font-size:13px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
+details{border:4px solid #000;margin-bottom:16px;background:#fff}
+details summary{background:#000;color:#facc15;padding:14px 20px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:-.01em;cursor:pointer;user-select:none;display:flex;justify-content:space-between;align-items:center}
+details summary::-webkit-details-marker{display:none}
+details summary::after{content:' ▼';font-size:14px}
+details[open] summary::after{content:' ▲'}
+details summary .count{background:#facc15;color:#000;padding:2px 10px;font-size:13px;letter-spacing:.04em}
+table{width:100%;border-collapse:collapse;table-layout:fixed}
+th,td{padding:10px 14px;text-align:left;border-bottom:2px solid #000;vertical-align:top;overflow:hidden;text-overflow:ellipsis}
+th{background:#fef3c7;font-family:'Archivo Black',sans-serif;font-size:12px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;user-select:none;border-bottom:4px solid #000;position:sticky;top:0;z-index:1}
+th.sortable:hover{background:#facc15}
+th[data-dir="asc"]::after{content:' ▲'}
+th[data-dir="desc"]::after{content:' ▼'}
+tr:nth-child(even) td{background:#fafaf6}
+tr.hidden{display:none}
+.bcard:hover{transform:translateY(-2px);box-shadow:6px 6px 0 #000;border-color:#000;background:#fef3c7}
+.bcard:active{transform:translateY(0);box-shadow:none}
+.rail-card{aspect-ratio:auto !important;height:auto !important;scroll-snap-align:start}
+@media (max-width:640px){
+  #grid{grid-template-columns:repeat(2,1fr) !important}
+  #cols,#cols-label{display:none}
+  h1{font-size:40px !important}
+  .bcard{padding:10px}
+  .bcard div[style*='font-size:14px']{font-size:12px}
+}
+/* When grid columns ≥ 9, force NEEDS WORK to compact ★ */
+#grid:has([style*='grid-template-columns:repeat(9'])  .needs-work-full,
+#grid[style*='repeat(10'] .needs-work-full,
+#grid[style*='repeat(11'] .needs-work-full,
+#grid[style*='repeat(12'] .needs-work-full{display:none !important}
+#grid[style*='repeat(9']  .needs-work-short,
+#grid[style*='repeat(10'] .needs-work-short,
+#grid[style*='repeat(11'] .needs-work-short,
+#grid[style*='repeat(12'] .needs-work-short{display:inline !important}
+@media (min-width:641px) and (max-width:1024px){
+  #grid{grid-template-columns:repeat(4,1fr) !important}
+}
+@media (min-width:1025px) and (max-width:1640px){
+  #grid{column-gap:8px}
+}
+@media (min-width:2400px){
+  #grid:not([data-cols-locked]){grid-template-columns:repeat(14,1fr) !important}
+}
+.grade-rail{margin-bottom:18px}
+.grade-rail summary{font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;cursor:pointer;user-select:none;padding:8px 14px;background:#fff;border:3px solid #000;display:flex;justify-content:space-between;align-items:center}
+.grade-rail summary::-webkit-details-marker{display:none}
+.grade-rail summary::after{content:' ▼';font-size:11px}
+.grade-rail[open] summary::after{content:' ▲'}
+.grade-rail-body{display:grid;grid-auto-flow:column;grid-auto-columns:200px;gap:10px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding:12px 0 8px;border-left:3px solid #000;border-right:3px solid #000;border-bottom:3px solid #000;background:#fff}
+.grade-rail-body .bcard{aspect-ratio:auto !important;height:160px}
+/* ★ Detail drawer — slide-in from right */
+.drawer{position:fixed;top:0;right:0;bottom:0;width:min(640px,90vw);background:#fff;border-left:4px solid #000;box-shadow:-12px 0 0 #000a;transform:translateX(105%);transition:transform .25s ease-out;z-index:50;overflow-y:auto;padding:24px}
+.drawer.is-open{transform:translateX(0)}
+.drawer-backdrop{position:fixed;inset:0;background:#0008;opacity:0;pointer-events:none;transition:opacity .2s;z-index:49}
+.drawer-backdrop.is-open{opacity:1;pointer-events:auto}
+.drawer-close{position:absolute;top:14px;right:14px;background:#000;color:#facc15;border:0;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer}
+.peek-btn{position:absolute;top:8px;left:8px;background:#000;color:#facc15;border:2px solid #000;padding:1px 6px;font-family:'Archivo Black',sans-serif;font-size:10px;letter-spacing:.04em;text-decoration:none;cursor:pointer;opacity:0;transition:opacity .15s}
+.bcard:hover .peek-btn{opacity:1}
+.bcard.is-starred{outline:3px solid #facc15;outline-offset:-3px}
+.bcard.is-starred::before{content:'★';position:absolute;top:6px;left:6px;background:#facc15;color:#000;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-family:'Archivo Black',sans-serif;font-size:14px;border:2px solid #000;z-index:2}
+@keyframes pulse-update{0%{box-shadow:0 0 0 0 #22c55e;background-color:#dcfce7}50%{box-shadow:0 0 0 8px #22c55e00;background-color:#fff}100%{box-shadow:0 0 0 0 #22c55e00;background-color:#fff}}
+/* ★ Spinner for in-progress drawer actions */
+@keyframes dd-spin{to{transform:rotate(360deg)}}
+.dd-spinner{display:inline-block;width:12px;height:12px;border:2px solid #000;border-right-color:transparent;border-radius:50%;animation:dd-spin .9s linear infinite;vertical-align:-2px;margin-right:6px}
+/* ★ kbd hint pill */
+.kbd-hints{position:fixed;bottom:14px;right:14px;background:#000;color:#facc15;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;padding:8px 12px;border:3px solid #facc15;letter-spacing:.04em;z-index:40;display:none;max-width:300px}
+.kbd-hints.is-open{display:block}
+.kbd-hints kbd{background:#facc15;color:#000;padding:1px 5px;font-family:inherit;font-weight:700}
+${svgHeroStylesheet()}
+.col-name{width:18%}
+.col-desc{width:30%;font-size:12px;color:#444;font-weight:400}
+.col-cat{width:8%}
+.col-last{width:13%;font-size:12px}
+.col-open{width:18%}
+.col-analyze{width:13%}
+.cat-tag{display:inline-block;background:#000;color:#facc15;padding:2px 8px;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
+.empty{padding:48px;text-align:center;color:#666;font-weight:700;text-transform:uppercase;letter-spacing:.04em}
+footer{padding:32px 24px;border-top:4px solid #000;background:#000;color:#aaa;text-align:center;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin-top:48px}
+footer a{color:#facc15;text-decoration:none}
+</style></head><body>
+<div class="tape"><span></span></div>
+<header class="hd"><div class="hd-inner">
+  <div class="lg">BUILDS / INDEX</div>
+  <nav class="nv">
+    <a class="active" href="/builds">BUILDS</a>
+    <a href="/website-analysis">ANALYSIS</a>
+    <a href="/">SMB BUILDER</a>
+  </nav>
+</div></header>
+<div class="layout">
+__SIDEBAR_PLACEHOLDER__
+<main style="padding:0">
+`;
+
+const FOOT = `</main>
+</div>
+<footer>★ ${new Date().getFullYear()} AGENT ABRAMS ★ SCANS ~/Projects + MEMORY.md LIVE ★ <a href="/website-analysis">RUN A NEW ANALYSIS</a> ★</footer>
+<script>
+(function() {
+  const filter = document.getElementById('filter');
+  const onlineToggle = document.getElementById('filter-online');
+  const gitToggle = document.getElementById('filter-git');
+  const viewGrid = document.getElementById('view-grid');
+  const viewTable = document.getElementById('view-table');
+  const grid = document.getElementById('grid');
+  const tableView = document.getElementById('table-view');
+  const railSection = document.getElementById('rail-section');
+  const gridControls = document.getElementById('grid-controls');
+  const cols = document.getElementById('cols');
+  const colsLabel = document.getElementById('cols-label');
+  const gridCount = document.getElementById('grid-count');
+  const rail = document.getElementById('rail');
+  let onlyOnline = false, onlyGit = false, onlyStale = false, onlyTop = false;
+  const staleToggle = document.getElementById('filter-stale');
+  const topToggle = document.getElementById('filter-top');
+
+  // ★ density slider — persist to localStorage so refresh keeps Steve's pick
+  const SAVED_COLS = parseInt(localStorage.getItem('builds.cols') || '6', 10);
+  cols.value = String(Math.min(12, Math.max(4, SAVED_COLS)));
+  function applyCols() {
+    const n = parseInt(cols.value, 10);
+    grid.style.gridTemplateColumns = 'repeat(' + n + ', 1fr)';
+    colsLabel.textContent = n;
+    localStorage.setItem('builds.cols', String(n));
+  }
+  applyCols();
+  cols?.addEventListener('input', applyCols);
+
+  // ★ view toggle — grid (default) vs table (legacy)
+  const SAVED_VIEW = localStorage.getItem('builds.view') || 'grid';
+  function applyView(v) {
+    const isGrid = v === 'grid';
+    grid.style.display = isGrid ? 'grid' : 'none';
+    railSection.style.display = isGrid ? '' : 'none';
+    gridControls.style.display = isGrid ? 'flex' : 'none';
+    tableView.style.display = isGrid ? 'none' : 'block';
+    viewGrid.style.background = isGrid ? '#facc15' : '#000';
+    viewGrid.style.color = isGrid ? '#000' : '#facc15';
+    viewTable.style.background = isGrid ? '#000' : '#facc15';
+    viewTable.style.color = isGrid ? '#facc15' : '#000';
+    localStorage.setItem('builds.view', v);
+  }
+  applyView(SAVED_VIEW);
+  viewGrid?.addEventListener('click', () => applyView('grid'));
+  viewTable?.addEventListener('click', () => applyView('table'));
+
+  // ★ rail nav — scroll by ~1.5 cards per click. data-rail picks the target.
+  document.querySelectorAll('.rail-nav').forEach(b => {
+    b.addEventListener('click', (e) => {
+      e.preventDefault();
+      const dir = parseInt(b.dataset.dir, 10);
+      const targetId = b.dataset.rail || 'rail';
+      const target = document.getElementById(targetId);
+      if (target) target.scrollBy({ left: dir * 360, behavior: 'smooth' });
+    });
+  });
+
+  function applyFilter() {
+    const q = (filter.value || '').toLowerCase().trim();
+    // table rows
+    document.querySelectorAll('tr[data-row]').forEach(tr => {
+      const t = tr.dataset.search || '';
+      const matches = !q || t.includes(q);
+      const pm2Ok = !onlyOnline || tr.dataset.pm2 === '1';
+      const gitOk = !onlyGit || /★ git/i.test(tr.querySelector('.col-name').textContent);
+      tr.classList.toggle('hidden', !(matches && pm2Ok && gitOk));
+    });
+    document.querySelectorAll('details').forEach(d => {
+      const countSpan = d.querySelector('.count');
+      if (!countSpan) return; // grade-rail details don't have .count
+      const visible = d.querySelectorAll('tr[data-row]:not(.hidden)').length;
+      countSpan.textContent = visible;
+      if (visible === 0) d.style.display = 'none'; else d.style.display = '';
+    });
+    // grid + rail cards
+    let shown = 0;
+    document.querySelectorAll('.bcard').forEach(c => {
+      const t = c.dataset.search || '';
+      const matches = !q || t.includes(q);
+      const pm2Ok = !onlyOnline || c.dataset.pm2 === '1';
+      const g = c.dataset.grade || 'Z';
+      const staleOk = !onlyStale || g === 'D' || g === 'F';
+      const topOk = !onlyTop || g === 'A' || g === 'B';
+      const visible = matches && pm2Ok && staleOk && topOk;
+      c.style.display = visible ? '' : 'none';
+      if (visible && c.parentElement.id === 'grid') shown += 1;
+    });
+    gridCount.textContent = shown + ' CARDS';
+  }
+  filter?.addEventListener('input', applyFilter);
+  // ★ Filter changes reset card focus to top — a stale focusIdx after filtering
+  // would point at a card that's now hidden.
+  filter?.addEventListener('input', () => { focusIdx = -1; document.querySelectorAll('.bcard').forEach(c => { c.style.outline = ''; }); });
+  onlineToggle?.addEventListener('click', () => {
+    onlyOnline = !onlyOnline;
+    onlineToggle.style.background = onlyOnline ? '#22c55e' : '#000';
+    onlineToggle.style.color = onlyOnline ? '#000' : '#facc15';
+    applyFilter();
+  });
+  gitToggle?.addEventListener('click', () => {
+    onlyGit = !onlyGit;
+    gitToggle.style.background = onlyGit ? '#facc15' : '#000';
+    gitToggle.style.color = onlyGit ? '#000' : '#facc15';
+    applyFilter();
+  });
+  staleToggle?.addEventListener('click', () => {
+    onlyStale = !onlyStale;
+    if (onlyStale) onlyTop = false;
+    staleToggle.style.background = onlyStale ? '#dc2626' : '#000';
+    staleToggle.style.color = onlyStale ? '#fff' : '#facc15';
+    topToggle.style.background = '#000'; topToggle.style.color = '#facc15';
+    applyFilter();
+  });
+  topToggle?.addEventListener('click', () => {
+    onlyTop = !onlyTop;
+    if (onlyTop) onlyStale = false;
+    topToggle.style.background = onlyTop ? '#22c55e' : '#000';
+    topToggle.style.color = onlyTop ? '#000' : '#facc15';
+    staleToggle.style.background = '#000'; staleToggle.style.color = '#facc15';
+    applyFilter();
+  });
+
+  // ★ Sync URL params + localStorage chip state on load. URL wins over storage
+  // so a deep link still works.
+  (function syncFromUrlAndStorage() {
+    const params = new URLSearchParams(location.search);
+    const view = (params.get('view') || '').toLowerCase();
+    const grade = (params.get('grade') || '').toUpperCase();
+    const stored = JSON.parse(localStorage.getItem('builds.chips') || '{}');
+    if (stored.online || view === 'active') onlineToggle.click();
+    if (stored.git) gitToggle.click();
+    if (stored.stale || view === 'stale') staleToggle.click();
+    if (stored.top || grade === 'A' || grade === 'B') topToggle.click();
+  })();
+  function persistChips() {
+    localStorage.setItem('builds.chips', JSON.stringify({
+      online: onlyOnline, git: onlyGit, stale: onlyStale, top: onlyTop,
+    }));
+  }
+  [onlineToggle, gitToggle, staleToggle, topToggle].forEach(b =>
+    b.addEventListener('click', () => setTimeout(persistChips, 0))
+  );
+
+  // ★ Sort dropdown — reorders #grid children by data attribute. Persisted.
+  const sortBy = document.getElementById('sort-by');
+  function applySort() {
+    const v = sortBy.value;
+    localStorage.setItem('builds.sort', v);
+    const cards = Array.from(grid.querySelectorAll('.bcard'));
+    cards.sort((a, b) => {
+      const get = (el, k) => parseFloat(el.dataset[k] || '0');
+      const getStr = (el, k) => (el.dataset[k] || '');
+      switch (v) {
+        case 'grade-score-desc':  return get(b, 'gradeScore') - get(a, 'gradeScore');
+        case 'audit-score-desc':  return get(b, 'auditScore') - get(a, 'auditScore');
+        case 'last-desc':         return get(b, 'last') - get(a, 'last');
+        case 'last-asc':          return get(a, 'last') - get(b, 'last');
+        case 'name-asc':          return getStr(a, 'name').localeCompare(getStr(b, 'name'));
+        default: return 0;
+      }
+    });
+    cards.forEach(c => grid.appendChild(c));
+  }
+  if (sortBy) {
+    const SAVED_SORT = localStorage.getItem('builds.sort');
+    if (SAVED_SORT) sortBy.value = SAVED_SORT;
+    sortBy.addEventListener('change', applySort);
+    applySort();
+  }
+
+  // ★ DRAWER — open with PEEK button or Enter on focused card
+  const drawer = document.getElementById('drawer');
+  const drawerBd = document.getElementById('drawer-bd');
+  const drawerBody = document.getElementById('drawer-body');
+  window._peekCurrentSlug = null;
+  window.openPeek = function(slug) {
+    window._peekCurrentSlug = slug;
+    drawer.classList.add('is-open');
+    drawerBd.classList.add('is-open');
+    drawer.setAttribute('aria-hidden', 'false');
+    drawerBody.innerHTML = '<div style="padding:32px;color:#666">★ Loading <strong>' + slug + '</strong>…</div>';
+    fetch('/api/wa/' + encodeURIComponent(slug) + '/peek')
+      .then(r => r.text())
+      .then(html => {
+        drawerBody.innerHTML = html;
+        // Reflect star state in the drawer button — Steve's body click
+        // listener flips the class, this just paints the initial label.
+        try {
+          const stars = new Set(JSON.parse(localStorage.getItem('agentabrams.builds.starred') || '[]'));
+          drawerBody.querySelectorAll('button[data-star]').forEach(b => {
+            const s = b.dataset.star;
+            if (stars.has(s)) { b.classList.add('is-starred'); b.textContent = '★ UNSTAR'; }
+            b.addEventListener('click', () => {
+              setTimeout(() => {
+                const cur = new Set(JSON.parse(localStorage.getItem('agentabrams.builds.starred') || '[]'));
+                b.textContent = cur.has(s) ? '★ UNSTAR' : '★ STAR';
+              }, 0);
+            });
+          });
+        } catch {}
+        // Intercept the inline DEEP-DIVE form so it doesn't navigate away —
+        // POST in-place, then refresh the peek fragment.
+        drawerBody.querySelectorAll('form[action$="/deep-dive"]').forEach(f => {
+          f.addEventListener('submit', (e) => {
+            e.preventDefault();
+            const btn = f.querySelector('button');
+            if (btn) { btn.disabled = true; btn.innerHTML = '<span class="dd-spinner"></span>RUNNING…'; }
+            fetch(f.action, { method: 'POST' })
+              .then(() => {
+                window.openPeek(window._peekCurrentSlug || slug);
+                // Mark the card itself dirty so a future filter pass / refresh
+                // pulls the new score. Server-side cache is busted by the
+                // deep-dive endpoint already; this is just visual.
+                const card = document.querySelector('.bcard[data-slug="' + (window._peekCurrentSlug || slug) + '"]');
+                if (card) {
+                  card.style.animation = 'pulse-update 1.6s ease-out';
+                  setTimeout(() => { card.style.animation = ''; }, 1700);
+                }
+              })
+              .catch(() => { if (btn) { btn.disabled = false; btn.textContent = '★ DEEP-DIVE'; } });
+          });
+        });
+      })
+      .catch(() => { drawerBody.innerHTML = '<div style="padding:32px;color:#dc2626">★ Failed to load.</div>'; });
+  };
+  window.closePeek = function() {
+    drawer.classList.remove('is-open');
+    drawerBd.classList.remove('is-open');
+    drawer.setAttribute('aria-hidden', 'true');
+  };
+  // ★ Drawer NEXT/PREV — step through currently-visible cards in #grid order.
+  window.peekStep = function(dir) {
+    const visible = Array.from(grid.querySelectorAll('.bcard'))
+      .filter(c => c.style.display !== 'none' && c.dataset.slug);
+    if (!visible.length) return;
+    const cur = window._peekCurrentSlug;
+    let idx = visible.findIndex(c => c.dataset.slug === cur);
+    if (idx < 0) idx = 0;
+    idx = (idx + dir + visible.length) % visible.length;
+    window.openPeek(visible[idx].dataset.slug);
+  };
+
+  // ★ j/k card focus nav + Enter open + p peek
+  let focusIdx = -1;
+  function highlightFocus() {
+    document.querySelectorAll('.bcard').forEach(c => { c.style.outline = ''; });
+    const visible = Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none');
+    if (focusIdx < 0 || focusIdx >= visible.length) return;
+    const c = visible[focusIdx];
+    c.style.outline = '4px solid #facc15';
+    c.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+  }
+  document.addEventListener('keydown', (e) => {
+    const tag = e.target?.tagName?.toLowerCase();
+    if (tag === 'input' || tag === 'textarea' || tag === 'select') {
+      if (e.key === 'Escape') { e.target.blur(); }
+      return;
+    }
+    if (e.key === 'Escape') { window.closePeek(); return; }
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    if (e.key === 'j') { focusIdx += 1; highlightFocus(); }
+    else if (e.key === 'k') { focusIdx = Math.max(0, focusIdx - 1); highlightFocus(); }
+    else if (e.key === 'Enter') {
+      const visible = Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none');
+      if (focusIdx >= 0 && visible[focusIdx]) visible[focusIdx].click();
+    } else if (e.key === 'p') {
+      const visible = Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none');
+      const c = visible[focusIdx];
+      if (c?.dataset?.slug) window.openPeek(c.dataset.slug);
+    } else if (e.key === '[' && drawer.classList.contains('is-open')) {
+      e.preventDefault(); window.peekStep(-1);
+    } else if (e.key === ']' && drawer.classList.contains('is-open')) {
+      e.preventDefault(); window.peekStep(1);
+    } else if (e.key === 's') {
+      // ★ Toggle star on the focused or peeked card
+      const target = window._peekCurrentSlug
+        || (focusIdx >= 0 ? Array.from(grid.querySelectorAll('.bcard')).filter(c => c.style.display !== 'none')[focusIdx]?.dataset.slug : null);
+      if (!target) return;
+      e.preventDefault();
+      const KEY = 'agentabrams.builds.starred';
+      let stars; try { stars = new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } catch { stars = new Set(); }
+      const wasStarred = stars.has(target);
+      if (wasStarred) stars.delete(target); else stars.add(target);
+      localStorage.setItem(KEY, JSON.stringify([...stars]));
+      const card = document.querySelector('.bcard[data-slug="' + target + '"]');
+      if (card) {
+        card.classList.toggle('is-starred', !wasStarred);
+        card.style.animation = 'pulse-update 1.2s ease-out';
+        setTimeout(() => { card.style.animation = ''; }, 1300);
+      }
+      // Sync any open drawer button label
+      drawerBody.querySelectorAll('button[data-star="' + target + '"]').forEach(b => {
+        b.textContent = wasStarred ? '★ STAR' : '★ UNSTAR';
+        b.classList.toggle('is-starred', !wasStarred);
+      });
+    }
+  });
+
+  // ★ Sidebar live count refresh — recompute view counts based on what's
+  // currently passing the JS chip filters, not the static server-side filter.
+  function refreshSidebarCounts() {
+    const visible = Array.from(document.querySelectorAll('#grid .bcard'))
+      .filter(c => c.style.display !== 'none');
+    document.querySelectorAll('.sidebar a').forEach(link => {
+      const span = link.querySelector('.sb-count');
+      if (!span) return;
+      const href = link.getAttribute('href') || '';
+      const m = href.match(/[?&]view=([a-z]+)/);
+      if (!m) return;
+      const view = m[1];
+      let n = 0;
+      const now = Date.now();
+      for (const c of visible) {
+        const last = parseInt(c.dataset.last || '0', 10);
+        const days = (now - last) / 86400000;
+        if (view === 'all') n += 1;
+        else if (view === 'active' && c.dataset.pm2 === '1') n += 1;
+        else if (view === 'today' && days < 1) n += 1;
+        else if (view === 'week' && days < 7) n += 1;
+        else if (view === 'stale' && (last === 0 || days > 30)) n += 1;
+        else if (view === 'graded' && c.dataset.grade !== 'Z') n += 1;
+        else if (view === 'ungraded' && c.dataset.grade === 'Z') n += 1;
+        else if (view === 'broken' && c.dataset.pm2 === '0') n += 1;
+        else if (view === 'url') n += 1; // can't know URL from card data alone
+      }
+      span.textContent = n;
+    });
+  }
+  // Hook into existing applyFilter via document-level event after every chip change.
+  // applyFilter is a function declaration; we observe via setInterval-poll-once
+  // pattern: dispatch a 'filter-applied' event after each call site triggers it.
+  // Simpler: wrap the existing toggles to also call refreshSidebarCounts.
+  const origs = [filter, onlineToggle, gitToggle, staleToggle, topToggle];
+  origs.forEach(el => el?.addEventListener(el.tagName === 'INPUT' ? 'input' : 'click', () => setTimeout(refreshSidebarCounts, 0)));
+  refreshSidebarCounts();
+
+  // ★ STAR ALL VISIBLE — bulk-star every currently-shown card. Reuses Steve's
+  // STAR_KEY so the sidebar Starred list updates immediately.
+  const starAllBtn = document.getElementById('star-all-visible');
+  if (starAllBtn) {
+    starAllBtn.addEventListener('click', () => {
+      const visible = Array.from(grid.querySelectorAll('.bcard'))
+        .filter(c => c.style.display !== 'none' && c.dataset.slug);
+      if (!visible.length) { alert('No visible cards.'); return; }
+      if (!confirm('Star ' + visible.length + ' visible cards?')) return;
+      const STAR_KEY = 'agentabrams.builds.starred';
+      let stars; try { stars = new Set(JSON.parse(localStorage.getItem(STAR_KEY) || '[]')); } catch { stars = new Set(); }
+      visible.forEach(c => { stars.add(c.dataset.slug); c.classList.add('is-starred'); });
+      localStorage.setItem(STAR_KEY, JSON.stringify([...stars]));
+      // Trigger Steve's existing refreshStarredSidebar by dispatching a synthetic click
+      // on a star button — simplest cross-IIFE bridge. Actually just reload.
+      const ul = document.getElementById('starred-list');
+      if (ul) {
+        ul.innerHTML = [...stars].slice(0, 20).map(s =>
+          '<li><a href="#" data-jump-to="' + s + '"><span>' + s + '</span><span class="sb-count">★</span></a></li>'
+        ).join('');
+      }
+    });
+  }
+
+  // ★ RESET button — clears filter input + all chip toggles + sort dropdown +
+  // localStorage chips/sort. Strips URL query params via history.replaceState.
+  const resetBtn = document.getElementById('filter-reset');
+  if (resetBtn) {
+    resetBtn.addEventListener('click', () => {
+      filter.value = '';
+      if (onlyOnline)  onlineToggle.click();
+      if (onlyGit)     gitToggle.click();
+      if (onlyStale)   staleToggle.click();
+      if (onlyTop)     topToggle.click();
+      if (sortBy) { sortBy.value = 'grade-score-desc'; applySort(); }
+      localStorage.removeItem('builds.chips');
+      localStorage.removeItem('builds.sort');
+      history.replaceState(null, '', '/builds');
+      applyFilter();
+    });
+  }
+
+  // ★ Fresh-pulse on initial paint — cards whose audit_score changed in the
+  // last 5 minutes get a brief green flash, so reloads after deep-dives clearly
+  // signal which records moved. Tracks last-seen audit_score per slug in
+  // localStorage and diffs against current data-audit-score.
+  (function pulseFreshlyChanged() {
+    let prev; try { prev = JSON.parse(localStorage.getItem('builds.lastscores') || '{}'); } catch { prev = {}; }
+    const next = {};
+    document.querySelectorAll('.bcard').forEach(c => {
+      const slug = c.dataset.slug; if (!slug) return;
+      const cur = parseInt(c.dataset.auditScore || '-1', 10);
+      next[slug] = cur;
+      if (prev[slug] != null && prev[slug] !== cur && cur >= 0) {
+        c.style.animation = 'pulse-update 1.6s ease-out';
+        setTimeout(() => { c.style.animation = ''; }, 1700);
+      }
+    });
+    localStorage.setItem('builds.lastscores', JSON.stringify(next));
+  })();
+
+  // ★ Initial star badge — paint .is-starred on cards already in the
+  // localStorage star set, so refreshing the page keeps stars visible.
+  (function paintInitialStars() {
+    let stars;
+    try { stars = new Set(JSON.parse(localStorage.getItem('agentabrams.builds.starred') || '[]')); } catch { return; }
+    if (stars.size === 0) return;
+    document.querySelectorAll('.bcard').forEach(c => {
+      if (stars.has(c.dataset.slug) || stars.has(c.dataset.name)) c.classList.add('is-starred');
+    });
+  })();
+
+  // ★ FIX VISIBLE — populate slugs hidden input with currently-visible card slugs
+  // at submit time, so the server only re-runs deep-dive on what's on screen.
+  const fixVisibleForm = document.getElementById('fix-visible-form');
+  const fixVisibleSlugs = document.getElementById('fix-visible-slugs');
+  if (fixVisibleForm) {
+    fixVisibleForm.addEventListener('submit', (e) => {
+      const slugs = [];
+      document.querySelectorAll('.bcard').forEach(c => {
+        if (c.style.display === 'none') return;
+        if (c.parentElement && c.parentElement.id !== 'grid') return; // grid only, skip rails
+        const s = c.dataset.slug;
+        if (s) slugs.push(s);
+      });
+      if (!slugs.length) {
+        e.preventDefault();
+        alert('No visible cards to fix.');
+        return;
+      }
+      if (!confirm('Force-rerun deep-dive on ' + slugs.length + ' visible cards?')) {
+        e.preventDefault(); return;
+      }
+      fixVisibleSlugs.value = slugs.join(',');
+    });
+  }
+  document.getElementById('expand-all')?.addEventListener('click', () => {
+    document.querySelectorAll('details').forEach(d => d.open = true);
+  });
+  document.getElementById('collapse-all')?.addEventListener('click', () => {
+    document.querySelectorAll('details').forEach(d => d.open = false);
+  });
+  document.querySelectorAll('th.sortable').forEach(th => {
+    th.addEventListener('click', () => {
+      const tbody = th.closest('table').querySelector('tbody');
+      const idx = Array.from(th.parentNode.children).indexOf(th);
+      const key = th.dataset.key;
+      const cur = th.dataset.dir;
+      const dir = cur === 'asc' ? 'desc' : 'asc';
+      th.parentNode.querySelectorAll('th').forEach(o => o.removeAttribute('data-dir'));
+      th.dataset.dir = dir;
+      const rows = Array.from(tbody.querySelectorAll('tr[data-row]'));
+      rows.sort((a, b) => {
+        const av = a.dataset[key] || '';
+        const bv = b.dataset[key] || '';
+        if (key === 'last') return (Number(bv) - Number(av)) * (dir === 'asc' ? -1 : 1);
+        return av.localeCompare(bv) * (dir === 'asc' ? 1 : -1);
+      });
+      rows.forEach(r => tbody.appendChild(r));
+    });
+  });
+})();
+</script>
+</body></html>`;
+
+function renderCard(r) {
+  const last = r.last_at ? new Date(r.last_at).toISOString().slice(0,10) : '—';
+  const days = r.last_at ? Math.floor((Date.now() - Date.parse(r.last_at))/86400000) : 999;
+  const rel = days === 0 ? 'today' : days === 1 ? '1d' : days < 30 ? `${days}d` : days < 365 ? `${Math.floor(days/30)}mo` : `${Math.floor(days/365)}y`;
+  const search = [r.name, r.description, r.category, r.pm2?.name, r.pm2?.status, r.grade?.letter, r.deep_search].filter(Boolean).join(' ').toLowerCase();
+  const u = safeUrl(r.url);
+  const linkAttr = r.analysis_slug ? `href="/wa/${esc(r.analysis_slug)}" target="_blank"` : (u ? `href="${esc(u)}" target="_blank"` : `href="file://${esc(r.path)}"`);
+  // SVG hero referenced by class — declared once in <style>. Deterministic per project.
+  const bgClass = classFor(r.name, r.grade?.vertical || r.category);
+  return `<a class="bcard ${bgClass}" data-card data-slug="${esc(r.analysis_slug || r.slug || '')}" data-search="${esc(search)}" data-name="${esc(r.name.toLowerCase())}" data-cat="${esc(r.category)}" data-grade="${esc(r.grade?.letter || 'Z')}" data-grade-score="${r.grade?.score || 0}" data-audit-score="${r.audit_score == null ? -1 : r.audit_score}" data-last="${r.last_at ? Date.parse(r.last_at) : 0}" data-pm2="${r.pm2?.status === 'online' ? '1' : '0'}" ${linkAttr} rel="noopener"
+    style="display:block;border:4px solid #000;padding:14px;text-decoration:none;color:#000;position:relative;overflow:hidden;aspect-ratio:1;transition:transform .12s">
+    ${r.analysis_slug ? `<button class="peek-btn" data-peek="${esc(r.analysis_slug)}" onclick="event.preventDefault();event.stopPropagation();window.openPeek('${esc(r.analysis_slug)}')" type="button">★ PEEK</button>` : ''}
+    ${r.grade ? `<div style="position:absolute;top:8px;right:8px;background:${r.grade.color};color:#000;border:2px solid #000;font-family:'Archivo Black',sans-serif;font-size:18px;line-height:1;padding:2px 7px">${esc(r.grade.letter)}</div>` : ''}
+    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;line-height:1.05;text-transform:uppercase;letter-spacing:-.01em;margin-bottom:6px;padding-right:32px;word-break:break-word">${esc(r.name)}</div>
+    <div style="font-size:9px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#666;margin-bottom:6px"><span class="cat-tag" style="font-size:8px">${esc(r.category)}</span></div>
+    <div style="font-size:10px;line-height:1.35;color:#333;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;font-weight:500">${esc((r.description || 'no description').slice(0, 200))}</div>
+    <div style="position:absolute;bottom:8px;left:14px;right:14px;display:flex;justify-content:space-between;align-items:center;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;gap:6px;flex-wrap:wrap">
+      <span>${esc(last)} ★ ${esc(rel)}</span>
+      <div style="display:flex;gap:4px;align-items:center">
+        ${r.audit_score != null && r.audit_score < 50 && r.analysis_slug ? `<a href="/wa/${esc(r.analysis_slug)}#audit-report" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="Audit score ${r.audit_score}/100 — jump to quick-wins" style="background:#dc2626;color:#fff;padding:1px 5px;text-decoration:none;white-space:nowrap;font-size:9px"><span class="needs-work-full">★ NEEDS WORK</span><span class="needs-work-short" style="display:none">★</span></a>` : ''}
+        ${r.pm2?.status === 'online' ? '<span style="background:#22c55e;color:#000;padding:1px 5px">★ LIVE</span>' : ''}
+      </div>
+    </div>
+  </a>`;
+}
+
+// Per-card action buttons. Native + web links. Returns inline HTML.
+function actionButtons(r) {
+  const u = safeUrl(r.url);
+  const vscodeHref = `vscode://file${r.path}`;
+  const ghSearch = `https://github.com/search?q=${encodeURIComponent(r.name)}+stevestudio2&type=repositories`;
+  const pm2Name = r.pm2?.name;
+  const out = [];
+  if (u) {
+    const hostLabel = u.replace(/^https?:\/\//, '').replace(/\/$/, '').slice(0, 26);
+    out.push(`<a href="${esc(u)}" target="_blank" rel="noopener" class="bbtn bbtn-primary" title="Open public URL">★ ${esc(hostLabel)} ↗</a>`);
+  }
+  if (r.port) out.push(`<a href="http://127.0.0.1:${r.port}" target="_blank" rel="noopener" class="bbtn bbtn-port" title="Local port :${r.port}">:${r.port}</a>`);
+  if (r.analysis_slug) out.push(`<a href="/wa/${esc(r.analysis_slug)}" target="_blank" rel="noopener" class="bbtn bbtn-mock" title="Open mockup audit">★ Mockups</a>`);
+  out.push(`<a href="/builds/build/${esc(r.slug || r.name)}" class="bbtn bbtn-deep" title="Deep-dive page · git log + deps + README">★ Deep Dive →</a>`);
+  out.push(`<a href="${esc(vscodeHref)}" class="bbtn" title="Open in VS Code / Cursor">VS Code</a>`);
+  out.push(`<button type="button" class="bbtn bbtn-copy" data-copy="${esc(r.path)}" title="Copy path to clipboard">Copy Path</button>`);
+  out.push(`<a href="file://${esc(r.path)}" class="bbtn" title="Open in Finder">Finder</a>`);
+  out.push(`<a href="${esc(ghSearch)}" target="_blank" rel="noopener" class="bbtn" title="GitHub repo search">GitHub</a>`);
+  if (pm2Name) out.push(`<a href="/builds/pm2-logs?app=${encodeURIComponent(pm2Name)}" target="_blank" rel="noopener" class="bbtn" title="Tail pm2 logs">Logs</a>`);
+  out.push(`<button type="button" class="bbtn bbtn-star" data-star="${esc(r.slug || r.name)}" title="Star this build">★ Star</button>`);
+  return out.join(' ');
+}
+
+// Stats banner shown above the rail.
+function statsBanner(rows) {
+  const c = (fn) => rows.filter(fn).length;
+  const cells = [
+    { label: 'Total',       val: rows.length,                                                                  bg: '#000',     fg: '#fff' },
+    { label: 'pm2 Online',  val: c(r => r.pm2?.status === 'online'),                                            bg: '#22c55e',  fg: '#000' },
+    { label: 'pm2 Broken',  val: c(r => r.pm2 && r.pm2.status !== 'online'),                                    bg: '#dc2626',  fg: '#fff' },
+    { label: 'Today',       val: c(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 86_400_000),       bg: '#facc15',  fg: '#000' },
+    { label: 'This Week',   val: c(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 7 * 86_400_000),   bg: '#fef3c7',  fg: '#000' },
+    { label: 'Stale 30d+',  val: c(r => !r.last_at || (Date.now() - Date.parse(r.last_at)) > 30 * 86_400_000), bg: '#f3f4f6',  fg: '#000' },
+    { label: 'Graded',      val: c(r => r.grade),                                                               bg: '#000',     fg: '#facc15' },
+    { label: 'A · top',     val: c(r => r.grade?.letter === 'A'),                                               bg: '#22c55e',  fg: '#000' },
+    { label: 'F · low',     val: c(r => r.grade?.letter === 'F'),                                               bg: '#dc2626',  fg: '#fff' },
+  ];
+  return `<div class="stats-banner">
+    ${cells.map(c => `<div class="stat-cell" style="background:${c.bg};color:${c.fg}"><div class="stat-val">${c.val.toLocaleString()}</div><div class="stat-label">${c.label}</div></div>`).join('')}
+  </div>`;
+}
+
+// Sidebar nav driven by view list + grade jump + top categories.
+function sidebarNav(rows, activeView, activeGrade, activeCat) {
+  const filterFns = {
+    all:      () => true,
+    active:   r => r.pm2?.status === 'online',
+    today:    r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 86_400_000,
+    week:     r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 7 * 86_400_000,
+    stale:    r => !r.last_at || (Date.now() - Date.parse(r.last_at)) > 30 * 86_400_000,
+    graded:   r => r.grade,
+    ungraded: r => !r.grade,
+    broken:   r => r.pm2 && r.pm2.status !== 'online',
+    url:      r => r.url,
+    timeline: () => true,
+  };
+  const views = [
+    ['all',      'All Builds'],
+    ['active',   'Active · pm2'],
+    ['today',    'Today'],
+    ['week',     'This Week'],
+    ['stale',    'Stale 30d+'],
+    ['graded',   'Graded'],
+    ['ungraded', 'Ungraded'],
+    ['broken',   'pm2 Broken'],
+    ['url',      'Has Public URL'],
+    ['timeline', 'Timeline'],
+  ];
+  const items = views.map(([key, label]) => {
+    const n = rows.filter(filterFns[key]).length;
+    const isActive = key === activeView;
+    return `<li><a href="/builds?view=${key}" class="${isActive ? 'is-active' : ''}"><span>${label}</span><span class="sb-count">${n}</span></a></li>`;
+  }).join('');
+  const gradeItems = ['A','B','C','D','F'].map(g => {
+    const n = rows.filter(r => r.grade?.letter === g).length;
+    if (n === 0) return '';
+    const isActive = g === activeGrade;
+    return `<li><a href="/builds?view=graded&grade=${g}" class="grade-${g} ${isActive ? 'is-active' : ''}"><span>Grade ${g}</span><span class="sb-count">${n}</span></a></li>`;
+  }).filter(Boolean).join('');
+  const catMap = new Map();
+  for (const r of rows) catMap.set(r.category, (catMap.get(r.category) || 0) + 1);
+  const topCats = [...catMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
+  const catItems = topCats.map(([cat, n]) => {
+    const isActive = cat === activeCat;
+    return `<li><a href="/builds?cat=${encodeURIComponent(cat)}" class="${isActive ? 'is-active' : ''}"><span>${esc(cat)}</span><span class="sb-count">${n}</span></a></li>`;
+  }).join('');
+  return `<aside class="sidebar">
+    <div class="sb-section"><div class="sb-h">★ Views</div><ul>${items}</ul></div>
+    <div class="sb-section"><div class="sb-h">★ Grade</div><ul>${gradeItems || '<li class="sb-empty">no grades</li>'}</ul></div>
+    <div class="sb-section"><div class="sb-h">★ Categories</div><ul>${catItems}</ul></div>
+    <div class="sb-section"><div class="sb-h">★ Starred · Local</div><ul id="starred-list"><li class="sb-empty">no starred yet</li></ul></div>
+    <div class="sb-section"><div class="sb-h">★ Quick Open</div><ul>
+      <li><a href="/" target="_blank">/  Salon Directory</a></li>
+      <li><a href="/admin/enrichment" target="_blank">/admin/enrichment</a></li>
+      <li><a href="/near" target="_blank">/near drive map</a></li>
+      <li><a href="/press" target="_blank">/press feed</a></li>
+      <li><a href="/healthz" target="_blank">/healthz JSON</a></li>
+    </ul></div>
+  </aside>`;
+}
+
+export function renderBuildsIndex({ builds, allBuilds = null, activeView = 'all', activeGrade = '', activeCat = '' } = {}) {
+  // allBuilds is the FULL list (used for sidebar + stats). builds is the
+  // filtered slice for the current view. Older callers pass only `builds`.
+  const fullList = allBuilds || builds;
+  const groups = new Map();
+  for (const b of builds) {
+    if (!groups.has(b.category)) groups.set(b.category, []);
+    groups.get(b.category).push(b);
+  }
+  // Top rail: highest-scoring 12 projects regardless of letter — gives a stable
+  // "what's worth attention" surface even when the A/B band is sparse. Tiebreak
+  // by recency so an A from 2024 doesn't outrank a B from yesterday.
+  const topRail = [...builds]
+    .filter(b => b.grade)
+    .sort((a, b) => (b.grade.score - a.grade.score) || ((b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0)))
+    .slice(0, 12);
+  // Full grid: every build, sorted by grade score desc → last touched.
+  const fullGrid = [...builds].sort((a, b) => {
+    const ag = a.grade?.score || -1; const bg = b.grade?.score || -1;
+    if (bg !== ag) return bg - ag;
+    return (b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0);
+  });
+  // Render newest categories first (by max last_at among children).
+  const ordered = [...groups.entries()].sort((a, b) => {
+    const am = Math.max(...a[1].map(r => r.last_at ? Date.parse(r.last_at) : 0));
+    const bm = Math.max(...b[1].map(r => r.last_at ? Date.parse(r.last_at) : 0));
+    return bm - am;
+  });
+
+  const sections = ordered.map(([cat, rows]) => {
+    const tbody = rows.map(r => {
+      const search = [r.name, r.description, r.category].join(' ').toLowerCase();
+      const lastTs = r.last_at ? Date.parse(r.last_at) : 0;
+      const pm2Status = r.pm2?.status === 'online' ? '1' : (r.pm2 ? '0' : '');
+      const descTrunc = esc(r.description || '').slice(0, 240) + ((r.description||'').length > 240 ? '…' : '');
+      const descBlock = r.description
+        ? `<div style="line-height:1.45">${descTrunc}</div>${r.description_source === 'package' ? '<div style="font-size:10px;color:#999;margin-top:4px">★ from package.json</div>' : ''}`
+        : `<div style="color:#aaa;font-style:italic">no description</div>`;
+      return `<tr data-row data-search="${esc(search)} ${esc(r.pm2?.name||'')} ${esc(r.pm2?.status||'')}" data-name="${esc(r.name.toLowerCase())}" data-cat="${esc(r.category)}" data-last="${lastTs}" data-desc="${esc((r.description||'').toLowerCase())}" data-pm2="${pm2Status}">
+        <td class="col-name">
+          <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:-.01em">${esc(r.name)}</div>
+          <div style="font-size:10px;color:#888;font-family:'Space Mono',monospace;margin-top:2px">~/Projects/${esc(r.name)}</div>
+          <div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">
+            ${r.has_git ? '<div style="display:inline-block;background:#000;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;letter-spacing:.04em;text-transform:uppercase">★ git</div>' : ''}
+            ${pm2Badge(r.pm2)}
+          </div>
+        </td>
+        <td class="col-cat">
+          <div style="display:flex;flex-direction:column;gap:4px;align-items:flex-start">
+            <span class="cat-tag">${esc(r.category)}</span>
+            ${r.grade ? `<a href="/wa/${esc(r.analysis_slug || r.slug)}" target="_blank" rel="noopener" title="Audit grade ${r.grade.score}/100" style="display:inline-block;background:${r.grade.color};color:#000;padding:1px 7px;font-family:'Archivo Black',sans-serif;font-size:14px;line-height:1;letter-spacing:.04em;text-decoration:none;border:2px solid #000">${esc(r.grade.letter)}</a>` : ''}
+          </div>
+        </td>
+        <td class="col-desc">${descBlock}</td>
+        <td class="col-last">${fmtDate(r.last_at)}</td>
+        <td class="col-open" style="min-width:240px;line-height:1.9">${actionButtons(r)}</td>
+        <td class="col-analyze">${analyzeBtn(r)}</td>
+      </tr>`;
+    }).join('');
+    // Open by default only the highest-signal categories (≤8 rows AND has at
+    // least one row with a real URL or pm2 process) — keeps initial paint compact.
+    const hasSignal = rows.some(r => r.url || r.pm2 || r.has_git);
+    const autoOpen = rows.length <= 8 && hasSignal;
+    return `<details ${autoOpen ? 'open' : ''}>
+      <summary><span>★ ${esc(cat.toUpperCase())}</span><span class="count">${rows.length}</span></summary>
+      <table>
+        <thead><tr>
+          <th class="sortable col-name" data-key="name">Name</th>
+          <th class="sortable col-cat" data-key="cat">Cat</th>
+          <th class="col-desc">Description</th>
+          <th class="sortable col-last" data-key="last" data-dir="desc">Last Worked</th>
+          <th class="col-open">Open</th>
+          <th class="col-analyze">Analyze</th>
+        </tr></thead>
+        <tbody>${tbody}</tbody>
+      </table>
+    </details>`;
+  }).join('');
+
+  // Build the active-view banner (e.g. "Showing: Active · pm2 online · 12 of 142")
+  const viewLabels = { all:'All Builds', active:'Active · pm2 online', today:'Touched today', week:'Touched this week', stale:'Stale 30d+', graded:'Graded only', ungraded:'Ungraded', broken:'pm2 Broken', url:'Has Public URL', timeline:'Timeline · newest first' };
+  const viewLabel = viewLabels[activeView] || 'All Builds';
+  const isFiltered = activeView !== 'all' || activeGrade || activeCat;
+  const viewBanner = isFiltered
+    ? `<div class="view-banner">
+        <div>
+          <div class="vb-title">★ ${esc(viewLabel)}${activeGrade ? ` · Grade ${esc(activeGrade)}` : ''}${activeCat ? ` · Category ${esc(activeCat)}` : ''}</div>
+          <div class="vb-meta">${builds.length} of ${fullList.length} builds</div>
+        </div>
+        <a href="/builds">★ Clear filter →</a>
+      </div>`
+    : '';
+
+  const html = HEAD + `
+<h1>★ ${builds.length} <mark>BUILDS</mark>.</h1>
+<div class="lead">★ EVERY DIRECTORY UNDER ~/PROJECTS — DESCRIPTIONS FROM MEMORY.md — DATES FROM GIT LOG ★</div>
+${statsBanner(fullList)}
+${viewBanner}
+<div class="controls">
+  <input id="filter" type="search" placeholder="★ FILTER BY NAME, DESCRIPTION, CATEGORY, PM2, GRADE..." autocomplete="off">
+  <select id="sort-by" style="padding:14px 18px;border:4px solid #000;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;background:#fff;cursor:pointer">
+    <option value="grade-score-desc">★ SORT: GRADE</option>
+    <option value="audit-score-desc">★ AUDIT SCORE</option>
+    <option value="last-desc">★ MOST RECENT</option>
+    <option value="last-asc">★ STALEST</option>
+    <option value="name-asc">★ A — Z</option>
+  </select>
+  <button id="filter-online" type="button">★ PM2 ONLINE</button>
+  <button id="filter-git" type="button">★ HAS GIT</button>
+  <button id="filter-stale" type="button">★ STALE (D/F)</button>
+  <button id="filter-top" type="button">★ TOP (A/B)</button>
+  <button id="filter-reset" type="button" title="Clear all filters + sort + chips" style="background:#dc2626;color:#fff;border:4px solid #000">✕ RESET</button>
+  <form method="post" action="/api/builds/run-all-deep-dives" style="display:inline;margin:0">
+    <button type="submit" title="Run deep-dive synthesis on every analysis missing a recent report">★ RUN ALL DEEP-DIVES</button>
+  </form>
+  <form id="fix-visible-form" method="post" action="/api/builds/fix-visible" style="display:inline;margin:0">
+    <input type="hidden" name="slugs" id="fix-visible-slugs" value="">
+    <button type="submit" title="Force-rerun deep-dive on currently-shown cards only">★ FIX VISIBLE</button>
+  </form>
+  <a href="/api/builds/export.csv" style="display:inline-block;background:#000;color:#facc15;border:4px solid #000;padding:14px 18px;font-family:'Archivo Black',sans-serif;font-size:13px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;text-decoration:none">★ CSV</a>
+  <button id="star-all-visible" type="button" title="Star every currently-visible card">★ STAR ALL VISIBLE</button>
+  <form method="post" action="/api/builds/rebuild-mockups" style="display:inline;margin:0" onsubmit="return confirm('Regenerate mockups for all 117+ analyses? Takes ~30s.')">
+    <button type="submit" title="Re-run build-all-analyses --rebuild — picks up any theme code changes">★ REBUILD MOCKUPS</button>
+  </form>
+  <button id="view-grid" type="button" style="background:#facc15;color:#000">★ GRID</button>
+  <button id="view-table" type="button">★ TABLE</button>
+</div>
+
+<!-- ★ TOP RAIL — A & B grade projects, horizontal scroll -->
+<div id="rail-section" style="margin-bottom:24px">
+  <div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:-.01em;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between">
+    <span>★ TOP RAIL <span style="font-size:11px;font-family:'Space Mono',monospace;color:#666;font-weight:700">★ TOP ${topRail.length} BY SCORE</span></span>
+    <div style="display:flex;gap:6px">
+      <button class="rail-nav" data-rail="rail" data-dir="-1" type="button" style="background:#000;color:#facc15;border:3px solid #000;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">←</button>
+      <button class="rail-nav" data-rail="rail" data-dir="1" type="button" style="background:#000;color:#facc15;border:3px solid #000;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">→</button>
+    </div>
+  </div>
+  <div id="rail" style="display:grid;grid-auto-flow:column;grid-auto-columns:240px;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;padding-bottom:8px">
+    ${topRail.length ? topRail.map(renderCard).join('') : '<div style="padding:32px;color:#aaa">★ NO A/B GRADES YET</div>'}
+  </div>
+</div>
+
+<!-- ★ GRADE RAILS — one per letter A/B/C/D/F, collapsible -->
+${(() => {
+  const order = ['A','B','C','D','F'];
+  const colors = { A:'#22c55e', B:'#84cc16', C:'#facc15', D:'#f97316', F:'#dc2626' };
+  const byGrade = new Map(order.map(g => [g, []]));
+  builds.filter(b => b.grade).forEach(b => byGrade.get(b.grade.letter)?.push(b));
+  byGrade.forEach((arr) => arr.sort((a, b) => (b.grade.score - a.grade.score) || ((b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0))));
+  return order.map(g => {
+    const arr = byGrade.get(g) || [];
+    if (arr.length === 0) return '';
+    const railId = 'rail-grade-' + g;
+    return `<details class="grade-rail" ${g === 'A' || g === 'B' ? 'open' : ''}>
+      <summary style="background:${colors[g]}"><span>★ GRADE ${g} <span style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700;margin-left:8px">★ ${arr.length} BUILDS</span></span><span><button class="rail-nav" data-rail="${railId}" data-dir="-1" type="button" style="background:#000;color:#facc15;border:0;padding:3px 9px;font-family:'Archivo Black',sans-serif;font-size:11px;cursor:pointer;margin-right:4px">←</button><button class="rail-nav" data-rail="${railId}" data-dir="1" type="button" style="background:#000;color:#facc15;border:0;padding:3px 9px;font-family:'Archivo Black',sans-serif;font-size:11px;cursor:pointer">→</button></span></summary>
+      <div id="${railId}" class="grade-rail-body">
+        ${arr.map(renderCard).join('')}
+      </div>
+    </details>`;
+  }).join('');
+})()}
+
+<!-- ★ DENSITY SLIDER -->
+<div id="grid-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:14px;flex-wrap:wrap">
+  <span style="font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ COLUMNS</span>
+  <input id="cols" type="range" min="4" max="12" step="1" value="6" style="flex:1;min-width:200px;max-width:400px;accent-color:#000">
+  <span id="cols-label" style="font-family:'Archivo Black',sans-serif;font-size:24px;background:#facc15;color:#000;padding:2px 12px;border:3px solid #000;min-width:48px;text-align:center">6</span>
+  <span id="grid-count" style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666">${fullGrid.length} CARDS</span>
+</div>
+
+<!-- ★ FULL GRID — every project, scaled by slider -->
+<div id="grid" style="display:grid;grid-template-columns:repeat(6, 1fr);gap:8px;margin-bottom:32px">
+  ${fullGrid.map(renderCard).join('')}
+</div>
+
+<!-- ★ LEGACY TABLE VIEW (toggle) -->
+<div id="table-view" style="display:none">
+  ${sections || '<div class="empty">★ NO BUILDS FOUND</div>'}
+</div>
+
+<!-- ★ DETAIL DRAWER (PEEK button per card opens this) -->
+<div id="drawer-bd" class="drawer-backdrop" onclick="window.closePeek()"></div>
+<aside id="drawer" class="drawer" aria-hidden="true">
+  <div style="position:absolute;top:14px;left:14px;display:flex;gap:6px">
+    <button class="drawer-nav" id="drawer-prev" onclick="window.peekStep(-1)" title="← previous visible card" style="background:#000;color:#facc15;border:0;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">←</button>
+    <button class="drawer-nav" id="drawer-next" onclick="window.peekStep(1)" title="next visible card →" style="background:#000;color:#facc15;border:0;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:14px;cursor:pointer">→</button>
+  </div>
+  <button class="drawer-close" onclick="window.closePeek()">CLOSE ✕</button>
+  <div id="drawer-body" style="padding-top:48px;font-family:'Space Mono',monospace">★ Loading…</div>
+</aside>
+
+<!-- ★ Per-card action bar (only on the focused selected card via JS) -->
+<div id="card-actions-pop" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#000;color:#facc15;border-top:4px solid #facc15;padding:14px 20px;z-index:99;font-family:'Space Mono',monospace;font-weight:700;font-size:12px;flex-wrap:wrap;gap:8px;align-items:center">
+  <span id="cap-name" style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-right:auto">—</span>
+  <span id="cap-actions"></span>
+  <button type="button" id="cap-close" style="background:#dc2626;color:#fff;border:2px solid #fff;padding:5px 12px;font-family:'Archivo Black',sans-serif;font-size:11px;cursor:pointer">CLOSE</button>
+</div>
+
+<script>
+// ===== Click-to-pop action bar with all per-card buttons =====
+// Cards remain quick-clickable (default action), but a long-press / right-click
+// or a "more" affordance opens the bottom action bar with VS Code, Copy Path,
+// GitHub, Logs, Star, etc. We pre-attach the buttons inline at server render so
+// they don't need a round-trip.
+(() => {
+  const cap = document.getElementById('card-actions-pop');
+  const capName = document.getElementById('cap-name');
+  const capActions = document.getElementById('cap-actions');
+  document.getElementById('cap-close')?.addEventListener('click', () => { cap.style.display = 'none'; });
+
+  // Star state in localStorage. Updates the sidebar list + button class.
+  const STAR_KEY = 'agentabrams.builds.starred';
+  function loadStars() { try { return new Set(JSON.parse(localStorage.getItem(STAR_KEY) || '[]')); } catch { return new Set(); } }
+  function saveStars(s) { localStorage.setItem(STAR_KEY, JSON.stringify([...s])); }
+  function refreshStarredSidebar() {
+    const s = loadStars();
+    const ul = document.getElementById('starred-list');
+    if (!ul) return;
+    if (s.size === 0) { ul.innerHTML = '<li class="sb-empty">no starred yet</li>'; return; }
+    ul.innerHTML = [...s].slice(0, 20).map(slug =>
+      '<li><a href="#" data-jump-to="' + slug + '"><span>' + slug + '</span><span class="sb-count">★</span></a></li>'
+    ).join('');
+    ul.querySelectorAll('a[data-jump-to]').forEach(a => a.addEventListener('click', e => {
+      e.preventDefault();
+      const slug = a.dataset.jumpTo;
+      const card = document.querySelector('a.bcard[data-name="' + slug.toLowerCase() + '"]');
+      if (card) card.scrollIntoView({ behavior:'smooth', block:'center' });
+    }));
+  }
+  refreshStarredSidebar();
+
+  document.body.addEventListener('click', (e) => {
+    const star = e.target.closest('button[data-star]');
+    if (star) {
+      e.preventDefault(); e.stopPropagation();
+      const id = star.dataset.star;
+      const stars = loadStars();
+      if (stars.has(id)) { stars.delete(id); star.classList.remove('is-starred'); }
+      else                { stars.add(id);    star.classList.add('is-starred'); }
+      saveStars(stars);
+      refreshStarredSidebar();
+      return;
+    }
+    const copy = e.target.closest('button[data-copy]');
+    if (copy) {
+      e.preventDefault(); e.stopPropagation();
+      const v = copy.dataset.copy;
+      navigator.clipboard?.writeText(v).then(() => {
+        copy.classList.add('is-copied');
+        const old = copy.textContent;
+        copy.textContent = '✓ Copied';
+        setTimeout(() => { copy.textContent = old; copy.classList.remove('is-copied'); }, 1400);
+      });
+      return;
+    }
+  });
+
+  // Mark already-starred buttons on initial paint.
+  const stars = loadStars();
+  document.querySelectorAll('button[data-star]').forEach(btn => {
+    if (stars.has(btn.dataset.star)) btn.classList.add('is-starred');
+  });
+
+  // Keyboard shortcuts
+  // /  → focus search
+  // g a → /builds?view=active
+  // g t → /builds?view=today
+  // g g → /builds?view=graded
+  // g s → /builds?view=stale
+  let prefix = null;
+  let prefixTimer = null;
+  document.addEventListener('keydown', (e) => {
+    const tag = e.target?.tagName?.toLowerCase();
+    if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    if (e.key === '/') {
+      e.preventDefault();
+      document.getElementById('filter')?.focus();
+      return;
+    }
+    if (e.key === 'g' && !prefix) {
+      prefix = 'g';
+      clearTimeout(prefixTimer);
+      prefixTimer = setTimeout(() => { prefix = null; }, 1000);
+      return;
+    }
+    if (prefix === 'g') {
+      const targets = { a:'active', t:'today', w:'week', g:'graded', s:'stale', b:'broken', u:'url', l:'timeline' };
+      if (targets[e.key]) {
+        location.href = '/builds?view=' + targets[e.key];
+        return;
+      }
+      prefix = null;
+    }
+  });
+})();
+</script>
+
+` + FOOT;
+  // Substitute the sidebar placeholder with the rendered nav.
+  return html.replace('__SIDEBAR_PLACEHOLDER__', sidebarNav(fullList, activeView, activeGrade, activeCat));
+}
diff --git a/src/render/category.js b/src/render/category.js
new file mode 100644
index 0000000..197db58
--- /dev/null
+++ b/src/render/category.js
@@ -0,0 +1,285 @@
+// /c/:category — category landing page. Editorial layout: hero with one-line
+// pitch ("Every barbershop in LA County, sorted by neighborhood"), then the
+// neighborhood breakdown ("Top neighborhoods for {category}"), then a grid of
+// top shops, then a long alphabetical roll. Wired with ItemList JSON-LD so
+// "best barbers in LA" / "nail salons LA" queries surface this page.
+
+import { esc, escJsonLd } from '../lib/escape.js';
+
+function slugify(s) {
+  return String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+}
+
+const CAT_META = {
+  barbershop: {
+    title: 'Barbershops',
+    pitch: 'Every state-licensed barbershop in LA + Orange + Riverside + San Bernardino + Ventura counties.',
+    eyebrow: 'Barber · Beard · Fade · Cuts',
+    accent: '#1a1a1a',
+  },
+  salon: {
+    title: 'Salons',
+    pitch: 'Hair, nails, lash, brow, color, blowout — every state-licensed salon across the five-county LA region.',
+    eyebrow: 'Hair · Nails · Color · Blowout',
+    accent: '#5a6e3a',
+  },
+  spa: {
+    title: 'Spas & Massage',
+    pitch: 'Day spas, massage parlors, and treatment rooms across Los Angeles, Orange County, and the Inland Empire.',
+    eyebrow: 'Massage · Facials · Treatments',
+    accent: '#6b3a8e',
+  },
+  beard: {
+    title: 'Beard & Grooming',
+    pitch: 'Specialty beard shops and grooming-only barbers across the LA region.',
+    eyebrow: 'Beard · Grooming',
+    accent: '#1a1a1a',
+  },
+  nail: {
+    title: 'Nail Salons',
+    pitch: 'Manicure, pedicure, and nail art studios across the five-county LA region.',
+    eyebrow: 'Manicure · Pedicure · Nail Art',
+    accent: '#c2410c',
+  },
+  lash: {
+    title: 'Lash Studios',
+    pitch: 'Eyelash extensions and lash treatments across Los Angeles.',
+    eyebrow: 'Lashes · Extensions · Lift',
+    accent: '#c2410c',
+  },
+  brow: {
+    title: 'Brow Studios',
+    pitch: 'Eyebrow shaping, threading, and microblading across LA.',
+    eyebrow: 'Brows · Threading · Tinting',
+    accent: '#c2410c',
+  },
+  restaurant: {
+    title: 'Restaurants',
+    pitch: 'Restaurants on the corridors and through the LA neighborhoods we cover.',
+    eyebrow: 'Dining · Eat · Sit',
+    accent: '#a16207',
+  },
+  cafe: {
+    title: 'Cafés & Coffee',
+    pitch: 'Coffee shops, cafés, and coffee bars across Los Angeles.',
+    eyebrow: 'Coffee · Café · Espresso',
+    accent: '#a16207',
+  },
+  bar: {
+    title: 'Bars',
+    pitch: 'Bars, lounges, and cocktail rooms across LA.',
+    eyebrow: 'Bars · Cocktails · Lounges',
+    accent: '#7c2d12',
+  },
+  bakery: {
+    title: 'Bakeries',
+    pitch: 'Bakeries, patisseries, and pastry shops across LA.',
+    eyebrow: 'Bread · Pastries · Cakes',
+    accent: '#a16207',
+  },
+  retail: {
+    title: 'Retail',
+    pitch: 'Boutiques, specialty retailers, and storefronts across the LA corridors.',
+    eyebrow: 'Shops · Boutiques · Storefronts',
+    accent: '#5a6e3a',
+  },
+  health: {
+    title: 'Health & Wellness',
+    pitch: 'Health clinics, wellness studios, and medical offices across LA.',
+    eyebrow: 'Clinics · Wellness · Medical',
+    accent: '#5a6e3a',
+  },
+};
+
+function metaFor(cat) {
+  return CAT_META[cat] || {
+    title: (cat || 'Business')[0].toUpperCase() + (cat || 'business').slice(1),
+    pitch: `Every ${cat || 'business'} we've catalogued across the five-county LA region.`,
+    eyebrow: cat || 'Business',
+    accent: '#5a6e3a',
+  };
+}
+
+export function renderCategory({ category, businesses = [], hoodCounts = [], totalLicensed = 0 } = {}) {
+  const meta = metaFor(category);
+  const featured = businesses.slice(0, 24);
+  const baseUrl = process.env.SITE_URL || 'https://localhost';
+  const itemList = {
+    '@context': 'https://schema.org',
+    '@type': 'ItemList',
+    name: `${meta.title} · ${businesses.length}`,
+    description: meta.pitch,
+    url: `${baseUrl}/c/${esc(category)}`,
+    numberOfItems: businesses.length,
+    itemListElement: businesses.slice(0, 100).map((b, i) => ({
+      '@type': 'ListItem',
+      position: i + 1,
+      item: {
+        '@type': b.source_data_json?.source === 'dca_bbcb' ? 'HairSalon' : 'LocalBusiness',
+        name: b.name,
+        url: `${baseUrl}/biz/${b.slug}`,
+        ...(b.address ? { address: { '@type': 'PostalAddress', streetAddress: b.address, addressLocality: b.city || undefined, addressRegion: 'CA', postalCode: b.zip || undefined } } : {}),
+      },
+    })),
+  };
+  const breadcrumbs = {
+    '@context': 'https://schema.org',
+    '@type': 'BreadcrumbList',
+    itemListElement: [
+      { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl + '/' },
+      { '@type': 'ListItem', position: 2, name: meta.title, item: `${baseUrl}/c/${esc(category)}` },
+    ],
+  };
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(meta.title)} · LA Salon Directory · ${businesses.length} listed</title>
+<meta name="description" content="${esc(meta.pitch).slice(0, 160)}">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<script type="application/ld+json">${escJsonLd(JSON.stringify(breadcrumbs))}</script>
+<script type="application/ld+json">${escJsonLd(JSON.stringify(itemList))}</script>
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:${meta.accent};--gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+img{display:block;max-width:100%;height:auto}
+
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
+.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.topnav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav a:hover{color:var(--ink)}
+
+.hero{padding:80px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+.hero-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}
+.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,7vw,96px);line-height:.96;letter-spacing:-.02em;margin-bottom:20px;max-width:14ch}
+.hero h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:62ch;margin-bottom:32px}
+.hero-stats{display:flex;gap:48px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
+.hero-stats b{display:block;font-family:var(--serif);font-style:italic;font-size:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}
+
+.section{padding:64px 32px;max-width:1240px;margin:0 auto}
+.section + .section{border-top:1px solid var(--rule)}
+.section-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:24px;padding-bottom:6px;border-bottom:1px solid var(--rule)}
+
+/* Hood breakdown grid */
+.hood-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
+.hood-cell{background:var(--paper);padding:22px 20px;display:flex;flex-direction:column;gap:2px;transition:background 200ms ease}
+.hood-cell:hover{background:#fff}
+.hood-cell-name{font-family:var(--serif);font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.1}
+.hood-cell-count{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--accent)}
+
+/* Featured shop grid */
+.shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:24px}
+.shop-card{display:block;color:var(--ink);background:#fff;padding:18px 20px;border:1px solid var(--rule);transition:border-color 180ms ease,transform 180ms ease}
+.shop-card:hover{border-color:var(--accent);transform:translateY(-2px)}
+.sc-cat{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;display:flex;justify-content:space-between;align-items:center}
+.sc-cat .lic{background:var(--gold);color:var(--ink);padding:2px 6px;font-weight:700;letter-spacing:.18em}
+.sc-name{font-family:var(--serif);font-size:21px;font-weight:500;letter-spacing:-.005em;line-height:1.15;margin-bottom:4px}
+.sc-where{font-family:var(--mono);font-size:10px;letter-spacing:.04em;color:var(--mute);text-transform:uppercase;line-height:1.5}
+
+/* Long alphabetical roll */
+.roll-list{column-count:3;column-gap:48px;column-rule:1px solid var(--rule)}
+@media (max-width:880px){.roll-list{column-count:1}}
+.roll-row{break-inside:avoid;display:flex;justify-content:space-between;align-items:baseline;padding:8px 0;border-bottom:1px dotted var(--rule);transition:padding 150ms ease}
+.roll-row:hover{padding-left:6px}
+.roll-name{font-family:var(--serif);font-size:17px;font-weight:500;letter-spacing:-.005em}
+.roll-where{font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase}
+
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
+@media (max-width:880px){
+  .topbar{padding:14px 20px}
+  .hero{padding:48px 20px 32px}
+  .section{padding:48px 20px}
+}
+</style>
+</head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ ${esc(meta.title.toLowerCase())}</span></div>
+  <nav class="topnav">
+    <a href="/neighborhoods">Neighborhoods</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/press">Press</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/map">Map</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-eyebrow">${esc(meta.eyebrow)}</div>
+  <h1>${esc(meta.title.split(' ')[0])} <em>${esc(meta.title.split(' ').slice(1).join(' ') || 'in LA')}.</em></h1>
+  <p class="hero-lede">${esc(meta.pitch)}</p>
+  <div class="hero-stats">
+    <div>Listings<b>${businesses.length.toLocaleString()}</b></div>
+    <div>State-licensed<b>${totalLicensed.toLocaleString()}</b></div>
+    <div>Neighborhoods<b>${hoodCounts.length}</b></div>
+  </div>
+</section>
+
+${hoodCounts.length ? `
+<section class="section">
+  <div class="section-label">Top Neighborhoods for ${esc(meta.title)}</div>
+  <div class="hood-grid">
+    ${hoodCounts.slice(0, 24).map(h => `
+      <a class="hood-cell" href="/n/${esc(slugify(h.neighborhood))}">
+        <div class="hood-cell-name">${esc(h.neighborhood)}</div>
+        <div class="hood-cell-count">${(h.count || 0).toLocaleString()} ${esc(meta.title.toLowerCase())}</div>
+      </a>
+    `).join('')}
+  </div>
+</section>
+` : ''}
+
+${featured.length ? `
+<section class="section">
+  <div class="section-label">Featured · State-Licensed First · By Neighborhood</div>
+  <div class="shop-grid">
+    ${featured.map(b => {
+      const lic = b.source_data_json?.source === 'dca_bbcb';
+      return `
+      <a class="shop-card" href="/biz/${esc(b.slug)}">
+        <div class="sc-cat"><span>${esc(b.neighborhood || b.city || '')}</span>${lic ? '<span class="lic">◆ Licensed</span>' : ''}</div>
+        <div class="sc-name">${esc(b.name)}</div>
+        ${b.address ? `<div class="sc-where">${esc(b.address)}</div>` : ''}
+      </a>`;
+    }).join('')}
+  </div>
+</section>
+` : ''}
+
+${businesses.length > 24 ? `
+<section class="section">
+  <div class="section-label">All ${businesses.length.toLocaleString()} ${esc(meta.title)} · A–Z</div>
+  <div class="roll-list">
+    ${[...businesses].sort((a,b) => (a.name||'').localeCompare(b.name||'')).slice(0, 600).map(b => `
+      <a class="roll-row" href="/biz/${esc(b.slug)}">
+        <span class="roll-name">${esc(b.name)}</span>
+        <span class="roll-where">${esc(b.neighborhood || b.city || '')}</span>
+      </a>
+    `).join('')}
+  </div>
+</section>
+` : ''}
+
+<footer class="footer">
+  <span>${esc(meta.title)} · LA Salon Directory · ${new Date().getFullYear()}</span>
+  <span>${businesses.length.toLocaleString()} listings · ${totalLicensed.toLocaleString()} state-licensed</span>
+  <span><a href="/">← Home</a></span>
+</footer>
+</body>
+</html>`;
+}
diff --git a/src/render/corridor-map.js b/src/render/corridor-map.js
new file mode 100644
index 0000000..3c8576b
--- /dev/null
+++ b/src/render/corridor-map.js
@@ -0,0 +1,416 @@
+// /corridor/:slug/map — full-screen Leaflet map of one corridor with live
+// geolocation. As the user drives the strip, the blue dot updates, the
+// nearest-businesses panel re-ranks by haversine distance, and the next-up-
+// ahead callouts swap in. Pins are color-coded:
+//   gold dot   = state-licensed salon (BBCB)
+//   olive dot  = unlicensed salon-category
+//   charcoal   = non-salon business
+// Brand-driven overlays (logo + name + address + neighborhood + license link)
+// open in a side-panel on click. Reuses the same data-fetch the editorial
+// /corridor/:slug page uses, so shipping a new corridor automatically gets
+// a /corridor/:slug/map for free.
+
+import { esc, escJsonLd } from '../lib/escape.js';
+
+export function renderCorridorMap({ corridorTag = 'ventura-blvd-20mi', meta, businesses = [] } = {}) {
+  // Build a slim, JSON-safe payload for the client. We only ship what the
+  // map needs to render + the side-panel; full profiles live at /biz/:slug.
+  const payload = businesses
+    .filter(b => b.latitude != null && b.longitude != null)
+    .map(b => ({
+      slug: b.slug,
+      name: b.name,
+      cat:  b.category || '',
+      addr: b.address || '',
+      hood: b.neighborhood || '',
+      city: b.city || '',
+      lat:  Number(b.latitude),
+      lng:  Number(b.longitude),
+      lic:  b.source_data_json?.source === 'dca_bbcb' ? (b.source_data_json?.license_number || '') : '',
+      sole: b.source_data_json?.shop_personality === 'sole_prop' ? 1 : 0,
+    }));
+
+  // Editorial naming + accent. Falls back if meta missing.
+  const title = meta?.title || 'Corridor';
+  const accent = meta?.accent || '#5a6e3a';
+  const corridorPath = `/corridor/${corridorTag.replace(/-\d+mi$/, '')}`;
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
+<title>${esc(title)} · Drive Map · LA Salon Directory</title>
+<meta name="description" content="Live map of ${esc(title)} — every state-licensed salon, restaurant, and storefront on the strip. Tracks your location as you drive.">
+<link rel="preconnect" href="https://unpkg.com" crossorigin>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="">
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="">
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:${accent};--gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;background:var(--ink);color:var(--ink);font-family:var(--sans);overflow:hidden}
+a{color:inherit;text-decoration:none}
+
+/* TOP BAR ============================================================ */
+.topbar{position:absolute;top:0;left:0;right:0;z-index:1000;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--rule)}
+.brand{font-family:var(--serif);font-size:16px;font-weight:500;letter-spacing:-.005em}
+.brand em{color:var(--accent);font-style:italic}
+.brand a{color:var(--ink)}
+.topbar .title{font-family:var(--serif);font-size:16px;color:var(--ink);font-style:italic;text-align:center;flex:1}
+.topbar .title em{color:var(--accent);font-style:italic}
+.topbar .actions{display:flex;gap:8px;align-items:center}
+.btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);cursor:pointer;transition:all 150ms ease;border-radius:0}
+.btn.is-on{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.btn:hover{border-color:var(--accent);color:var(--accent)}
+.btn.is-on:hover{color:var(--paper)}
+
+/* MAP =============================================================== */
+#map{position:absolute;top:50px;left:0;right:0;bottom:0;background:#222}
+
+/* USER-LOCATION DOT (custom, pulses) ================================ */
+.me-dot{width:18px;height:18px;border-radius:50%;background:#1a73e8;border:3px solid #fff;box-shadow:0 0 0 4px rgba(26,115,232,.25);animation:mePulse 2.4s ease-out infinite}
+@keyframes mePulse{0%,100%{box-shadow:0 0 0 4px rgba(26,115,232,.18)}50%{box-shadow:0 0 0 14px rgba(26,115,232,.04)}}
+
+/* PIN STYLES ======================================================== */
+.pin{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35)}
+.pin-licensed{background:var(--gold)}
+.pin-salon{background:var(--accent)}
+.pin-other{background:#3a3a3a}
+
+/* NEAREST-LIST PANEL ================================================ */
+.nearby{position:absolute;left:14px;bottom:14px;z-index:1000;width:min(360px,calc(100vw - 28px));max-height:42vh;overflow-y:auto;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border:1px solid var(--rule);box-shadow:0 12px 32px rgba(0,0,0,.18)}
+.nearby-head{padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:baseline;background:rgba(0,0,0,.02)}
+.nearby-head h2{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em}
+.nearby-head .meta{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
+.nearby-row{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px dotted var(--rule);transition:background 150ms ease;cursor:pointer}
+.nearby-row:hover{background:#fff}
+.nearby-row:last-child{border-bottom:none}
+.n-info{flex:1;min-width:0}
+.n-name{font-family:var(--serif);font-size:15px;font-weight:500;letter-spacing:-.005em;line-height:1.2;display:flex;align-items:center;gap:6px}
+.n-name .lic-mark{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--gold)}
+.n-where{font-family:var(--mono);font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.04em;line-height:1.4;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.n-dist{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent);white-space:nowrap}
+
+/* SIDE PANEL ON PIN-CLICK =========================================== */
+.detail{position:absolute;top:50px;right:0;bottom:0;z-index:1000;width:min(380px,90vw);background:var(--paper);border-left:1px solid var(--rule);box-shadow:-8px 0 24px rgba(0,0,0,.18);overflow-y:auto;transform:translateX(100%);transition:transform 280ms cubic-bezier(.2,.7,.3,1)}
+.detail.open{transform:translateX(0)}
+.d-close{position:absolute;top:14px;right:14px;width:32px;height:32px;border:1px solid var(--rule);background:#fff;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;font-family:var(--mono)}
+.d-close:hover{border-color:var(--accent);color:var(--accent)}
+.d-body{padding:32px 24px}
+.d-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
+.d-title{font-family:var(--serif);font-size:32px;font-weight:500;line-height:1.05;letter-spacing:-.01em;margin-bottom:16px}
+.d-meta{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.7;margin-bottom:24px}
+.d-meta .row{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px dotted var(--rule)}
+.d-meta .row strong{color:var(--ink);font-weight:500}
+.d-actions{display:flex;flex-direction:column;gap:8px;margin-top:18px}
+.d-btn{display:block;padding:12px 16px;text-align:center;border:1px solid var(--rule);background:#fff;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease}
+.d-btn.primary{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.d-btn:hover{border-color:var(--accent);color:var(--accent)}
+.d-btn.primary:hover{background:var(--ink);border-color:var(--ink);color:var(--paper)}
+
+@media (max-width:640px){
+  .topbar .title{display:none}
+  .nearby{max-height:35vh;width:calc(100vw - 28px)}
+}
+</style>
+</head>
+<body>
+
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em></a></div>
+  <div class="title">${esc(title)} · <em>Drive Map</em></div>
+  <div class="actions">
+    <button class="btn" id="follow-toggle" type="button">📍 Follow</button>
+    <a class="btn" href="${esc(corridorPath)}">Read</a>
+  </div>
+</header>
+
+<div id="map"></div>
+
+<aside class="nearby" id="nearby" aria-live="polite">
+  <div class="nearby-head">
+    <h2>Up ahead</h2>
+    <span class="meta" id="nearby-status">geolocate · 5 nearest</span>
+  </div>
+  <div id="nearby-list">
+    <div style="padding:14px 18px;font-family:var(--mono);font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">Tap "Follow" to share your location.</div>
+  </div>
+</aside>
+
+<aside class="detail" id="detail" aria-hidden="true">
+  <button class="d-close" id="detail-close" type="button">×</button>
+  <div class="d-body" id="detail-body"></div>
+</aside>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
+<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin=""></script>
+<script>
+// =========================================================================
+// Inline payload — all corridor businesses with lat/lng. ${payload.length} total.
+// =========================================================================
+const SHOPS = ${escJsonLd(JSON.stringify(payload))};
+const ACCENT = ${JSON.stringify(accent)};
+const CORRIDOR_TITLE = ${JSON.stringify(title)};
+
+// Build a Leaflet map seeded on the median lat/lng of the corridor.
+const lats = SHOPS.map(s => s.lat).sort((a,b) => a-b);
+const lngs = SHOPS.map(s => s.lng).sort((a,b) => a-b);
+const center = lats.length
+  ? [lats[Math.floor(lats.length/2)], lngs[Math.floor(lngs.length/2)]]
+  : [34.15, -118.45];
+
+const map = L.map('map', { zoomControl: true, attributionControl: true })
+  .setView(center, 14);
+L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+  maxZoom: 19,
+  attribution: '© OpenStreetMap'
+}).addTo(map);
+
+// Fit to all pins on first load — this is corridor-scoped so it's bounded.
+if (SHOPS.length) {
+  map.fitBounds(SHOPS.map(s => [s.lat, s.lng]), { padding: [60, 60] });
+}
+
+// =========================================================================
+// Pins — divIcon dots, color by license/category. Cluster for performance.
+// =========================================================================
+function pinFor(s) {
+  const cls = s.lic ? 'pin-licensed' : (['salon','barbershop','beard','nail','lash','brow','spa'].includes(s.cat) ? 'pin-salon' : 'pin-other');
+  return L.divIcon({
+    className: '',
+    html: '<div class="pin ' + cls + '" aria-label="' + (s.name || '').replace(/"/g, '&quot;') + '"></div>',
+    iconSize: [14, 14],
+    iconAnchor: [7, 7],
+  });
+}
+const cluster = L.markerClusterGroup({
+  showCoverageOnHover: false,
+  spiderfyOnMaxZoom: true,
+  maxClusterRadius: 50,
+});
+const markers = [];
+for (const s of SHOPS) {
+  const m = L.marker([s.lat, s.lng], { icon: pinFor(s) });
+  m.shop = s;
+  m.on('click', () => openDetail(s));
+  cluster.addLayer(m);
+  markers.push(m);
+}
+map.addLayer(cluster);
+
+// =========================================================================
+// Side-panel detail
+// =========================================================================
+const detailEl = document.getElementById('detail');
+const detailBody = document.getElementById('detail-body');
+function openDetail(s) {
+  const dcaUrl = s.lic ? 'https://search.dca.ca.gov/results?lic=' + encodeURIComponent(s.lic) : '';
+  // Apple Maps and Google Maps deep-links — both use ?daddr= or ?destination=
+  const apple  = 'https://maps.apple.com/?daddr=' + s.lat + ',' + s.lng;
+  const google = 'https://www.google.com/maps/dir/?api=1&destination=' + s.lat + ',' + s.lng;
+  detailBody.innerHTML = ''
+    + '<div class="d-eyebrow">' + escapeHtml(s.cat || 'business') + (s.lic ? ' · ◆ State-Licensed' : '') + (s.sole ? ' · Sole-Proprietor' : '') + '</div>'
+    + '<h1 class="d-title">' + escapeHtml(s.name) + '</h1>'
+    + '<div class="d-meta">'
+      + (s.addr ? '<div class="row"><span>Address</span><strong>' + escapeHtml(s.addr) + '</strong></div>' : '')
+      + (s.hood ? '<div class="row"><span>Neighborhood</span><strong>' + escapeHtml(s.hood) + '</strong></div>' : '')
+      + (s.city ? '<div class="row"><span>City</span><strong>' + escapeHtml(s.city) + '</strong></div>' : '')
+      + (s.lic  ? '<div class="row"><span>License</span><strong>' + escapeHtml(s.lic) + '</strong></div>' : '')
+      + '<div class="row"><span>Distance</span><strong id="d-distance">—</strong></div>'
+    + '</div>'
+    + '<div id="d-sources" style="margin:8px 0 0;font-family:var(--mono);font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">loading community links…</div>'
+    + '<div class="d-actions">'
+      + '<a class="d-btn primary" href="/biz/' + encodeURIComponent(s.slug) + '">View Profile</a>'
+      + '<a class="d-btn" href="' + apple + '" target="_blank" rel="noopener">Apple Maps · Drive Here</a>'
+      + '<a class="d-btn" href="' + google + '" target="_blank" rel="noopener">Google Maps · Drive Here</a>'
+      + (dcaUrl ? '<a class="d-btn" href="' + dcaUrl + '" target="_blank" rel="noopener">◆ Verify License (DCA)</a>' : '')
+      + '<a class="d-btn" href="/biz/' + encodeURIComponent(s.slug) + '/links" style="margin-top:14px">+ Add Reddit / Discord / Yelp</a>'
+    + '</div>';
+  // Async-fetch the combined panel payload (socials + articles + review-sources).
+  // Single round-trip; renders inline below the meta block.
+  fetch('/api/biz/' + encodeURIComponent(s.slug) + '/panel')
+    .then(r => r.json())
+    .then(j => {
+      const el = document.getElementById('d-sources');
+      if (!el || !j?.ok) { if (el) el.textContent = ''; return; }
+      const socials = j.socials || [];
+      const articles = j.articles || [];
+      const sources = j.review_sources || [];
+      const blocks = [];
+      // Website (if known)
+      if (j.website) {
+        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Website</div>'
+          + '<a href="' + escapeHtml(j.website) + '" target="_blank" rel="noopener" style="display:block;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:13px;font-family:var(--sans);margin-bottom:6px">' + escapeHtml(j.website) + ' ↗</a>');
+      }
+      // Socials row
+      if (socials.length) {
+        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Socials</div>'
+          + '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px">'
+          + socials.map(soc =>
+              '<a href="' + escapeHtml(soc.url) + '" target="_blank" rel="noopener" style="padding:6px 10px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:11px;font-family:var(--mono)">'
+              + escapeHtml(soc.platform) + (soc.handle ? ' · @' + escapeHtml(soc.handle) : '') + ' ↗</a>'
+            ).join('')
+          + '</div>');
+      }
+      // Articles list
+      if (articles.length) {
+        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Press · ' + articles.length + '</div>'
+          + articles.map(a =>
+              '<a href="' + escapeHtml(a.url) + '" target="_blank" rel="noopener" style="display:block;padding:9px 12px;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans);line-height:1.35">'
+              + '<div style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:2px">' + escapeHtml(a.publisher || 'press') + '</div>'
+              + '<div style="font-family:var(--serif);font-size:14px;font-weight:500">' + escapeHtml(a.title || a.url) + '</div>'
+              + '</a>'
+            ).join(''));
+      }
+      // Community sources
+      if (sources.length) {
+        blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Community</div>'
+          + sources.map(src =>
+              '<a href="' + escapeHtml(src.url) + '" target="_blank" rel="noopener" style="display:block;padding:8px 0;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans)">'
+              + '<span style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-right:8px">' + escapeHtml(src.source_type) + '</span>'
+              + escapeHtml(src.label || src.url) + '</a>'
+            ).join(''));
+      }
+      el.innerHTML = blocks.join('');
+    })
+    .catch(() => { const el = document.getElementById('d-sources'); if (el) el.textContent = ''; });
+  detailEl.classList.add('open');
+  detailEl.setAttribute('aria-hidden', 'false');
+  // If we have a location, fill in the distance now.
+  if (currentPos) {
+    document.getElementById('d-distance').textContent = formatDist(haversineMi(currentPos.lat, currentPos.lng, s.lat, s.lng));
+  }
+  // Pan map to the shop with a small offset so the panel doesn't cover it.
+  map.setView([s.lat, s.lng], Math.max(map.getZoom(), 16));
+}
+document.getElementById('detail-close').addEventListener('click', () => {
+  detailEl.classList.remove('open');
+  detailEl.setAttribute('aria-hidden', 'true');
+});
+
+// =========================================================================
+// Geolocation — watchPosition, blue-dot + auto-recenter when "Follow" is on.
+// =========================================================================
+let currentPos = null;
+let meMarker = null;
+let follow = false;
+let watchId = null;
+
+const followBtn = document.getElementById('follow-toggle');
+followBtn.addEventListener('click', () => {
+  if (!navigator.geolocation) {
+    setStatus('geolocation unsupported');
+    return;
+  }
+  if (!follow) startFollow(); else stopFollow();
+});
+
+function startFollow() {
+  follow = true;
+  followBtn.classList.add('is-on');
+  followBtn.textContent = '📍 Following';
+  setStatus('locating…');
+  watchId = navigator.geolocation.watchPosition(onPos, onPosErr, {
+    enableHighAccuracy: true,
+    maximumAge: 5000,
+    timeout: 15000,
+  });
+}
+function stopFollow() {
+  follow = false;
+  followBtn.classList.remove('is-on');
+  followBtn.textContent = '📍 Follow';
+  setStatus('paused · 5 nearest');
+  if (watchId != null) navigator.geolocation.clearWatch(watchId);
+  watchId = null;
+}
+
+function onPos(p) {
+  const lat = p.coords.latitude, lng = p.coords.longitude;
+  currentPos = { lat, lng };
+  if (!meMarker) {
+    meMarker = L.marker([lat, lng], {
+      icon: L.divIcon({ className:'', html:'<div class="me-dot"></div>', iconSize:[18,18], iconAnchor:[9,9] }),
+      zIndexOffset: 1000,
+      interactive: false,
+    }).addTo(map);
+  } else {
+    meMarker.setLatLng([lat, lng]);
+  }
+  if (follow) map.setView([lat, lng], Math.max(map.getZoom(), 15));
+  refreshNearby(lat, lng);
+}
+function onPosErr(e) {
+  setStatus('location ' + (e.code === 1 ? 'denied' : 'unavailable'));
+  stopFollow();
+}
+
+// =========================================================================
+// Nearby panel — sort by haversine, render top 5.
+// =========================================================================
+function haversineMi(lat1, lng1, lat2, lng2) {
+  const R = 3958.8;  // earth radius in miles
+  const toRad = d => d * Math.PI / 180;
+  const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
+  const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2;
+  return 2 * R * Math.asin(Math.sqrt(a));
+}
+function formatDist(mi) {
+  if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
+  if (mi < 10)   return mi.toFixed(2) + ' mi';
+  return mi.toFixed(1) + ' mi';
+}
+function setStatus(t) { document.getElementById('nearby-status').textContent = t; }
+
+function refreshNearby(lat, lng) {
+  const ranked = SHOPS
+    .map(s => ({ ...s, _d: haversineMi(lat, lng, s.lat, s.lng) }))
+    .sort((a, b) => a._d - b._d)
+    .slice(0, 5);
+  const list = document.getElementById('nearby-list');
+  list.innerHTML = ranked.map(s =>
+    '<div class="nearby-row" data-slug="' + escapeHtml(s.slug) + '">'
+      + '<div class="n-info">'
+        + '<div class="n-name">' + (s.lic ? '<span class="lic-mark" title="state-licensed"></span>' : '') + escapeHtml(s.name) + '</div>'
+        + '<div class="n-where">' + escapeHtml(s.cat) + (s.hood ? ' · ' + escapeHtml(s.hood) : '') + '</div>'
+      + '</div>'
+      + '<div class="n-dist">' + formatDist(s._d) + '</div>'
+    + '</div>'
+  ).join('');
+  setStatus(CORRIDOR_TITLE.split(' ')[0] + ' · 5 nearest');
+  // Wire each row to open the side-panel for that shop.
+  Array.from(list.querySelectorAll('.nearby-row')).forEach(row => {
+    row.addEventListener('click', () => {
+      const slug = row.getAttribute('data-slug');
+      const s = SHOPS.find(x => x.slug === slug);
+      if (s) openDetail(s);
+    });
+  });
+  // If detail panel is open, update its distance row live.
+  const dEl = document.getElementById('d-distance');
+  if (dEl) {
+    // Find which shop is currently shown and recompute.
+    const open = SHOPS.find(s => detailBody.querySelector('.d-title')?.textContent === s.name);
+    if (open) dEl.textContent = formatDist(haversineMi(lat, lng, open.lat, open.lng));
+  }
+}
+
+function escapeHtml(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+    .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+}
+</script>
+</body>
+</html>`;
+}
diff --git a/src/render/corridor.js b/src/render/corridor.js
new file mode 100644
index 0000000..caf522e
--- /dev/null
+++ b/src/render/corridor.js
@@ -0,0 +1,404 @@
+// Corridor page — long, linear stretch of road treated as the spine of a
+// curated neighborhood directory. West-to-east scrollable. Neighborhoods
+// as sticky anchors. Licensed salons featured at the top of each cluster.
+//
+// First instance: /corridor/ventura-blvd — the 20-mile Ventura Blvd
+// corridor through Calabasas/Woodland Hills/Tarzana/Encino/Sherman Oaks/
+// Studio City. Tagged in source_data_json as 'ventura-blvd-20mi'.
+
+import { esc, escJsonLd } from '../lib/escape.js';
+
+// Build BreadcrumbList + ItemList JSON-LD for a corridor. Google uses
+// ItemList to surface rich "list of N items" search results — meaningful
+// CTR lift for long-tail queries like "salons on ventura blvd".
+function corridorJsonLd({ meta, businesses, corridorTag }) {
+  const baseUrl = process.env.SITE_URL || 'https://localhost';
+  const corridorPath = `/corridor/${corridorTag.replace(/-\d+mi$/, '')}`;
+  const itemListElements = businesses.slice(0, 100).map((b, i) => ({
+    '@type': 'ListItem',
+    position: i + 1,
+    item: {
+      '@type': b.source_data_json?.source === 'dca_bbcb' ? 'HairSalon' : 'LocalBusiness',
+      name: b.name,
+      url: `${baseUrl}/biz/${b.slug}`,
+      ...(b.address ? { address: { '@type': 'PostalAddress', streetAddress: b.address, addressLocality: b.city || undefined, addressRegion: b.state || 'CA', postalCode: b.zip || undefined } } : {}),
+      ...(b.latitude && b.longitude ? { geo: { '@type': 'GeoCoordinates', latitude: Number(b.latitude), longitude: Number(b.longitude) } } : {}),
+    },
+  }));
+  return [
+    {
+      '@context': 'https://schema.org',
+      '@type': 'BreadcrumbList',
+      itemListElement: [
+        { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl + '/' },
+        { '@type': 'ListItem', position: 2, name: 'Corridors', item: baseUrl + '/' },
+        { '@type': 'ListItem', position: 3, name: meta.title, item: baseUrl + corridorPath },
+      ],
+    },
+    {
+      '@context': 'https://schema.org',
+      '@type': 'ItemList',
+      name: `${meta.title} · ${businesses.length} businesses`,
+      description: meta.subtitle,
+      url: baseUrl + corridorPath,
+      numberOfItems: businesses.length,
+      itemListElement: itemListElements,
+    },
+  ];
+}
+
+// Editorial titling per corridor + the city order along the strip.
+const CORRIDOR_META = {
+  'ventura-blvd-20mi': {
+    title:    'Ventura Boulevard',
+    subtitle: 'A twenty-mile corridor of state-licensed salons, restaurants, and storefronts through the San Fernando Valley.',
+    eyebrow:  'The Ventura Blvd Corridor · West to East',
+    citySequence: [
+      'Woodland Hills',
+      'Tarzana',
+      'Encino',
+      'Sherman Oaks',
+      'Studio City',
+    ],
+    cityTagline: {
+      'Woodland Hills': 'Western terminus · The Westfield end',
+      'Tarzana':        'Middle Valley · medical-spa belt',
+      'Encino':         'Encino flats · old-school dining row',
+      'Sherman Oaks':   'Galleria-adjacent · the busiest mile',
+      'Studio City':    'Eastern terminus · CBS Radford-adjacent',
+    },
+    accent: '#5a6e3a', // olive — same value as Sherman Oaks neighborhood accent
+  },
+  'sunset-blvd-22mi': {
+    title:    'Sunset Boulevard',
+    subtitle: 'A twenty-two-mile corridor from Echo Park to the Pacific Ocean — every licensed salon, restaurant, and storefront on the strip that built Hollywood.',
+    eyebrow:  'The Sunset Blvd Corridor · East to West',
+    // Editorial sequence reads east-to-west (Echo Park first, the ocean last).
+    // Within each city the byWestEast comparator still applies, which is fine
+    // for individual blocks. City names match `city` field in the DB
+    // (case-insensitive comparison in renderCorridor).
+    citySequence: [
+      'Los Angeles',     // covers Echo Park / Silver Lake / Hollywood / Bel-Air / Brentwood / Pac Pal east side
+      'Hollywood',       // OSM-tagged variant for the Hollywood stretch
+      'West Hollywood',  // The Sunset Strip
+      'Beverly Hills',   // brief crossing
+      'Pacific Palisades', // western terminus toward PCH
+    ],
+    cityTagline: {
+      'Los Angeles':       'Echo Park to the Palisades · the LA stretch',
+      'Hollywood':         'Walk of Fame mile · the marquee strip',
+      'West Hollywood':    'The Sunset Strip · Whisky, Roxy, Viper',
+      'Beverly Hills':     'Brief crossing · the boutique mile',
+      'Pacific Palisades': 'Western terminus · PCH and the ocean',
+    },
+    accent: '#c2410c', // burnt orange — distinct from Ventura's olive
+  },
+  'wilshire-blvd-16mi': {
+    title:    'Wilshire Boulevard',
+    subtitle: 'Sixteen miles of LA\'s main street — from the towers of Downtown through Koreatown, the Miracle Mile museums, Beverly Hills, Westwood, and Brentwood, ending at the Santa Monica ocean.',
+    eyebrow:  'The Wilshire Blvd Corridor · East to West',
+    // Wilshire crosses fewer named cities than Sunset — most of its 16 miles
+    // are inside the City of LA proper, then a brief Beverly Hills crossing,
+    // then Santa Monica at the western terminus. Westwood + Brentwood are
+    // City-of-LA neighborhoods, so OSM `addr:city` tends to read 'Los Angeles'
+    // for those. The renderCorridor matcher is case-insensitive so the
+    // sequence here is canonical-cased.
+    citySequence: [
+      'Los Angeles',     // DTLA / MacArthur Park / Koreatown / Mid-Wilshire / Miracle Mile / Westwood / Brentwood
+      'Beverly Hills',   // brief crossing through the Golden Triangle
+      'Santa Monica',    // western terminus at the ocean
+    ],
+    cityTagline: {
+      'Los Angeles':   'DTLA to Brentwood · the long LA stretch',
+      'Beverly Hills': 'The Golden Triangle · Rodeo-adjacent crossing',
+      'Santa Monica':  'Western terminus · the ocean mile',
+    },
+    accent: '#6b3a8e', // purple — distinct from Ventura olive + Sunset burnt-orange
+  },
+  'hollywood-blvd-5mi': {
+    title:    'Hollywood Boulevard',
+    subtitle: 'Five miles of marquees, sound stages, and salons on the strip the world thinks of when it thinks of LA — from East Hollywood through the Walk of Fame to the Hollywood Hills.',
+    eyebrow:  'The Hollywood Blvd Corridor · East to West',
+    // Hollywood Blvd is short and runs almost entirely inside the City of LA.
+    // OSM `addr:city` tags split between 'Los Angeles' (catch-all), 'Hollywood'
+    // (central stretch), 'East Hollywood' (Vermont side), and 'Hollywood Hills'
+    // (the western frontage toward Laurel Canyon). citySequence reads east-to-
+    // west so reading the page literally walks the strip.
+    citySequence: [
+      'Thai Town',       // Vermont / East Hollywood end
+      'Hollywood',       // central · Walk of Fame · Vine · Highland
+      'Los Angeles',     // umbrella-city catch-all (covers the bulk of the strip)
+    ],
+    cityTagline: {
+      'Thai Town':       'Eastern end · Vermont · LA\'s only official Thai Town',
+      'Hollywood':       'Walk of Fame · Vine · Highland · the marquee mile',
+      'Los Angeles':     'The umbrella-city stretch · running through the Hills',
+    },
+    accent: '#9b1c1c', // deep marquee red — distinct from olive/orange/purple
+  },
+};
+
+const CATEGORY_LABEL = {
+  barbershop:'Barber & Beard', beard:'Barber & Beard', salon:'Salon · Hair · Nails',
+  hair:'Hair', nail:'Nails', nail_salon:'Nails', lash:'Lash', brow:'Brow',
+  spa:'Spa', blowout:'Blowout', waxing:'Waxing',
+  restaurant:'Restaurant', cafe:'Café', bar:'Bar', bakery:'Bakery', deli:'Deli',
+  retail:'Retail', boutique:'Boutique', grocery:'Grocery',
+  pharmacy:'Pharmacy', health:'Health', finance:'Bank · Finance',
+  fitness:'Fitness', service:'Service', office:'Office',
+  entertainment:'Entertainment',
+  generic:'Business',
+};
+
+// Categories that are "salon-adjacent" and get the gold-accent treatment.
+const SALON_CATS = new Set(['salon','barbershop','beard','hair','nail','nail_salon','lash','brow','spa','blowout','waxing']);
+
+function categoryLabel(c) {
+  return CATEGORY_LABEL[c] || (c ? c[0].toUpperCase() + c.slice(1) : 'Business');
+}
+
+// Render a tight business card. Bigger for salons, compact strip for others.
+function renderBizCard(b, { variant = 'salon' } = {}) {
+  const cat = categoryLabel(b.category);
+  const isSalon = SALON_CATS.has(b.category);
+  const licensed = b.source_data_json?.source === 'dca_bbcb';
+  const licNum = b.source_data_json?.license_number || '';
+  // DCA verify link — sole-proprietor establishment licenses use license_type
+  // 'EST'/'BBR'; the public search will resolve any of them via lic-number alone.
+  const licUrl = licNum ? `https://search.dca.ca.gov/results?lic=${encodeURIComponent(licNum)}` : '';
+  if (variant === 'strip') {
+    // Compact horizontal-strip card for non-salon businesses
+    return `
+    <a class="strip-card" href="/biz/${esc(b.slug)}">
+      <span class="sc-cat">${esc(cat)}</span>
+      <span class="sc-name">${esc(b.name)}</span>
+      ${b.address ? `<span class="sc-addr">${esc(b.address)}</span>` : ''}
+    </a>`;
+  }
+  // Salon variant. Outer is the biz-card link; the license badge is a SECOND
+  // <a> sibling positioned absolutely over the image so the click goes outbound
+  // (DCA verify) rather than inward (the biz profile).
+  return `
+  <div class="biz-card${isSalon ? ' is-salon' : ''}">
+    <a class="bc-link" href="/biz/${esc(b.slug)}"><div class="bc-img">
+      ${b.hero_image_url
+        ? `<img loading="lazy" src="${esc(b.hero_image_url)}" alt="${esc(b.name)}">`
+        : `<div class="bc-img-placeholder"></div>`}
+    </div></a>
+    ${licensed && licUrl ? `<a class="bc-badge" href="${esc(licUrl)}" target="_blank" rel="noopener" title="Verify state cosmetology license #${esc(licNum)} on DCA">◆ Licensed · ${esc(licNum)}</a>` : ''}
+    <a class="bc-link" href="/biz/${esc(b.slug)}"><div class="bc-meta">
+      <span class="bc-cat">${esc(cat)}</span>
+      <h3 class="bc-name">${esc(b.name)}</h3>
+      ${b.address ? `<p class="bc-addr">${esc(b.address)}</p>` : ''}
+    </div></a>
+  </div>`;
+}
+
+// Build one city section (e.g., "Sherman Oaks") with featured salons + business strip.
+function renderCitySection(cityName, businesses, meta) {
+  const tagline = meta.cityTagline?.[cityName] || '';
+  // West-to-east comparator: rows with longitude lead (sorted ascending), then
+  // ungeocoded fall to the bottom alphabetically. Negative longitudes are
+  // expected for LA so smaller (more negative) = westernmost = first.
+  const byWestEast = (a, b) => {
+    const al = a.longitude == null ? Infinity : Number(a.longitude);
+    const bl = b.longitude == null ? Infinity : Number(b.longitude);
+    if (al !== bl) return al - bl;
+    return (a.name || '').localeCompare(b.name || '');
+  };
+  // Salons: licensed first, then west-to-east within each tier.
+  const salons = businesses
+    .filter(b => SALON_CATS.has(b.category))
+    .sort((a, b) => {
+      const aLic = a.source_data_json?.source === 'dca_bbcb' ? 0 : 1;
+      const bLic = b.source_data_json?.source === 'dca_bbcb' ? 0 : 1;
+      if (aLic !== bLic) return aLic - bLic;
+      return byWestEast(a, b);
+    });
+  // Others: pure west-to-east. Reading down the strip-list literally walks
+  // the block from the western end of the city to the eastern end.
+  const others = businesses
+    .filter(b => !SALON_CATS.has(b.category))
+    .sort(byWestEast);
+
+  const slug = cityName.toLowerCase().replace(/\s+/g, '-');
+  return `
+  <section class="city" id="${esc(slug)}">
+    <div class="city-head">
+      <span class="city-num">${businesses.length} businesses · ${salons.length} licensed salons</span>
+      <h2>${esc(cityName)}</h2>
+      ${tagline ? `<p class="city-tagline">${esc(tagline)}</p>` : ''}
+    </div>
+
+    ${salons.length ? `
+      <div class="city-section-label">Salons & Barbers · State-Licensed First</div>
+      <div class="biz-grid">
+        ${salons.slice(0, 24).map(b => renderBizCard(b, { variant: 'salon' })).join('')}
+      </div>
+    ` : ''}
+
+    ${others.length ? `
+      <div class="city-section-label">The Rest of the Block</div>
+      <div class="strip-list">
+        ${others.map(b => renderBizCard(b, { variant: 'strip' })).join('')}
+      </div>
+    ` : ''}
+  </section>`;
+}
+
+export function renderCorridor({ corridorTag = 'ventura-blvd-20mi', businesses = [] } = {}) {
+  const meta = CORRIDOR_META[corridorTag];
+  if (!meta) return '<!doctype html><title>Unknown corridor</title><h1>Unknown corridor</h1>';
+
+  // Group by city. We sort cities according to the editorial sequence; any
+  // unrecognized city (e.g., "Los Angeles" generic, or a typo'd entry) lands
+  // at the bottom under "Other".
+  const byCity = new Map();
+  for (const b of businesses) {
+    const c = (b.city || '').trim();
+    const key = meta.citySequence.find(seq => seq.toLowerCase() === c.toLowerCase()) || (c || 'Other');
+    if (!byCity.has(key)) byCity.set(key, []);
+    byCity.get(key).push(b);
+  }
+  // Order: editorial sequence first, then any extras alphabetically.
+  const orderedKeys = [
+    ...meta.citySequence.filter(c => byCity.has(c)),
+    ...[...byCity.keys()].filter(c => !meta.citySequence.includes(c)).sort(),
+  ];
+
+  const totalLicensed = businesses.filter(b => b.source_data_json?.source === 'dca_bbcb').length;
+  const totalSalons = businesses.filter(b => SALON_CATS.has(b.category)).length;
+  const totalGeocoded = businesses.filter(b => b.latitude != null).length;
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(meta.title)} · A Salon & Business Corridor · LA Salon Directory</title>
+<meta name="description" content="${esc(meta.subtitle).slice(0, 160)}">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:${meta.accent};
+  --gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+img{display:block;max-width:100%;height:auto}
+
+/* TOPBAR =================================================== */
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}
+.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.brand a{display:inline}
+
+/* CITY-ANCHOR NAV ========================================== */
+.anchor-nav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);overflow-x:auto;white-space:nowrap}
+.anchor-nav a{padding:4px 0;border-bottom:1px solid transparent;transition:border-color 200ms ease,color 200ms ease}
+.anchor-nav a:hover{color:var(--ink);border-bottom-color:var(--accent)}
+
+/* HERO ===================================================== */
+.hero{padding:80px 32px 56px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+.hero-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:20px}
+.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,8vw,108px);line-height:.96;letter-spacing:-.02em;margin-bottom:24px;max-width:14ch}
+.hero h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:60ch;margin-bottom:48px}
+.hero-stats{display:flex;gap:48px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
+.hero-stats b{display:block;font-family:var(--serif);font-style:italic;font-size:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}
+
+/* CITY SECTION ============================================= */
+.city{padding:96px 32px;max-width:1240px;margin:0 auto;scroll-margin-top:72px}
+.city + .city{border-top:1px solid var(--rule)}
+.city-head{margin-bottom:48px}
+.city-num{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
+.city-head h2{font-family:var(--serif);font-weight:500;font-size:clamp(56px,8vw,108px);line-height:.96;letter-spacing:-.02em;margin:6px 0 12px}
+.city-tagline{font-family:var(--serif);font-style:italic;font-size:22px;color:var(--mute);max-width:50ch}
+.city-section-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin:32px 0 18px;padding-bottom:6px;border-bottom:1px solid var(--rule)}
+
+/* BIZ CARD GRID (for salons) =============================== */
+.biz-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:24px}
+.biz-card{display:block;color:var(--ink)}
+.bc-img{aspect-ratio:4/5;overflow:hidden;background:#222;margin-bottom:12px;position:relative}
+.bc-img img{width:100%;height:100%;object-fit:cover;transition:transform 600ms ease}
+.biz-card:hover .bc-img img{transform:scale(1.04)}
+.bc-img-placeholder{position:absolute;inset:0;background:linear-gradient(135deg,var(--ink) 0%,#2a2a2a 100%)}
+.bc-badge{position:absolute;top:10px;left:10px;background:var(--gold);color:var(--ink);font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;padding:4px 8px;border-radius:2px;font-weight:700}
+.biz-card.is-salon .bc-img-placeholder{background:linear-gradient(135deg,#1a1a1a 0%,${meta.accent} 140%)}
+.bc-cat{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;display:inline-block}
+.bc-name{font-family:var(--serif);font-size:21px;font-weight:500;letter-spacing:-.005em;line-height:1.15;margin-bottom:4px}
+.bc-addr{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.4}
+
+/* STRIP-CARD LIST (for non-salon businesses) =============== */
+.strip-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0;border-top:1px solid var(--rule)}
+.strip-card{display:flex;flex-direction:column;padding:14px 18px;border-bottom:1px solid var(--rule);border-right:1px solid var(--rule);transition:background 150ms ease}
+.strip-card:hover{background:#fff}
+.sc-cat{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:4px}
+.sc-name{font-family:var(--serif);font-size:17px;font-weight:500;letter-spacing:-.005em;line-height:1.2;margin-bottom:2px}
+.sc-addr{font-family:var(--mono);font-size:10px;color:var(--mute);letter-spacing:.04em}
+
+/* MANIFESTO ================================================= */
+.manifesto{padding:96px 32px;background:var(--ink);color:var(--paper);text-align:center}
+.manifesto p{font-family:var(--serif);font-style:italic;font-size:clamp(28px,4vw,48px);line-height:1.2;letter-spacing:-.01em;font-weight:400;max-width:880px;margin:0 auto}
+.manifesto p em{font-style:normal;color:var(--gold)}
+.manifesto .sig{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#888;margin-top:32px}
+
+/* FOOTER ==================================================== */
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
+
+@media (max-width:880px){
+  .topbar{flex-direction:column;align-items:flex-start;gap:12px;padding:14px 20px}
+  .anchor-nav{width:100%}
+  .hero{padding:48px 20px 32px}
+  .city{padding:48px 20px}
+  .strip-list{grid-template-columns:1fr}
+}
+</style>
+${corridorJsonLd({ meta, businesses, corridorTag }).map(blob => `<script type="application/ld+json">${escJsonLd(JSON.stringify(blob))}</script>`).join('\n')}
+</head>
+<body>
+
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ ${esc(meta.title)}</span></div>
+  <nav class="anchor-nav">
+    ${orderedKeys.map(c => `<a href="#${esc(c.toLowerCase().replace(/\s+/g, '-'))}">${esc(c)}</a>`).join('')}
+    <a href="/corridor/${corridorTag.replace(/-\d+mi$/, '')}/map" style="margin-left:auto;background:var(--accent);color:var(--paper);padding:4px 12px;border-radius:0;font-weight:600">📍 Drive Map →</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-eyebrow">${esc(meta.eyebrow)}</div>
+  <h1>${esc(meta.title.split(' ')[0])} <em>${esc(meta.title.split(' ').slice(1).join(' '))}.</em></h1>
+  <p class="hero-lede">${esc(meta.subtitle)}</p>
+  <div class="hero-stats">
+    <div>Businesses on the strip<b>${businesses.length}</b></div>
+    <div>State-licensed salons<b>${totalLicensed}</b></div>
+    <div>All salon-types<b>${totalSalons}</b></div>
+    <div>Geocoded<b>${totalGeocoded}</b></div>
+    <div>Cities covered<b>${orderedKeys.length}</b></div>
+  </div>
+</section>
+
+${orderedKeys.map(c => renderCitySection(c, byCity.get(c) || [], meta)).join('')}
+
+<section class="manifesto">
+  <p>One street. <em>Twenty miles.</em> Every shop with a license, every restaurant worth a stop, every business that registered with the city — west to east, in order.</p>
+  <div class="sig">— The ${esc(meta.title)} Corridor</div>
+</section>
+
+<footer class="footer">
+  <span>${esc(meta.title)} Corridor · ${new Date().getFullYear()}</span>
+  <span>${businesses.length} businesses · ${orderedKeys.length} cities · sourced from BBCB + OSM</span>
+  <span><a href="/">← Full directory</a></span>
+</footer>
+
+</body>
+</html>`;
+}
diff --git a/src/render/edit.js b/src/render/edit.js
index 8d0f9d9..f4800e2 100644
--- a/src/render/edit.js
+++ b/src/render/edit.js
@@ -17,7 +17,7 @@ export function renderEdit({ business: b, socials }) {
     <a class="back" href="/">← All businesses</a>
     <h1>${esc(b.name)}</h1>
     <p class="meta">${esc(b.category || '')} · ${esc(b.city || '')}${b.state ? ', ' + esc(b.state) : ''}</p>
-    <p class="tier">Tier: <strong>${esc(b.tier.toUpperCase())}</strong></p>
+    <p class="tier">Tier: <strong>${esc((b.tier || 'free').toUpperCase())}</strong></p>
 
     <details open class="mf"><summary>Basics</summary>
       <form onsubmit="return saveJson(event,'/api/biz/update')">
@@ -100,7 +100,7 @@ export function renderEdit({ business: b, socials }) {
       ${tpls.map(m => `
         <section class="tpl">
           <header><h3>${esc(m.name)}</h3><a target="_blank" rel="noopener" href="/biz/${esc(b.slug)}/template/${m.id}">Open ↗</a></header>
-          <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview"></iframe>
+          <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview" title="${esc(m.name)} preview" aria-hidden="true" tabindex="-1"></iframe>
           <p class="blurb">${esc(m.blurb)}</p>
         </section>`).join('')}
     </div>
diff --git a/src/render/fresh.js b/src/render/fresh.js
new file mode 100644
index 0000000..1fb18b9
--- /dev/null
+++ b/src/render/fresh.js
@@ -0,0 +1,150 @@
+// /fresh — newest state-licensed shops first. The BBCB Issue Date field is the
+// "registered with the state" timestamp — a salon's grand-opening signal. This
+// page lists the freshest 100 by issue date, perfect for a weekly-refresh
+// page that pulls repeat traffic + ranks for "newly opened salon" / "new
+// barbershop in {neighborhood}" long-tail queries.
+
+import { esc, escJsonLd } from '../lib/escape.js';
+
+function fmtDate(s) {
+  // BBCB stores either "MM-DD-YYYY" or "MM/DD/YYYY" — render as "Mar 14, 2025"
+  const m = String(s || '').match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
+  if (!m) return s || '';
+  const [, mm, dd, yyyy] = m;
+  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+  const idx = parseInt(mm, 10) - 1;
+  if (idx < 0 || idx > 11) return s;
+  return `${months[idx]} ${parseInt(dd, 10)}, ${yyyy}`;
+}
+
+function slugify(s) {
+  return String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+}
+
+export function renderFresh({ businesses = [] } = {}) {
+  const baseUrl = process.env.SITE_URL || 'https://localhost';
+  const collectionPage = {
+    '@context': 'https://schema.org',
+    '@type': 'CollectionPage',
+    name: 'New & Newly-Licensed Salons in LA',
+    description: 'The 100 most-recently licensed salons, barbershops, and spas across LA + Orange + Riverside + San Bernardino + Ventura counties.',
+    url: `${baseUrl}/fresh`,
+    dateModified: new Date().toISOString(),
+    mainEntity: {
+      '@type': 'ItemList',
+      numberOfItems: businesses.length,
+      itemListElement: businesses.slice(0, 100).map((b, i) => ({
+        '@type': 'ListItem',
+        position: i + 1,
+        item: {
+          '@type': 'HairSalon',
+          name: b.name,
+          url: `${baseUrl}/biz/${b.slug}`,
+        },
+      })),
+    },
+  };
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Just Opened · Newest LA Salons & Barbershops · LA Salon Directory</title>
+<meta name="description" content="The freshest state-licensed salons, barbers, and spas across the LA region — sorted by Board of Barbering & Cosmetology issue date.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<script type="application/ld+json">${escJsonLd(JSON.stringify(collectionPage))}</script>
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:#a16207; /* warm gold-amber for "fresh / new" */
+  --gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
+.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.topnav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav a:hover{color:var(--ink)}
+
+.hero{padding:80px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+.hero-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}
+.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,7vw,96px);line-height:.96;letter-spacing:-.02em;margin-bottom:20px;max-width:14ch}
+.hero h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:62ch;margin-bottom:28px}
+.hero-meta{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+
+.fresh-list{padding:48px 32px;max-width:1240px;margin:0 auto}
+.fresh-row{display:grid;grid-template-columns:120px 1fr 200px 120px;gap:32px;align-items:baseline;padding:22px 0;border-bottom:1px solid var(--rule);transition:padding 180ms ease}
+.fresh-row:hover{padding-left:8px;background:#fff}
+.fresh-date{font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--accent);text-transform:uppercase;font-weight:500}
+.fresh-name{font-family:var(--serif);font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.1}
+.fresh-name em{font-style:italic;color:var(--mute);font-size:13px;margin-left:8px}
+.fresh-where{font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.5}
+.fresh-lic{font-family:var(--mono);font-size:9px;letter-spacing:.16em;color:var(--mute);text-align:right}
+.fresh-lic a{padding:4px 8px;border:1px solid var(--rule);transition:all 150ms ease}
+.fresh-lic a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+@media (max-width:880px){
+  .fresh-row{grid-template-columns:80px 1fr;gap:16px}
+  .fresh-where,.fresh-lic{grid-column:2;font-size:10px}
+}
+
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
+@media (max-width:880px){
+  .topbar{padding:14px 20px}
+  .hero{padding:48px 20px 32px}
+  .fresh-list{padding:32px 20px}
+}
+</style>
+</head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ just opened</span></div>
+  <nav class="topnav">
+    <a href="/neighborhoods">Neighborhoods</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/corridor/ventura-blvd">Ventura</a>
+    <a href="/corridor/sunset-blvd">Sunset</a>
+    <a href="/map">Map</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-eyebrow">Fresh · State-Licensed · This Quarter</div>
+  <h1>Just <em>opened.</em></h1>
+  <p class="hero-lede">The 100 most-recent state-licensed salons, barbers, and spas across the LA region — sorted by California Board of Barbering &amp; Cosmetology issue date. New chairs, new lobbies, new colorists.</p>
+  <div class="hero-meta">${businesses.length} listings · sourced from CA DCA · refreshed ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</div>
+</section>
+
+<section class="fresh-list">
+  ${businesses.map(b => {
+    const issued = b.source_data_json?.issue_date || '';
+    const lic = b.source_data_json?.license_number || '';
+    const licUrl = lic ? `https://search.dca.ca.gov/results?lic=${encodeURIComponent(lic)}` : '';
+    return `
+    <div class="fresh-row">
+      <span class="fresh-date">${esc(fmtDate(issued))}</span>
+      <a class="fresh-name" href="/biz/${esc(b.slug)}">${esc(b.name)}<em>${esc(b.category || '')}</em></a>
+      <span class="fresh-where">${esc(b.neighborhood || b.city || '')}</span>
+      <span class="fresh-lic">${licUrl ? `<a href="${esc(licUrl)}" target="_blank" rel="noopener">◆ ${esc(lic)}</a>` : ''}</span>
+    </div>`;
+  }).join('')}
+</section>
+
+<footer class="footer">
+  <span>LA Salon Directory · ${new Date().getFullYear()}</span>
+  <span>Source: CA Department of Consumer Affairs · BBCB</span>
+  <span><a href="/">← Home</a></span>
+</footer>
+</body>
+</html>`;
+}
diff --git a/src/render/grid.js b/src/render/grid.js
index 058d361..f248f65 100644
--- a/src/render/grid.js
+++ b/src/render/grid.js
@@ -12,24 +12,27 @@ export function renderGrid({ businesses }) {
   const tpls = templateMeta();
   const hasAny = businesses && businesses.length > 0;
 
-  const rows = (businesses || []).map(b => `
+  const rows = (businesses || []).map(b => {
+    const tier = b.tier || 'free';
+    return `
     <article class="row" data-slug="${esc(b.slug)}">
       <header class="row-head">
         <div>
           <h2><a href="/biz/${esc(b.slug)}">${esc(b.name)}</a></h2>
-          <p class="meta">${esc(b.category || '—')} · ${esc(b.city || '')}${b.state ? ', ' + esc(b.state) : ''} · <span class="tier tier-${esc(b.tier)}">${esc(b.tier.toUpperCase())}</span></p>
+          <p class="meta">${esc(b.category || '—')} · ${esc(b.city || '')}${b.state ? ', ' + esc(b.state) : ''} · <span class="tier tier-${esc(tier)}">${esc(tier.toUpperCase())}</span></p>
         </div>
         <a class="btn-edit" href="/biz/${esc(b.slug)}/edit">Edit</a>
       </header>
       <div class="carousel">
         ${tpls.map(m => `
-          <a class="card" href="/biz/${esc(b.slug)}/template/${m.id}" target="_blank" rel="noopener">
-            <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview" title="${esc(b.name)} — ${esc(m.name)}"></iframe>
+          <a class="card" href="/biz/${esc(b.slug)}/template/${m.id}" target="_blank" rel="noopener" aria-label="${esc(b.name)} — ${esc(m.name)}">
+            <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview" title="${esc(b.name)} — ${esc(m.name)}" aria-hidden="true" tabindex="-1"></iframe>
             <div class="card-foot"><strong>${esc(m.name)}</strong><span>${esc(m.blurb)}</span></div>
           </a>
         `).join('')}
       </div>
-    </article>`).join('');
+    </article>`;
+  }).join('');
 
   return `<!doctype html>
 <html lang="en"><head>
diff --git a/src/render/home.js b/src/render/home.js
index f5e60fd..dfdb2b5 100644
--- a/src/render/home.js
+++ b/src/render/home.js
@@ -146,7 +146,7 @@ function renderFeaturedInterview(featured) {
 }
 
 // businesses: full list, neighborhoods: [{neighborhood, count}], featuredBarbers: shops where category in barbershop/beard
-export function renderHome({ featuredBarbers = [], otherShops = [], neighborhoods = [], featured = null, totalCount = 0 } = {}) {
+export function renderHome({ featuredBarbers = [], otherShops = [], neighborhoods = [], featured = null, totalCount = 0, latestFinds = [] } = {}) {
   // Sort neighborhood list by editorial priority, then by count desc, then alphabetical
   const orderIdx = (n) => {
     const i = NAV_ORDER.indexOf(n);
@@ -208,6 +208,18 @@ img{display:block;max-width:100%;height:auto}
 .section-head h2{font-family:var(--serif);font-weight:500;font-size:clamp(32px,4vw,52px);letter-spacing:-.015em;line-height:1}
 .section-head .kicker{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--mute)}
 
+/* ==== Corridor promo strip ====================================== */
+.corridor-strip{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--rule);border:1px solid var(--rule);overflow:hidden}
+.corridor-tile{background:var(--paper);padding:48px 36px 40px;border-top:4px solid var(--c-accent,var(--accent));position:relative;transition:background 220ms ease;display:flex;flex-direction:column;gap:16px;min-height:340px}
+.corridor-tile:hover{background:#fff}
+.corridor-tile .ct-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--c-accent,var(--accent));font-weight:600}
+.corridor-tile .ct-title{font-family:var(--serif);font-size:clamp(38px,4.5vw,56px);font-weight:500;line-height:.96;letter-spacing:-.015em;margin:6px 0}
+.corridor-tile .ct-title em{font-style:italic;color:var(--c-accent,var(--accent))}
+.corridor-tile .ct-sub{font-size:14px;line-height:1.55;color:#3a3a3a;margin-bottom:auto}
+.corridor-tile .ct-cta{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--c-accent,var(--accent));margin-top:18px;padding-top:14px;border-top:1px solid var(--rule);transition:letter-spacing 200ms ease}
+.corridor-tile:hover .ct-cta{letter-spacing:.28em}
+@media (max-width:880px){.corridor-strip{grid-template-columns:1fr}}
+
 /* ==== Neighborhood tiles ======================================== */
 .hoods{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
 .hood{background:var(--paper);padding:28px 24px;display:flex;flex-direction:column;justify-content:flex-end;min-height:180px;border-left:4px solid var(--accent);transition:background 200ms ease,transform 200ms ease}
@@ -249,6 +261,17 @@ img{display:block;max-width:100%;height:auto}
 .manifesto p em{color:#c9a23a;font-style:normal}
 .manifesto .sig{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#aaa;margin-top:32px}
 
+/* ==== Map pill ================================================== */
+.hero-map-pill{
+  display:inline-block;margin-top:32px;padding:12px 20px;
+  font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;
+  border:1px solid var(--accent);color:var(--accent);
+  transition:background 150ms,color 150ms;
+}
+.hero-map-pill:hover{background:var(--accent);color:var(--paper)}
+.map-pill{color:var(--accent)!important}
+.map-pill:hover{color:var(--ink)!important}
+
 /* ==== Footer ==================================================== */
 .footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
 
@@ -260,15 +283,21 @@ img{display:block;max-width:100%;height:auto}
   .hero-stat{flex-wrap:wrap;gap:24px}
 }
 </style>
+<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"LA Salon Directory","url":"https://localhost/","potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://localhost/search?q={search_term_string}"},"query-input":"required name=search_term_string"}}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@type":"Organization","name":"LA Salon Directory","url":"https://localhost/","sameAs":[]}</script>
 </head>
 <body>
 
 <header class="topbar">
   <div class="brand">LA <em>Salon</em> Directory</div>
   <nav class="nav-mini">
-    <a href="#neighborhoods">Neighborhoods</a>
-    <a href="#barber">Barber & Beard</a>
-    <a href="#all">All Shops</a>
+    <a href="/best">★ The Best</a>
+    <a href="/leaderboard">Leaderboard</a>
+    <a href="/neighborhoods">Neighborhoods</a>
+    <a href="/addresses">Addresses</a>
+    <a href="/press">Press</a>
+    <a href="/timeline">Live Feed</a>
+    <a href="/near" class="map-pill">📍 Near Me →</a>
   </nav>
 </header>
 
@@ -281,10 +310,65 @@ img{display:block;max-width:100%;height:auto}
     <div>Neighborhoods<b>${neighborhoods.length}</b></div>
     <div>Categories<b>${new Set(featuredBarbers.concat(otherShops).map(s => s.category).filter(Boolean)).size}</b></div>
   </div>
+  <a href="/map" class="hero-map-pill">View the whole map →</a>
 </section>
 
 ${renderFeaturedInterview(featured)}
 
+${latestFinds.length ? `
+<section class="section" id="latest" style="padding-bottom:32px">
+  <div class="section-head">
+    <h2>Just discovered</h2>
+    <a href="/timeline" class="kicker" style="color:var(--accent);text-decoration:none">Live feed →</a>
+  </div>
+  <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule);overflow:hidden">
+    ${latestFinds.map(f => {
+      const ageMs = Date.now() - new Date(f.ts).getTime();
+      const ago = ageMs < 60_000 ? 'just now' : ageMs < 3_600_000 ? Math.floor(ageMs/60_000) + 'm ago' : ageMs < 86_400_000 ? Math.floor(ageMs/3_600_000) + 'h ago' : Math.floor(ageMs/86_400_000) + 'd ago';
+      const tagBg = f.kind === 'article' ? '#b8945a' : 'var(--accent)';
+      const tagFg = f.kind === 'article' ? '#1a1a1a' : '#fafaf6';
+      const icon  = f.kind === 'article' ? '📰' : '📷';
+      const subtitle = f.kind === 'article' ? (f.publisher || 'press') : `@${f.title || f.publisher}`;
+      return `<a href="/biz/${esc(f.slug)}" style="background:var(--paper);padding:18px 18px 14px;display:flex;flex-direction:column;gap:6px;transition:background 200ms ease;color:var(--ink);text-decoration:none" onmouseover="this.style.background='#fff'" onmouseout="this.style.background='var(--paper)'">
+        <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
+          <span style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:${tagFg};background:${tagBg};padding:2px 8px;font-weight:600">${icon} ${esc(f.kind === 'article' ? 'press' : f.publisher || 'social')}</span>
+          <span style="font-family:var(--mono);font-size:9px;color:var(--mute);letter-spacing:.04em;text-transform:uppercase">${esc(ago)}</span>
+        </div>
+        <div style="font-family:var(--serif);font-size:18px;font-weight:500;line-height:1.2;letter-spacing:-.005em;margin-top:6px">${esc(f.name)}</div>
+        <div style="font-family:var(--mono);font-size:10px;letter-spacing:.04em;color:var(--mute);text-transform:uppercase">${esc(f.neighborhood || '')}${f.neighborhood && subtitle ? ' · ' : ''}${esc(subtitle)}</div>
+      </a>`;
+    }).join('')}
+  </div>
+</section>
+` : ''}
+
+<section class="section corridors" id="corridors">
+  <div class="section-head">
+    <h2>The Corridors</h2>
+    <span class="kicker">Three streets · Walk every block</span>
+  </div>
+  <div class="corridor-strip">
+    <a class="corridor-tile" href="/corridor/ventura-blvd" style="--c-accent:#5a6e3a">
+      <span class="ct-label">No. 1 · 20 miles</span>
+      <h3 class="ct-title">Ventura <em>Boulevard</em></h3>
+      <p class="ct-sub">Woodland Hills → Studio City. The valley spine — medical-spa belts, old-school dining rows, every state-licensed salon on the strip.</p>
+      <span class="ct-cta">West to East · <a href="/corridor/ventura-blvd/map" onclick="event.stopPropagation()" style="color:inherit;text-decoration:underline">Drive Map →</a></span>
+    </a>
+    <a class="corridor-tile" href="/corridor/sunset-blvd" style="--c-accent:#c2410c">
+      <span class="ct-label">No. 2 · 22 miles</span>
+      <h3 class="ct-title">Sunset <em>Boulevard</em></h3>
+      <p class="ct-sub">Echo Park → Pacific Ocean. The strip that built Hollywood — Walk of Fame, the Whisky, the Roxy, every salon between Vine and the Palisades.</p>
+      <span class="ct-cta">East to West →</span>
+    </a>
+    <a class="corridor-tile" href="/corridor/wilshire-blvd" style="--c-accent:#6b3a8e">
+      <span class="ct-label">No. 3 · 16 miles</span>
+      <h3 class="ct-title">Wilshire <em>Boulevard</em></h3>
+      <p class="ct-sub">DTLA → Santa Monica. The main street — MacArthur Park, Miracle Mile, the Beverly Hills crossing, everything along LACMA's mile.</p>
+      <span class="ct-cta">East to West →</span>
+    </a>
+  </div>
+</section>
+
 <section class="section" id="neighborhoods">
   <div class="section-head">
     <h2>Browse by neighborhood</h2>
diff --git a/src/render/interview-live.js b/src/render/interview-live.js
new file mode 100644
index 0000000..93f8a18
--- /dev/null
+++ b/src/render/interview-live.js
@@ -0,0 +1,515 @@
+// Live AI-avatar interview — /biz/:slug/interview/live
+//
+// The owner clicks "Start" and:
+//   1. Browser asks for camera + mic permission.
+//   2. Avatar (stylized SVG interviewer, speaking-pulse animation) appears on
+//      the left. Webcam PIP bottom-right.
+//   3. Question is spoken via Web Speech API SpeechSynthesis AND shown big.
+//   4. User answers verbally; SpeechRecognition transcribes inline.
+//   5. They can edit the transcript before clicking Next.
+//   6. Optional "Snap photo" captures a webcam frame → POST /api/biz/photo/upload.
+//   7. Final click → POST /api/biz/interview saves all answers.
+//
+// Browser support: Chrome/Edge full. Safari has SpeechRecognition since 14.1.
+// Firefox lacks SpeechRecognition — we fall back to typed input.
+
+import { esc } from '../lib/escape.js';
+import { QUESTIONS, SECTIONS, localizeQuestion } from '../lib/interview-questions.js';
+
+// Escape JSON output so it's safe to embed inside a <script> tag.
+// JSON.stringify alone is JS-safe but NOT HTML-script-context safe — a string
+// containing "</script>" or "<!--" can break out of the surrounding tag.
+// Replacing the literal `<`, `>`, `&` with their \u-escaped forms inside the
+// JSON string is still valid JSON/JS and renders identically once parsed.
+function escScript(value) {
+  return JSON.stringify(value)
+    .replace(/</g, '\\u003c')
+    .replace(/>/g, '\\u003e')
+    .replace(/&/g, '\\u0026')
+}
+
+const NEIGHBORHOOD_ACCENT = {
+  'Silver Lake':'#1f6f70','Echo Park':'#c95a2c','Koreatown':'#c9a23a','K-Town':'#c9a23a',
+  'Highland Park':'#7a3b3b','DTLA':'#3a3a3a','West Hollywood':'#b6395f','Venice':'#2c5d8a',
+  'Beverly Hills':'#8a7448','Hollywood':'#7a2530','Sherman Oaks':'#5a6e3a','Santa Monica':'#3a5a6e',
+  'Culver City':'#6e5a3a','Los Feliz':'#4a6e3a','Westwood':'#5a3a6e','West Adams':'#6e3a4a',
+  'Los Angeles':'#3a3a3a',
+};
+
+export function renderInterviewLive({ business, interview }) {
+  const b = business;
+  const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
+  const existing = interview?.qa_json || {};
+
+  // Localize all questions for this business and ship them to the client.
+  const questionsForClient = QUESTIONS.map(q => {
+    const lq = localizeQuestion(q, b);
+    return {
+      id: q.id,
+      section: q.section,
+      sectionLabel: SECTIONS[q.section]?.label || q.section,
+      q: lq.q,
+      placeholder: lq.placeholder,
+      max_chars: q.max_chars,
+      prefill: existing[q.id] || '',
+    };
+  });
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Live Interview · ${esc(b.name)}</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{
+  --ink:#0e0e10;--paper:#f5f5f5;--rule:#262a36;--mute:#7a766f;--accent:${accent};
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;background:var(--ink);color:var(--paper);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased;overflow:hidden}
+button{font-family:inherit;cursor:pointer}
+
+/* ===== STAGE ===== */
+.stage{display:grid;grid-template-columns:340px 1fr;height:100vh;gap:0}
+.left{padding:32px;display:flex;flex-direction:column;align-items:center;justify-content:center;border-right:1px solid var(--rule);background:linear-gradient(180deg,#0a0a0c 0%,#15151a 100%)}
+.right{padding:48px 64px;display:flex;flex-direction:column;justify-content:center;position:relative;overflow-y:auto}
+
+/* ===== AVATAR ===== */
+.avatar{width:200px;height:200px;border-radius:50%;background:#1a1a1f;position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:24px;border:2px solid var(--accent);overflow:visible}
+.avatar svg{width:100%;height:100%}
+.avatar.speaking::before{content:"";position:absolute;inset:-12px;border-radius:50%;border:2px solid var(--accent);opacity:.5;animation:pulse 1.4s infinite}
+.avatar.speaking::after{content:"";position:absolute;inset:-24px;border-radius:50%;border:2px solid var(--accent);opacity:.25;animation:pulse 1.4s .35s infinite}
+@keyframes pulse{0%{transform:scale(.95);opacity:.6}70%{transform:scale(1.08);opacity:0}100%{transform:scale(1.08);opacity:0}}
+
+.avatar-name{font-family:var(--serif);font-size:24px;font-weight:500;letter-spacing:-.01em;color:var(--paper);margin-bottom:4px}
+.avatar-role{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:32px}
+
+/* ===== STATUS DOT ===== */
+.status{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#888;display:flex;align-items:center;gap:8px;margin-top:auto}
+.status .dot{width:8px;height:8px;border-radius:50%;background:#444;display:inline-block}
+.status[data-state="speaking"] .dot{background:var(--accent);box-shadow:0 0 8px var(--accent)}
+.status[data-state="listening"] .dot{background:#d87a7a;box-shadow:0 0 8px #d87a7a;animation:listen 1s infinite}
+.status[data-state="thinking"] .dot{background:#c9a23a}
+@keyframes listen{0%,100%{opacity:1}50%{opacity:.4}}
+
+/* ===== WEBCAM PIP ===== */
+.cam-wrap{position:absolute;top:24px;right:24px;width:240px;background:#000;border:1px solid var(--rule);border-radius:6px;overflow:hidden;z-index:10;box-shadow:0 12px 40px rgba(0,0,0,.5)}
+.cam-wrap video{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;background:#000}
+.cam-wrap .cam-meta{padding:6px 10px;font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:#aaa;display:flex;justify-content:space-between;align-items:center;background:#0a0a0c}
+.cam-wrap .cam-meta b{color:#d87a7a}
+.cam-wrap button{padding:4px 8px;background:var(--accent);color:var(--ink);border:0;font-family:var(--mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;font-weight:600;border-radius:2px}
+.cam-wrap button:hover{background:#fff}
+.cam-wrap.idle{opacity:.5}
+
+/* ===== QUESTION SCREEN ===== */
+.scene{max-width:780px}
+.scene-num{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
+.scene-section{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#888;margin-bottom:24px}
+.scene-q{font-family:var(--serif);font-weight:500;font-size:clamp(28px,3.6vw,46px);line-height:1.2;letter-spacing:-.015em;margin-bottom:32px;min-height:1.6em}
+.scene-a{margin-bottom:24px}
+.scene-a textarea{width:100%;min-height:140px;padding:18px 20px;background:#15151a;border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:4px;font-family:var(--sans);font-size:18px;line-height:1.55;color:var(--paper);resize:vertical}
+.scene-a textarea:focus{outline:none;border-color:var(--accent)}
+.scene-a textarea.live{border-left:3px solid #d87a7a}
+
+.scene-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.scene-controls button{padding:13px 22px;background:var(--accent);color:var(--ink);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:600;border-radius:4px}
+.scene-controls button:hover{background:#fff}
+.scene-controls button.ghost{background:transparent;color:var(--paper);border:1px solid var(--rule)}
+.scene-controls button.ghost:hover{border-color:var(--accent);color:var(--accent);background:transparent}
+.scene-controls button.danger{background:transparent;color:#d87a7a;border:1px solid #d87a7a}
+.scene-controls button:disabled{opacity:.3;cursor:not-allowed}
+.scene-controls .progress{margin-left:auto;font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#888}
+
+.tip{margin-top:32px;padding:14px 18px;background:#15151a;border-left:2px solid var(--accent);font-family:var(--mono);font-size:11px;line-height:1.5;color:#aaa;letter-spacing:.04em}
+
+/* ===== START SCREEN ===== */
+.start{max-width:560px}
+.start h1{font-family:var(--serif);font-size:64px;font-weight:500;letter-spacing:-.02em;line-height:1;margin-bottom:16px}
+.start h1 em{font-style:italic;color:var(--accent)}
+.start .lede{font-size:17px;line-height:1.55;color:#bbb;margin-bottom:32px}
+.start .perm{display:flex;gap:14px;margin-bottom:36px;flex-wrap:wrap}
+.start .perm-pill{padding:8px 14px;background:#15151a;border:1px solid var(--rule);border-radius:99px;font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#bbb}
+.start .perm-pill::before{content:"○ ";color:var(--mute)}
+.start .perm-pill.ok::before{content:"● ";color:var(--accent)}
+.start .perm-pill.deny::before{content:"× ";color:#d87a7a}
+.start button.primary{padding:18px 36px;background:var(--accent);color:var(--ink);border:0;font-family:var(--mono);font-size:13px;letter-spacing:.2em;text-transform:uppercase;font-weight:600;border-radius:4px}
+.start button.primary:hover{background:#fff}
+.start .skip{display:block;margin-top:24px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#666}
+.start .skip a{color:var(--accent)}
+
+/* ===== END SCREEN ===== */
+.end{max-width:680px;text-align:center}
+.end h2{font-family:var(--serif);font-size:64px;font-weight:500;letter-spacing:-.02em;margin-bottom:16px}
+.end h2 em{font-style:italic;color:var(--accent)}
+.end p{font-size:17px;color:#bbb;line-height:1.55;margin-bottom:32px}
+
+/* ===== UTIL ===== */
+.hidden{display:none !important}
+.scene.fade-in{animation:fade .5s ease}
+@keyframes fade{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
+</style>
+</head>
+<body>
+
+<div class="stage">
+
+  <!-- LEFT: avatar + status -->
+  <aside class="left">
+    <div class="avatar" id="avatar">
+      <!-- Stylized SVG interviewer — abstract, editorial, no uncanny-valley face -->
+      <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
+        <defs>
+          <radialGradient id="bg" cx="50%" cy="40%" r="60%">
+            <stop offset="0%" stop-color="#2a2a32"/>
+            <stop offset="100%" stop-color="#0e0e10"/>
+          </radialGradient>
+        </defs>
+        <circle cx="100" cy="100" r="98" fill="url(#bg)"/>
+        <!-- shoulders -->
+        <path d="M 30 200 Q 30 150 100 145 Q 170 150 170 200 Z" fill="${accent}" opacity=".22"/>
+        <!-- head silhouette -->
+        <ellipse cx="100" cy="92" rx="42" ry="48" fill="#15151a" stroke="${accent}" stroke-width="1.5"/>
+        <!-- abstract glasses-line as editorial signifier -->
+        <line x1="68" y1="92" x2="132" y2="92" stroke="${accent}" stroke-width="1.5"/>
+        <circle cx="83" cy="92" r="9" fill="none" stroke="${accent}" stroke-width="1.5"/>
+        <circle cx="117" cy="92" r="9" fill="none" stroke="${accent}" stroke-width="1.5"/>
+        <!-- mouth indicator -->
+        <line id="mouth" x1="92" y1="115" x2="108" y2="115" stroke="${accent}" stroke-width="2.5" stroke-linecap="round"/>
+      </svg>
+    </div>
+    <div class="avatar-name">The Interviewer</div>
+    <div class="avatar-role">LA Salon Directory · Editorial</div>
+
+    <div class="status" id="status" data-state="idle">
+      <span class="dot"></span><span id="statusText">Ready</span>
+    </div>
+  </aside>
+
+  <!-- RIGHT: dynamic content -->
+  <main class="right">
+
+    <!-- Webcam PIP (hidden until camera is up) -->
+    <div class="cam-wrap idle" id="camWrap">
+      <video id="cam" autoplay muted playsinline></video>
+      <div class="cam-meta">
+        <span><b id="recDot">●</b> Live · <span id="camStatus">offline</span></span>
+        <button id="snapBtn" disabled>Snap photo</button>
+      </div>
+    </div>
+
+    <!-- START SCREEN -->
+    <div class="start" id="startScreen">
+      <h1>Sit down. <em>Let's talk shop.</em></h1>
+      <p class="lede">A 5-minute editorial interview about <strong>${esc(b.name)}</strong>. The Interviewer will ask, you talk — your camera and mic do the rest. Pause, edit, or skip any question. We'll save everything to your profile when you're done.</p>
+      <div class="perm">
+        <div class="perm-pill" id="permMic">Microphone</div>
+        <div class="perm-pill" id="permCam">Camera</div>
+        <div class="perm-pill" id="permTts">Voice</div>
+        <div class="perm-pill" id="permStt">Transcribe</div>
+      </div>
+      <button class="primary" id="startBtn">Start the interview →</button>
+      <a class="skip" href="/biz/${esc(b.slug)}/interview">Prefer to type? Use the standard form →</a>
+    </div>
+
+    <!-- QUESTION SCENE -->
+    <div class="scene hidden" id="scene">
+      <div class="scene-num"><span id="qNum">Q 1 of ${QUESTIONS.length}</span></div>
+      <div class="scene-section" id="qSection">${esc(SECTIONS.story.label)}</div>
+      <div class="scene-q" id="qText"></div>
+      <div class="scene-a">
+        <textarea id="aText" rows="5" placeholder="Speak naturally — we'll transcribe. Or type if you prefer."></textarea>
+      </div>
+      <div class="scene-controls">
+        <button id="repeatBtn" class="ghost">↻ Repeat question</button>
+        <button id="micBtn" class="ghost">🎙 Listen again</button>
+        <button id="skipBtn" class="ghost">Skip</button>
+        <button id="nextBtn">Save & next →</button>
+        <span class="progress" id="progressTxt">1 / ${QUESTIONS.length}</span>
+      </div>
+      <div class="tip">Tip: walk the camera around the shop while you answer the neighborhood questions — every "Snap photo" click saves a candidate hero image to your gallery.</div>
+    </div>
+
+    <!-- END SCREEN -->
+    <div class="end hidden" id="endScreen">
+      <h2>That's a <em>wrap.</em></h2>
+      <p id="endStats">Saving your interview…</p>
+      <a class="primary" id="endLink" href="/biz/${esc(b.slug)}" style="display:inline-block;padding:18px 36px;background:var(--accent);color:var(--ink);font-family:var(--mono);font-size:13px;letter-spacing:.2em;text-transform:uppercase;font-weight:600;border-radius:4px;text-decoration:none">View your profile →</a>
+    </div>
+
+  </main>
+</div>
+
+<script>
+// =================================================================
+// State
+// =================================================================
+const SLUG = ${escScript(b.slug)};
+const BIZ_NAME = ${escScript(b.name)};
+const QUESTIONS = ${escScript(questionsForClient)};
+const ANSWERS = Object.fromEntries(QUESTIONS.map(q => [q.id, q.prefill || '']));
+let qIdx = 0;
+let recognition = null;
+let mediaStream = null;
+let speaking = false;
+let listening = false;
+
+// =================================================================
+// Capabilities probe
+// =================================================================
+const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+const ttsOK = 'speechSynthesis' in window;
+const sttOK = !!SR;
+const camOK = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
+
+document.getElementById('permTts').classList.toggle('ok', ttsOK);
+document.getElementById('permTts').classList.toggle('deny', !ttsOK);
+document.getElementById('permStt').classList.toggle('ok', sttOK);
+document.getElementById('permStt').classList.toggle('deny', !sttOK);
+
+// =================================================================
+// Status helpers
+// =================================================================
+const statusEl = document.getElementById('status');
+const statusTxt = document.getElementById('statusText');
+const avatarEl = document.getElementById('avatar');
+function setStatus(state, txt) {
+  statusEl.dataset.state = state;
+  statusTxt.textContent = txt;
+  avatarEl.classList.toggle('speaking', state === 'speaking');
+}
+
+// =================================================================
+// Camera + mic — single getUserMedia call
+// =================================================================
+async function startMedia() {
+  if (!camOK) {
+    document.getElementById('permCam').classList.add('deny');
+    document.getElementById('permMic').classList.add('deny');
+    return false;
+  }
+  try {
+    mediaStream = await navigator.mediaDevices.getUserMedia({
+      video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
+      audio: true,
+    });
+    document.getElementById('cam').srcObject = mediaStream;
+    document.getElementById('camWrap').classList.remove('idle');
+    document.getElementById('camStatus').textContent = 'rolling';
+    document.getElementById('snapBtn').disabled = false;
+    document.getElementById('permCam').classList.add('ok');
+    document.getElementById('permMic').classList.add('ok');
+    return true;
+  } catch (e) {
+    console.warn('media denied', e);
+    document.getElementById('permCam').classList.add('deny');
+    document.getElementById('permMic').classList.add('deny');
+    document.getElementById('camStatus').textContent = 'denied';
+    return false;
+  }
+}
+
+// =================================================================
+// TTS — speak the question
+// =================================================================
+function speak(text) {
+  return new Promise((resolve) => {
+    if (!ttsOK) return resolve();
+    speaking = true;
+    setStatus('speaking', 'Asking…');
+    // Animate the mouth line a tiny bit while speaking
+    const mouth = document.getElementById('mouth');
+    const mouthInterval = setInterval(() => {
+      const w = 8 + Math.random() * 12;
+      mouth.setAttribute('x1', String(100 - w / 2));
+      mouth.setAttribute('x2', String(100 + w / 2));
+    }, 110);
+    const u = new SpeechSynthesisUtterance(text);
+    u.rate = 0.95; u.pitch = 1.0; u.volume = 1;
+    // Prefer a higher-quality voice if one is available
+    const voices = speechSynthesis.getVoices();
+    const preferred = voices.find(v => /Samantha|Karen|Alex|Daniel|Aria|Jenny|Google US English/i.test(v.name));
+    if (preferred) u.voice = preferred;
+    u.onend = () => {
+      clearInterval(mouthInterval);
+      mouth.setAttribute('x1', '92'); mouth.setAttribute('x2', '108');
+      speaking = false;
+      resolve();
+    };
+    u.onerror = () => { clearInterval(mouthInterval); speaking = false; resolve(); };
+    speechSynthesis.cancel();
+    speechSynthesis.speak(u);
+  });
+}
+
+// =================================================================
+// STT — listen for the answer, append to textarea live
+// =================================================================
+function startListening() {
+  if (!sttOK) return;
+  if (recognition) try { recognition.abort(); } catch {}
+  recognition = new SR();
+  recognition.continuous = true;
+  recognition.interimResults = true;
+  recognition.lang = 'en-US';
+  const ta = document.getElementById('aText');
+  ta.classList.add('live');
+  let baseline = ta.value;
+  if (baseline && !baseline.endsWith(' ')) baseline += ' ';
+  let finalText = baseline;
+  recognition.onresult = (ev) => {
+    let interim = '';
+    for (let i = ev.resultIndex; i < ev.results.length; i++) {
+      const t = ev.results[i][0].transcript;
+      if (ev.results[i].isFinal) finalText += t + ' ';
+      else interim += t;
+    }
+    ta.value = finalText + interim;
+  };
+  recognition.onerror = (e) => { console.warn('stt error', e.error); };
+  recognition.onend = () => {
+    ta.classList.remove('live');
+    listening = false;
+    if (statusEl.dataset.state === 'listening') setStatus('idle', 'Edit if you like');
+  };
+  try {
+    recognition.start();
+    listening = true;
+    setStatus('listening', 'Listening…');
+  } catch (e) { console.warn('stt start failed', e); }
+}
+
+function stopListening() {
+  if (recognition && listening) try { recognition.stop(); } catch {}
+}
+
+// =================================================================
+// Snap photo — capture canvas frame from webcam, POST to server
+// =================================================================
+document.getElementById('snapBtn').addEventListener('click', async () => {
+  const video = document.getElementById('cam');
+  if (!video.videoWidth) return;
+  const canvas = document.createElement('canvas');
+  canvas.width = video.videoWidth; canvas.height = video.videoHeight;
+  canvas.getContext('2d').drawImage(video, 0, 0);
+  const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
+  const btn = document.getElementById('snapBtn');
+  btn.disabled = true; btn.textContent = 'saving…';
+  try {
+    const res = await fetch('/api/biz/photo/upload', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ slug: SLUG, dataUrl, kind: 'interview-frame' }),
+    });
+    const r = await res.json();
+    btn.textContent = r.ok ? 'saved ✓' : 'failed';
+    setTimeout(() => { btn.textContent = 'Snap photo'; btn.disabled = false; }, 1400);
+  } catch (e) {
+    btn.textContent = 'failed'; setTimeout(() => { btn.textContent = 'Snap photo'; btn.disabled = false; }, 1400);
+  }
+});
+
+// =================================================================
+// Question flow
+// =================================================================
+async function showQuestion(idx) {
+  if (idx >= QUESTIONS.length) return finish();
+  qIdx = idx;
+  const q = QUESTIONS[idx];
+  document.getElementById('qNum').textContent = 'Q ' + (idx + 1) + ' of ' + QUESTIONS.length;
+  document.getElementById('qSection').textContent = q.sectionLabel;
+  document.getElementById('qText').textContent = q.q;
+  document.getElementById('progressTxt').textContent = (idx + 1) + ' / ' + QUESTIONS.length;
+  const ta = document.getElementById('aText');
+  ta.value = ANSWERS[q.id] || '';
+  ta.placeholder = q.placeholder;
+  document.getElementById('scene').classList.remove('fade-in'); void document.getElementById('scene').offsetWidth;
+  document.getElementById('scene').classList.add('fade-in');
+
+  await speak(q.q);
+  // Auto-listen unless user already typed something
+  if (!ta.value.trim()) startListening();
+}
+
+function captureCurrent() {
+  const q = QUESTIONS[qIdx];
+  const ta = document.getElementById('aText');
+  ANSWERS[q.id] = (ta.value || '').trim();
+}
+
+document.getElementById('nextBtn').addEventListener('click', () => {
+  stopListening();
+  captureCurrent();
+  showQuestion(qIdx + 1);
+});
+document.getElementById('skipBtn').addEventListener('click', () => {
+  stopListening();
+  ANSWERS[QUESTIONS[qIdx].id] = '';
+  showQuestion(qIdx + 1);
+});
+document.getElementById('repeatBtn').addEventListener('click', async () => {
+  stopListening();
+  await speak(QUESTIONS[qIdx].q);
+});
+document.getElementById('micBtn').addEventListener('click', () => {
+  if (listening) stopListening();
+  else startListening();
+});
+
+// =================================================================
+// Finish — save
+// =================================================================
+async function finish() {
+  document.getElementById('scene').classList.add('hidden');
+  document.getElementById('endScreen').classList.remove('hidden');
+  setStatus('thinking', 'Saving…');
+  // Stop media
+  if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
+  if (recognition) try { recognition.abort(); } catch {}
+
+  const filled = Object.fromEntries(Object.entries(ANSWERS).filter(([_, v]) => v && v.trim()));
+  try {
+    const res = await fetch('/api/biz/interview', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ slug: SLUG, qa: filled, recorded_by: 'Live AI Interviewer' }),
+    });
+    const r = await res.json();
+    if (r.ok) {
+      const stats = document.getElementById('endStats');
+      stats.textContent = '';
+      // Build via textContent only — never set innerHTML on user-derived data.
+      stats.textContent = \`Saved \${r.fields} answers across 4 sections. Your profile is live.\`;
+      setStatus('idle', 'Done');
+    } else {
+      document.getElementById('endStats').textContent = 'Save failed: ' + (r.error || 'unknown');
+    }
+  } catch (e) {
+    document.getElementById('endStats').textContent = 'Save failed: ' + e.message;
+  }
+}
+
+// =================================================================
+// Boot
+// =================================================================
+document.getElementById('startBtn').addEventListener('click', async () => {
+  const btn = document.getElementById('startBtn');
+  btn.disabled = true; btn.textContent = 'Starting…';
+  await startMedia();
+  // Force voices to load (Chrome needs a kick)
+  if (ttsOK) speechSynthesis.getVoices();
+  document.getElementById('startScreen').classList.add('hidden');
+  document.getElementById('scene').classList.remove('hidden');
+  await speak(\`Hi. I'm here to interview you about \${BIZ_NAME}. We'll go through about \${QUESTIONS.length} questions. Take your time, edit anything I get wrong, and feel free to walk the camera around the shop while you answer. Ready?\`);
+  showQuestion(0);
+});
+
+// Kick voice loading
+if (ttsOK) speechSynthesis.onvoiceschanged = () => {};
+</script>
+</body>
+</html>`;
+}
diff --git a/src/render/map.js b/src/render/map.js
new file mode 100644
index 0000000..36bcfac
--- /dev/null
+++ b/src/render/map.js
@@ -0,0 +1,546 @@
+// /map — The Whole Map. All geocoded businesses on a Leaflet + MarkerCluster canvas.
+// Renderer only: no DB calls here. Server passes pre-computed stats.
+// Client fetches /api/map.json and builds all markers in-browser.
+
+import { esc } from '../lib/escape.js';
+
+export function renderMap({ stats = {} } = {}) {
+  const geocoded = stats.geocoded || 0;
+  const licensed = stats.licensed || 0;
+  const cities    = stats.cities   || 0;
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>The Whole Map · LA Salon &amp; Business Directory</title>
+<meta name="description" content="Every geocoded shop in the LA Salon Directory — salons, barbers, spas, restaurants, and more — pinned on a single map.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin>
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin>
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:#5a6e3a;--gold:#b8945a;
+  --sidebar:240px;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased;height:100%}
+a{color:inherit;text-decoration:none}
+
+/* ── TOPBAR ─────────────────────────────────────────── */
+.topbar{
+  padding:16px 28px;display:flex;align-items:center;
+  justify-content:space-between;border-bottom:1px solid var(--rule);
+  position:sticky;top:0;background:var(--paper);z-index:500;
+}
+.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.brand a{color:inherit}
+.topbar-crumb{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-left:12px}
+
+/* ── HERO STRIP ─────────────────────────────────────── */
+.hero-strip{
+  padding:32px 28px 24px;border-bottom:1px solid var(--rule);
+  background:var(--paper);
+}
+.hero-eyebrow{
+  font-family:var(--mono);font-size:10px;letter-spacing:.22em;
+  text-transform:uppercase;color:var(--accent);margin-bottom:10px;
+}
+.hero-strip h1{
+  font-family:var(--serif);font-weight:500;
+  font-size:clamp(32px,5vw,64px);line-height:.97;
+  letter-spacing:-.02em;margin-bottom:6px;
+}
+.hero-strip h1 em{font-style:italic;color:var(--accent)}
+.hero-sub{
+  font-family:var(--serif);font-style:italic;
+  font-size:clamp(15px,2vw,20px);color:var(--mute);
+  margin-bottom:12px;
+}
+.hero-stats{
+  display:flex;gap:32px;font-family:var(--mono);
+  font-size:10px;letter-spacing:.16em;text-transform:uppercase;
+  color:var(--mute);flex-wrap:wrap;
+}
+.hero-stats b{
+  display:inline;font-family:var(--serif);font-style:italic;
+  font-size:18px;letter-spacing:-.01em;color:var(--ink);
+  font-weight:500;text-transform:none;margin-right:4px;
+}
+
+/* ── MAP LAYOUT ─────────────────────────────────────── */
+.map-shell{
+  display:flex;
+  height:calc(100vh - var(--topbar-h, 56px) - var(--hero-h, 100px));
+  min-height:80vh;
+  position:relative;
+}
+
+/* ── SIDEBAR ─────────────────────────────────────────── */
+.sidebar{
+  width:var(--sidebar);flex-shrink:0;
+  border-right:1px solid var(--rule);
+  background:var(--paper);
+  overflow-y:auto;padding:20px 16px;
+  z-index:400;
+}
+.filter-label{
+  font-family:var(--mono);font-size:9px;letter-spacing:.22em;
+  text-transform:uppercase;color:var(--accent);
+  margin:0 0 6px;display:block;
+}
+.filter-group{margin-bottom:20px}
+.sidebar select,.sidebar input[type=text]{
+  width:100%;padding:8px 10px;
+  border:1px solid var(--rule);background:#fff;
+  font-family:var(--sans);font-size:13px;color:var(--ink);
+  border-radius:2px;outline:none;
+  transition:border-color 150ms;
+}
+.sidebar select:focus,.sidebar input[type=text]:focus{border-color:var(--accent)}
+.sidebar select[multiple]{height:140px}
+
+.toggle-label{
+  display:flex;align-items:center;gap:8px;cursor:pointer;
+  font-family:var(--sans);font-size:13px;color:var(--ink);
+}
+.toggle-label input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
+
+.sidebar-count{
+  font-family:var(--mono);font-size:10px;letter-spacing:.12em;
+  color:var(--mute);text-transform:uppercase;margin-top:6px;
+}
+
+.filter-reset{
+  margin-top:4px;font-family:var(--mono);font-size:9px;
+  letter-spacing:.16em;text-transform:uppercase;color:var(--mute);
+  background:none;border:none;cursor:pointer;padding:0;
+}
+.filter-reset:hover{color:var(--ink)}
+
+/* Sidebar collapse on mobile */
+@media (max-width:740px){
+  .sidebar{
+    width:100%;border-right:none;border-bottom:1px solid var(--rule);
+    padding:12px 16px;
+  }
+  .map-shell{flex-direction:column}
+  .sidebar-filters{display:none}
+  .sidebar-filters.open{display:block}
+  .sidebar-toggle{
+    display:flex;align-items:center;justify-content:space-between;
+    cursor:pointer;
+  }
+  .sidebar-toggle-label{
+    font-family:var(--mono);font-size:10px;letter-spacing:.18em;
+    text-transform:uppercase;color:var(--accent);
+  }
+  .sidebar-toggle-icon{font-size:18px;color:var(--mute)}
+}
+@media (min-width:741px){
+  .sidebar-toggle{display:none}
+  .sidebar-filters{display:block}
+}
+
+/* ── MAP ──────────────────────────────────────────────── */
+#map{flex:1;min-width:0;z-index:100}
+
+/* ── BOTTOM SHEET ────────────────────────────────────── */
+#sheet{
+  position:fixed;bottom:-400px;left:50%;transform:translateX(-50%);
+  width:min(520px,calc(100vw - 40px));
+  background:var(--paper);border-top:3px solid var(--accent);
+  border-radius:4px 4px 0 0;box-shadow:0 -4px 32px rgba(0,0,0,.14);
+  z-index:600;padding:24px 24px 28px;
+  transition:bottom 300ms cubic-bezier(.3,0,.2,1);
+}
+#sheet.open{bottom:0}
+#sheet-close{
+  position:absolute;top:12px;right:16px;
+  background:none;border:none;cursor:pointer;
+  font-size:20px;color:var(--mute);line-height:1;
+}
+#sheet-close:hover{color:var(--ink)}
+#sheet-cat{
+  font-family:var(--mono);font-size:9px;letter-spacing:.22em;
+  text-transform:uppercase;color:var(--accent);margin-bottom:6px;
+}
+#sheet-name{
+  font-family:var(--serif);font-size:clamp(22px,4vw,32px);
+  font-weight:500;letter-spacing:-.01em;line-height:1.1;margin-bottom:6px;
+}
+#sheet-addr{
+  font-family:var(--mono);font-size:10px;letter-spacing:.08em;
+  color:var(--mute);text-transform:uppercase;margin-bottom:14px;
+}
+.sheet-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}
+.sheet-btn{
+  display:inline-block;padding:10px 16px;
+  font-family:var(--mono);font-size:10px;letter-spacing:.18em;
+  text-transform:uppercase;border:1px solid var(--ink);
+  background:none;color:var(--ink);cursor:pointer;
+  transition:background 150ms,color 150ms;text-decoration:none;
+}
+.sheet-btn:hover,.sheet-btn:focus{background:var(--ink);color:var(--paper)}
+.sheet-btn.gold{border-color:var(--gold);color:var(--gold)}
+.sheet-btn.gold:hover{background:var(--gold);color:var(--ink)}
+
+/* ── LOADING OVERLAY ─────────────────────────────────── */
+#loading{
+  position:absolute;inset:0;display:flex;align-items:center;
+  justify-content:center;background:rgba(245,245,245,.85);
+  z-index:300;font-family:var(--mono);font-size:11px;
+  letter-spacing:.2em;text-transform:uppercase;color:var(--mute);
+}
+
+/* ── CUSTOM PINS ─────────────────────────────────────── */
+.pin-dot{
+  width:10px;height:10px;border-radius:50%;
+  border:2px solid rgba(255,255,255,.7);
+  box-shadow:0 1px 4px rgba(0,0,0,.35);
+}
+.pin-gold  .pin-dot{background:var(--gold)}
+.pin-olive .pin-dot{background:var(--accent)}
+.pin-ink   .pin-dot{background:var(--ink)}
+
+/* ── LEAFLET CLUSTER OVERRIDE ────────────────────────── */
+.marker-cluster-small,.marker-cluster-medium,.marker-cluster-large{
+  background:rgba(90,110,58,.2)!important;
+}
+.marker-cluster-small div,.marker-cluster-medium div,.marker-cluster-large div{
+  background:rgba(90,110,58,.7)!important;
+  color:#fff!important;font-family:var(--mono)!important;
+  font-size:11px!important;font-weight:600!important;
+}
+</style>
+</head>
+<body>
+
+<header class="topbar" id="topbar">
+  <div class="brand">
+    <a href="/">LA <em>Salon</em> Directory</a>
+    <span class="topbar-crumb">/ The Whole Map</span>
+  </div>
+</header>
+
+<div class="hero-strip" id="hero-strip">
+  <div class="hero-eyebrow">Full Firehose · All Counties</div>
+  <h1>The Whole <em>Map.</em></h1>
+  <p class="hero-sub">Every shop. Every block. Pinned.</p>
+  <div class="hero-stats">
+    <div><b>${esc(String(geocoded.toLocaleString()))}</b> geocoded</div>
+    <div><b>${esc(String(licensed.toLocaleString()))}</b> state-licensed</div>
+    <div><b>${esc(String(cities.toLocaleString()))}</b> cities</div>
+  </div>
+</div>
+
+<div class="map-shell" id="map-shell">
+  <!-- SIDEBAR -->
+  <aside class="sidebar" id="sidebar">
+    <!-- Mobile toggle header -->
+    <div class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()">
+      <span class="sidebar-toggle-label">Filter</span>
+      <span class="sidebar-toggle-icon" id="toggle-icon">+</span>
+    </div>
+
+    <div class="sidebar-filters" id="sidebar-filters">
+
+      <div class="filter-group" style="margin-top:12px">
+        <label class="toggle-label">
+          <input type="checkbox" id="filter-licensed">
+          State-licensed only
+        </label>
+      </div>
+
+      <div class="filter-group">
+        <span class="filter-label">Category</span>
+        <select id="filter-cat" multiple title="Hold Ctrl/Cmd to select multiple">
+          <option value="">All categories</option>
+          <option value="salon">Salon / Hair</option>
+          <option value="barbershop">Barber &amp; Beard</option>
+          <option value="beard">Beard</option>
+          <option value="spa">Spa</option>
+          <option value="nail">Nails</option>
+          <option value="nail_salon">Nail Salon</option>
+          <option value="lash">Lash</option>
+          <option value="brow">Brow</option>
+          <option value="restaurant">Restaurant</option>
+          <option value="cafe">Café</option>
+          <option value="retail">Retail</option>
+          <option value="health">Health</option>
+        </select>
+      </div>
+
+      <div class="filter-group">
+        <span class="filter-label">City</span>
+        <select id="filter-city">
+          <option value="">All cities</option>
+        </select>
+      </div>
+
+      <div class="filter-group">
+        <span class="filter-label">Search name</span>
+        <input type="text" id="filter-name" placeholder="Fuzzy match…" autocomplete="off" spellcheck="false">
+      </div>
+
+      <div class="sidebar-count" id="sidebar-count">Loading…</div>
+      <button class="filter-reset" id="filter-reset" onclick="resetFilters()">Reset filters</button>
+    </div>
+  </aside>
+
+  <!-- MAP CANVAS -->
+  <div id="map">
+    <div id="loading">Loading pins…</div>
+  </div>
+</div>
+
+<!-- BOTTOM SHEET -->
+<div id="sheet" role="dialog" aria-modal="true" aria-label="Business details">
+  <button id="sheet-close" aria-label="Close" onclick="closeSheet()">×</button>
+  <div id="sheet-cat"></div>
+  <div id="sheet-name"></div>
+  <div id="sheet-addr"></div>
+  <div class="sheet-actions" id="sheet-actions"></div>
+</div>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
+<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin></script>
+<script>
+(function(){
+'use strict';
+
+// ── Map init ──────────────────────────────────────────────
+function resizeShell(){
+  var tb = document.getElementById('topbar');
+  var hs = document.getElementById('hero-strip');
+  var sh = document.getElementById('map-shell');
+  var tbH = tb ? tb.offsetHeight : 56;
+  var hsH = hs ? hs.offsetHeight : 100;
+  var avail = window.innerHeight - tbH - hsH;
+  sh.style.height = Math.max(avail, 400) + 'px';
+}
+resizeShell();
+window.addEventListener('resize', resizeShell);
+
+var map = L.map('map', {
+  center: [34.05, -118.25],
+  zoom: 10,
+  zoomControl: true,
+  attributionControl: true,
+});
+L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+  maxZoom: 19,
+}).addTo(map);
+
+// ── State ─────────────────────────────────────────────────
+var allData = [];
+var clusters = L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 60 });
+map.addLayer(clusters);
+
+// ── Pin factory ───────────────────────────────────────────
+var SALON_CATS = new Set(['salon','barbershop','beard','hair','nail','nail_salon','lash','brow','spa','blowout','waxing']);
+
+function pinClass(row){
+  if (row.lic) return 'pin-gold';
+  if (SALON_CATS.has(row.category)) return 'pin-olive';
+  return 'pin-ink';
+}
+
+function makeIcon(cls){
+  return L.divIcon({
+    className: cls,
+    html: '<div class="pin-dot"></div>',
+    iconSize: [10,10],
+    iconAnchor: [5,5],
+  });
+}
+
+// ── Sheet ─────────────────────────────────────────────────
+var sheet = document.getElementById('sheet');
+
+function openSheet(row){
+  var catEl   = document.getElementById('sheet-cat');
+  var nameEl  = document.getElementById('sheet-name');
+  var addrEl  = document.getElementById('sheet-addr');
+  var actsEl  = document.getElementById('sheet-actions');
+
+  catEl.textContent  = row.category || '';
+  nameEl.textContent = row.name || '';
+  addrEl.textContent = [row.address, row.city].filter(Boolean).join(' · ');
+
+  // Clear previous action buttons
+  while (actsEl.firstChild) actsEl.removeChild(actsEl.firstChild);
+
+  if (row.slug) {
+    var profBtn = document.createElement('a');
+    profBtn.href = '/biz/' + encodeURIComponent(row.slug);
+    profBtn.className = 'sheet-btn';
+    profBtn.textContent = 'View profile →';
+    actsEl.appendChild(profBtn);
+  }
+
+  if (row.lic) {
+    var licBtn = document.createElement('a');
+    licBtn.href = 'https://search.dca.ca.gov/results?lic=' + encodeURIComponent(row.lic);
+    licBtn.target = '_blank';
+    licBtn.rel = 'noopener noreferrer';
+    licBtn.className = 'sheet-btn gold';
+    licBtn.textContent = 'Verify license →';
+    actsEl.appendChild(licBtn);
+  }
+
+  if (row.phone) {
+    var phBtn = document.createElement('a');
+    phBtn.href = 'tel:' + row.phone;
+    phBtn.className = 'sheet-btn';
+    phBtn.textContent = row.phone;
+    actsEl.appendChild(phBtn);
+  }
+
+  sheet.classList.add('open');
+}
+
+function closeSheet(){ sheet.classList.remove('open'); }
+window.closeSheet = closeSheet;
+
+document.addEventListener('keydown', function(e){
+  if (e.key === 'Escape') closeSheet();
+});
+
+// ── Filtering ─────────────────────────────────────────────
+var filterLicensed = document.getElementById('filter-licensed');
+var filterCat      = document.getElementById('filter-cat');
+var filterCity     = document.getElementById('filter-city');
+var filterName     = document.getElementById('filter-name');
+var sidebarCount   = document.getElementById('sidebar-count');
+
+function getSelectedCats(){
+  var sel = [];
+  for (var i = 0; i < filterCat.options.length; i++){
+    if (filterCat.options[i].selected && filterCat.options[i].value)
+      sel.push(filterCat.options[i].value);
+  }
+  return sel;
+}
+
+function fuzzy(haystack, needle){
+  if (!needle) return true;
+  var h = haystack.toLowerCase();
+  var n = needle.toLowerCase();
+  var hi = 0;
+  for (var ni = 0; ni < n.length; ni++){
+    hi = h.indexOf(n[ni], hi);
+    if (hi === -1) return false;
+    hi++;
+  }
+  return true;
+}
+
+var renderTimer = null;
+function scheduleRender(){
+  clearTimeout(renderTimer);
+  renderTimer = setTimeout(renderMarkers, 80);
+}
+
+function renderMarkers(){
+  clusters.clearLayers();
+  var onlyLic  = filterLicensed.checked;
+  var selCats  = getSelectedCats();
+  var selCity  = filterCity.value;
+  var nameQ    = filterName.value.trim();
+  var count    = 0;
+  var markers  = [];
+
+  for (var i = 0; i < allData.length; i++){
+    var row = allData[i];
+    if (onlyLic  && !row.lic)               continue;
+    if (selCats.length && selCats.indexOf(row.category) === -1) continue;
+    if (selCity  && row.city !== selCity)   continue;
+    if (nameQ    && !fuzzy(row.name, nameQ)) continue;
+
+    var m = L.marker([row.lat, row.lng], { icon: makeIcon(pinClass(row)) });
+    (function(r){ m.on('click', function(){ openSheet(r); }); })(row);
+    markers.push(m);
+    count++;
+  }
+
+  clusters.addLayers(markers);
+  sidebarCount.textContent = count.toLocaleString() + ' pin' + (count !== 1 ? 's' : '') + ' shown';
+}
+
+function resetFilters(){
+  filterLicensed.checked = false;
+  for (var i = 0; i < filterCat.options.length; i++) filterCat.options[i].selected = false;
+  filterCity.value = '';
+  filterName.value = '';
+  renderMarkers();
+}
+window.resetFilters = resetFilters;
+
+filterLicensed.addEventListener('change', scheduleRender);
+filterCat.addEventListener('change', scheduleRender);
+filterCity.addEventListener('change', scheduleRender);
+filterName.addEventListener('input', scheduleRender);
+
+// ── Load data ─────────────────────────────────────────────
+fetch('/api/map.json')
+  .then(function(r){ return r.json(); })
+  .then(function(data){
+    allData = data;
+
+    // Populate city dropdown (dedupe + sort by frequency)
+    var cityFreq = {};
+    for (var i = 0; i < data.length; i++){
+      var c = data[i].city;
+      if (c) cityFreq[c] = (cityFreq[c] || 0) + 1;
+    }
+    var cities = Object.keys(cityFreq).sort(function(a,b){ return cityFreq[b] - cityFreq[a]; }).slice(0, 30);
+    var sel = filterCity;
+    for (var ci = 0; ci < cities.length; ci++){
+      var opt = document.createElement('option');
+      opt.value = cities[ci];
+      opt.textContent = cities[ci] + ' (' + cityFreq[cities[ci]] + ')';
+      sel.appendChild(opt);
+    }
+
+    // Remove loading overlay
+    var loadEl = document.getElementById('loading');
+    if (loadEl) loadEl.parentNode.removeChild(loadEl);
+
+    renderMarkers();
+  })
+  .catch(function(e){
+    var loadEl = document.getElementById('loading');
+    if (loadEl) loadEl.textContent = 'Error loading data — try refreshing.';
+    console.error('map.json fetch failed', e);
+  });
+
+// ── Mobile sidebar toggle ─────────────────────────────────
+function toggleSidebar(){
+  var f = document.getElementById('sidebar-filters');
+  var icon = document.getElementById('toggle-icon');
+  if (f.classList.contains('open')){
+    f.classList.remove('open');
+    icon.textContent = '+';
+  } else {
+    f.classList.add('open');
+    icon.textContent = '−';
+  }
+}
+window.toggleSidebar = toggleSidebar;
+
+})();
+</script>
+</body>
+</html>`;
+}
diff --git a/src/render/near.js b/src/render/near.js
new file mode 100644
index 0000000..bcc6419
--- /dev/null
+++ b/src/render/near.js
@@ -0,0 +1,287 @@
+// /near — universal drive-mode map. Same UX as /corridor/:slug/map but
+// unbounded: pulls all geocoded shops (capped to 5,000 nearest the viewport)
+// and recomputes "5 nearest" as the user drives. Designed for ongoing daily
+// use; the /corridor/:slug/map pages are editorial drive-throughs whereas
+// this one is the app surface.
+//
+// Server hands the client a tiny payload (just slug+name+lat+lng+lic) for
+// performance — full panel data is fetched via /api/biz/:slug/panel on
+// pin-click.
+
+import { esc, escJsonLd } from '../lib/escape.js';
+
+export function renderNear({ businesses = [], totalGeocoded = 0 } = {}) {
+  // The payload ships ONLY the fields the map + nearby-list need. Detail
+  // panel fetches the rest async on pin-click.
+  const payload = businesses.map(b => ({
+    slug: b.slug,
+    name: b.name,
+    cat:  b.category || '',
+    hood: b.neighborhood || '',
+    lat:  Number(b.latitude),
+    lng:  Number(b.longitude),
+    lic:  b.source_data_json?.source === 'dca_bbcb' ? 1 : 0,
+    dba:  b.source_data_json?.dba_name || '',
+  }));
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
+<title>Near Me · Drive Map · LA Salon Directory</title>
+<meta name="description" content="Live map of every state-licensed salon, barber, and shop near you across LA + Orange + Riverside + San Bernardino + Ventura counties. Updates as you drive.">
+<link rel="preconnect" href="https://unpkg.com" crossorigin>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="">
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="">
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:#5a6e3a;--gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;background:var(--ink);color:var(--ink);font-family:var(--sans);overflow:hidden}
+a{color:inherit;text-decoration:none}
+
+.topbar{position:absolute;top:0;left:0;right:0;z-index:1000;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--rule)}
+.brand{font-family:var(--serif);font-size:16px;font-weight:500;letter-spacing:-.005em}
+.brand em{color:var(--accent);font-style:italic}
+.brand a{color:var(--ink)}
+.title{font-family:var(--serif);font-size:16px;font-style:italic;text-align:center;flex:1}
+.actions{display:flex;gap:8px;align-items:center}
+.btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);cursor:pointer;transition:all 150ms ease;border-radius:0}
+.btn.is-on{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.btn:hover{border-color:var(--accent);color:var(--accent)}
+.btn.is-on:hover{color:var(--paper)}
+
+#map{position:absolute;top:50px;left:0;right:0;bottom:0;background:#222}
+
+.me-dot{width:18px;height:18px;border-radius:50%;background:#1a73e8;border:3px solid #fff;box-shadow:0 0 0 4px rgba(26,115,232,.25);animation:mePulse 2.4s ease-out infinite}
+@keyframes mePulse{0%,100%{box-shadow:0 0 0 4px rgba(26,115,232,.18)}50%{box-shadow:0 0 0 14px rgba(26,115,232,.04)}}
+
+.pin{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35)}
+.pin-licensed{background:var(--gold)}
+.pin-salon{background:var(--accent)}
+.pin-other{background:#3a3a3a}
+
+.nearby{position:absolute;left:14px;bottom:14px;z-index:1000;width:min(360px,calc(100vw - 28px));max-height:42vh;overflow-y:auto;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border:1px solid var(--rule);box-shadow:0 12px 32px rgba(0,0,0,.18)}
+.nearby-head{padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:baseline;background:rgba(0,0,0,.02)}
+.nearby-head h2{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em}
+.nearby-head .meta{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
+.nearby-row{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px dotted var(--rule);cursor:pointer;transition:background 150ms ease}
+.nearby-row:hover{background:#fff}
+.nearby-row:last-child{border-bottom:none}
+.n-info{flex:1;min-width:0}
+.n-name{font-family:var(--serif);font-size:15px;font-weight:500;line-height:1.2;display:flex;align-items:center;gap:6px}
+.n-name .lic-mark{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--gold)}
+.n-where{font-family:var(--mono);font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.04em;line-height:1.4;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.n-dist{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent);white-space:nowrap}
+
+.detail{position:absolute;top:50px;right:0;bottom:0;z-index:1000;width:min(380px,90vw);background:var(--paper);border-left:1px solid var(--rule);box-shadow:-8px 0 24px rgba(0,0,0,.18);overflow-y:auto;transform:translateX(100%);transition:transform 280ms cubic-bezier(.2,.7,.3,1)}
+.detail.open{transform:translateX(0)}
+.d-close{position:absolute;top:14px;right:14px;width:32px;height:32px;border:1px solid var(--rule);background:#fff;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;font-family:var(--mono)}
+.d-close:hover{border-color:var(--accent);color:var(--accent)}
+.d-body{padding:32px 24px}
+.d-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
+.d-title{font-family:var(--serif);font-size:32px;font-weight:500;line-height:1.05;letter-spacing:-.01em;margin-bottom:16px}
+.d-meta{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.7;margin-bottom:18px}
+.d-meta .row{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px dotted var(--rule)}
+.d-meta .row strong{color:var(--ink);font-weight:500}
+.d-actions{display:flex;flex-direction:column;gap:8px;margin-top:18px}
+.d-btn{display:block;padding:12px 16px;text-align:center;border:1px solid var(--rule);background:#fff;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease}
+.d-btn.primary{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.d-btn:hover{border-color:var(--accent);color:var(--accent)}
+.d-btn.primary:hover{background:var(--ink);border-color:var(--ink);color:var(--paper)}
+
+.empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#888;font-family:var(--serif);font-size:24px;font-style:italic;pointer-events:none}
+
+@media (max-width:640px){
+  .title{display:none}
+  .nearby{max-height:35vh;width:calc(100vw - 28px)}
+}
+</style>
+</head>
+<body>
+
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em></a></div>
+  <div class="title"><em>Near</em> me · drive map</div>
+  <div class="actions">
+    <button class="btn" id="follow-toggle" type="button">📍 Follow</button>
+    <a class="btn" href="/">Home</a>
+  </div>
+</header>
+
+<div id="map"></div>
+
+<aside class="nearby" id="nearby" aria-live="polite">
+  <div class="nearby-head">
+    <h2>Up ahead</h2>
+    <span class="meta" id="nearby-status">${totalGeocoded.toLocaleString()} shops · tap follow</span>
+  </div>
+  <div id="nearby-list">
+    <div style="padding:14px 18px;font-family:var(--mono);font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">Tap "Follow" to share your location and see what's around you.</div>
+  </div>
+</aside>
+
+<aside class="detail" id="detail" aria-hidden="true">
+  <button class="d-close" id="detail-close" type="button">×</button>
+  <div class="d-body" id="detail-body"></div>
+</aside>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
+<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin=""></script>
+<script>
+const SHOPS = ${escJsonLd(JSON.stringify(payload))};
+const center = [34.05, -118.25]; // Downtown LA — fallback when geolocation off
+
+const map = L.map('map', { zoomControl: true, attributionControl: true })
+  .setView(center, 12);
+L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+  maxZoom: 19,
+  attribution: '© OpenStreetMap'
+}).addTo(map);
+
+function pinFor(s) {
+  const cls = s.lic ? 'pin-licensed' : (['salon','barbershop','beard','nail','lash','brow','spa'].includes(s.cat) ? 'pin-salon' : 'pin-other');
+  return L.divIcon({
+    className: '',
+    html: '<div class="pin ' + cls + '" aria-label="' + (s.name || '').replace(/"/g, '&quot;') + '"></div>',
+    iconSize: [14, 14],
+    iconAnchor: [7, 7],
+  });
+}
+const cluster = L.markerClusterGroup({
+  showCoverageOnHover: false,
+  spiderfyOnMaxZoom: true,
+  maxClusterRadius: 60,
+  chunkedLoading: true,  // perf — incremental marker insertion
+});
+for (const s of SHOPS) {
+  const m = L.marker([s.lat, s.lng], { icon: pinFor(s) });
+  m.shop = s;
+  m.on('click', () => openDetail(s));
+  cluster.addLayer(m);
+}
+map.addLayer(cluster);
+
+const detailEl = document.getElementById('detail');
+const detailBody = document.getElementById('detail-body');
+function openDetail(s) {
+  const apple  = 'https://maps.apple.com/?daddr=' + s.lat + ',' + s.lng;
+  const google = 'https://www.google.com/maps/dir/?api=1&destination=' + s.lat + ',' + s.lng;
+  detailBody.innerHTML = ''
+    + '<div class="d-eyebrow">' + escapeHtml(s.cat || 'business') + (s.lic ? ' · ◆ State-Licensed' : '') + '</div>'
+    + '<h1 class="d-title">' + escapeHtml(s.name) + '</h1>'
+    + '<div class="d-meta">'
+      + (s.hood ? '<div class="row"><span>Neighborhood</span><strong>' + escapeHtml(s.hood) + '</strong></div>' : '')
+      + '<div class="row"><span>Distance</span><strong id="d-distance">—</strong></div>'
+    + '</div>'
+    + '<div id="d-sources" style="font-family:var(--mono);font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">loading…</div>'
+    + '<div class="d-actions">'
+      + '<a class="d-btn primary" href="/biz/' + encodeURIComponent(s.slug) + '">View Profile</a>'
+      + '<a class="d-btn" href="' + apple + '" target="_blank" rel="noopener">Apple Maps · Drive Here</a>'
+      + '<a class="d-btn" href="' + google + '" target="_blank" rel="noopener">Google Maps · Drive Here</a>'
+      + '<a class="d-btn" href="/biz/' + encodeURIComponent(s.slug) + '/links" style="margin-top:14px">+ Add Reddit / Discord / Yelp</a>'
+    + '</div>';
+  detailEl.classList.add('open');
+  detailEl.setAttribute('aria-hidden', 'false');
+  if (currentPos) {
+    document.getElementById('d-distance').textContent = formatDist(haversineMi(currentPos.lat, currentPos.lng, s.lat, s.lng));
+  }
+  map.setView([s.lat, s.lng], Math.max(map.getZoom(), 16));
+  // Async-fetch the rich panel data (websites, socials, articles, sources).
+  fetch('/api/biz/' + encodeURIComponent(s.slug) + '/panel')
+    .then(r => r.json())
+    .then(j => {
+      const el = document.getElementById('d-sources');
+      if (!el || !j?.ok) { if (el) el.textContent = ''; return; }
+      const blocks = [];
+      if (j.website) blocks.push('<div style="margin:14px 0 6px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase">Website</div><a href="' + escapeHtml(j.website) + '" target="_blank" rel="noopener" style="display:block;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:13px;font-family:var(--sans)">' + escapeHtml(j.website) + ' ↗</a>');
+      if (j.socials?.length) blocks.push('<div style="margin:14px 0 6px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase">Socials</div><div style="display:flex;flex-wrap:wrap;gap:6px">' + j.socials.map(s => '<a href="' + escapeHtml(s.url) + '" target="_blank" rel="noopener" style="padding:6px 10px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:11px;font-family:var(--mono)">' + escapeHtml(s.platform) + (s.handle ? ' · @' + escapeHtml(s.handle) : '') + ' ↗</a>').join('') + '</div>');
+      if (j.articles?.length) blocks.push('<div style="margin:14px 0 6px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase">Press · ' + j.articles.length + '</div>' + j.articles.map(a => '<a href="' + escapeHtml(a.url) + '" target="_blank" rel="noopener" style="display:block;padding:8px 12px;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans)"><div style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:2px">' + escapeHtml(a.publisher || 'press') + '</div><div style="font-family:var(--serif);font-size:14px;font-weight:500">' + escapeHtml(a.title || a.url) + '</div></a>').join(''));
+      el.innerHTML = blocks.join('');
+    })
+    .catch(() => { const el = document.getElementById('d-sources'); if (el) el.textContent = ''; });
+}
+document.getElementById('detail-close').addEventListener('click', () => {
+  detailEl.classList.remove('open');
+  detailEl.setAttribute('aria-hidden', 'true');
+});
+
+let currentPos = null, meMarker = null, follow = false, watchId = null;
+const followBtn = document.getElementById('follow-toggle');
+followBtn.addEventListener('click', () => {
+  if (!navigator.geolocation) { setStatus('geolocation unsupported'); return; }
+  if (!follow) startFollow(); else stopFollow();
+});
+function startFollow() {
+  follow = true;
+  followBtn.classList.add('is-on');
+  followBtn.textContent = '📍 Following';
+  setStatus('locating…');
+  watchId = navigator.geolocation.watchPosition(onPos, onPosErr, { enableHighAccuracy:true, maximumAge:5000, timeout:15000 });
+}
+function stopFollow() {
+  follow = false;
+  followBtn.classList.remove('is-on');
+  followBtn.textContent = '📍 Follow';
+  setStatus('paused');
+  if (watchId != null) navigator.geolocation.clearWatch(watchId);
+  watchId = null;
+}
+function onPos(p) {
+  const lat = p.coords.latitude, lng = p.coords.longitude;
+  currentPos = { lat, lng };
+  if (!meMarker) {
+    meMarker = L.marker([lat, lng], { icon:L.divIcon({ className:'', html:'<div class="me-dot"></div>', iconSize:[18,18], iconAnchor:[9,9] }), zIndexOffset:1000, interactive:false }).addTo(map);
+  } else { meMarker.setLatLng([lat, lng]); }
+  if (follow) map.setView([lat, lng], Math.max(map.getZoom(), 14));
+  refreshNearby(lat, lng);
+}
+function onPosErr(e) { setStatus('location ' + (e.code === 1 ? 'denied' : 'unavailable')); stopFollow(); }
+function haversineMi(lat1, lng1, lat2, lng2) {
+  const R = 3958.8, toRad = d => d * Math.PI / 180;
+  const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
+  const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2;
+  return 2 * R * Math.asin(Math.sqrt(a));
+}
+function formatDist(mi) {
+  if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
+  if (mi < 10)   return mi.toFixed(2) + ' mi';
+  return mi.toFixed(1) + ' mi';
+}
+function setStatus(t) { document.getElementById('nearby-status').textContent = t; }
+function refreshNearby(lat, lng) {
+  const ranked = SHOPS.map(s => ({ ...s, _d: haversineMi(lat, lng, s.lat, s.lng) }))
+    .sort((a, b) => a._d - b._d).slice(0, 8);
+  const list = document.getElementById('nearby-list');
+  list.innerHTML = ranked.map(s =>
+    '<div class="nearby-row" data-slug="' + escapeHtml(s.slug) + '">'
+      + '<div class="n-info"><div class="n-name">' + (s.lic ? '<span class="lic-mark"></span>' : '') + escapeHtml(s.name) + '</div>'
+      + '<div class="n-where">' + escapeHtml(s.cat) + (s.hood ? ' · ' + escapeHtml(s.hood) : '') + '</div></div>'
+      + '<div class="n-dist">' + formatDist(s._d) + '</div></div>'
+  ).join('');
+  setStatus(ranked[0] ? ('nearest ' + formatDist(ranked[0]._d)) : 'no shops nearby');
+  Array.from(list.querySelectorAll('.nearby-row')).forEach(row => {
+    row.addEventListener('click', () => {
+      const slug = row.getAttribute('data-slug');
+      const s = SHOPS.find(x => x.slug === slug);
+      if (s) openDetail(s);
+    });
+  });
+}
+function escapeHtml(s) {
+  return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+}
+</script>
+</body>
+</html>`;
+}
diff --git a/src/render/neighborhoods.js b/src/render/neighborhoods.js
new file mode 100644
index 0000000..63080e3
--- /dev/null
+++ b/src/render/neighborhoods.js
@@ -0,0 +1,165 @@
+// /neighborhoods — full grid of every neighborhood in the directory, sorted
+// by shop count. Editorial side-of-the-paper layout: huge serif title, mono
+// subtitle, then a two-column dense list (count · neighborhood) that reads
+// like an index in the back of a magazine.
+//
+// Why this page matters: after the ZIP backfill we have 160+ distinct
+// neighborhoods. The homepage only shows the top ~30. This is the
+// crawler-friendly + reader-friendly index of all of them, each linked to
+// /n/:slug.
+
+import { esc } from '../lib/escape.js';
+
+function slugify(s) {
+  return String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+}
+
+export function renderNeighborhoods({ neighborhoods = [], totalShops = 0, totalLicensed = 0 } = {}) {
+  // neighborhoods = [{ neighborhood, count, licensed_count }, …] already sorted desc.
+  // Group by first-letter for the alphabet anchor.
+  const sorted = [...neighborhoods].sort((a, b) => (a.neighborhood || '').localeCompare(b.neighborhood || ''));
+  const byLetter = new Map();
+  for (const n of sorted) {
+    const k = (n.neighborhood?.[0] || '#').toUpperCase();
+    if (!byLetter.has(k)) byLetter.set(k, []);
+    byLetter.get(k).push(n);
+  }
+  const letters = [...byLetter.keys()].sort();
+
+  // Top-20 by count for the masthead "headliners" section.
+  const top20 = [...neighborhoods].sort((a, b) => (b.count || 0) - (a.count || 0)).slice(0, 20);
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>All Neighborhoods · ${neighborhoods.length} · LA Salon Directory</title>
+<meta name="description" content="Every neighborhood we cover — ${neighborhoods.length} of them, ${totalShops.toLocaleString()} shops total, ${totalLicensed.toLocaleString()} state-licensed. Sorted by size, indexed alphabetically.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:#5a6e3a;--gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
+.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.crumb{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav a:hover{color:var(--ink)}
+
+.hero{padding:80px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+.hero-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}
+.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,7vw,96px);line-height:.96;letter-spacing:-.02em;margin-bottom:20px;max-width:18ch}
+.hero h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:62ch;margin-bottom:32px}
+.hero-stats{display:flex;gap:48px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
+.hero-stats b{display:block;font-family:var(--serif);font-style:italic;font-size:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}
+
+/* HEADLINERS (top-20 by shop count) */
+.headliners{padding:64px 32px;max-width:1240px;margin:0 auto;border-bottom:1px solid var(--rule)}
+.section-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:24px;padding-bottom:6px;border-bottom:1px solid var(--rule)}
+.head-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
+.head-card{background:var(--paper);padding:24px 22px;display:flex;flex-direction:column;gap:6px;transition:background 200ms ease}
+.head-card:hover{background:#fff}
+.head-rank{font-family:var(--mono);font-size:10px;letter-spacing:.18em;color:var(--mute)}
+.head-name{font-family:var(--serif);font-size:30px;font-weight:500;letter-spacing:-.01em;line-height:1.1}
+.head-count{font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--accent);margin-top:6px}
+
+/* INDEX A-Z */
+.index-section{padding:64px 32px;max-width:1240px;margin:0 auto}
+.alpha-anchor{display:flex;flex-wrap:wrap;gap:6px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:48px;padding-bottom:18px;border-bottom:1px solid var(--rule)}
+.alpha-anchor a{padding:4px 10px;border:1px solid var(--rule);transition:all 150ms ease}
+.alpha-anchor a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.letter-block{margin-bottom:48px;scroll-margin-top:90px}
+.letter-mark{font-family:var(--serif);font-size:64px;font-weight:500;color:var(--accent);font-style:italic;letter-spacing:-.02em;margin-bottom:8px;line-height:1}
+.hood-list{column-count:3;column-gap:48px;column-rule:1px solid var(--rule)}
+@media (max-width:880px){.hood-list{column-count:1}}
+.hood-row{break-inside:avoid;display:flex;justify-content:space-between;align-items:baseline;padding:8px 0;border-bottom:1px dotted var(--rule);transition:padding 150ms ease}
+.hood-row:hover{padding-left:6px}
+.hood-name{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em}
+.hood-count{font-family:var(--mono);font-size:10px;letter-spacing:.08em;color:var(--mute)}
+.hood-row .hood-count b{font-family:var(--mono);color:var(--accent);font-weight:500}
+
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
+@media (max-width:880px){
+  .topbar{padding:14px 20px}
+  .hero{padding:48px 20px 32px}
+  .headliners,.index-section{padding:48px 20px}
+}
+</style>
+</head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ neighborhoods</span></div>
+  <nav class="topnav">
+    <a href="/proprietors">Proprietors</a>
+    <a href="/press">Press</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/corridor/ventura-blvd">Ventura</a>
+    <a href="/map">Map</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-eyebrow">Index of Neighborhoods · A–Z</div>
+  <h1>Every <em>neighborhood</em> we cover.</h1>
+  <p class="hero-lede">Sorted alphabetically, indexed by shop count. ${neighborhoods.length.toLocaleString()} neighborhoods across LA + Orange + Riverside + San Bernardino + Ventura counties — every one with at least one state-licensed salon, barber, or storefront we've catalogued.</p>
+  <div class="hero-stats">
+    <div>Neighborhoods<b>${neighborhoods.length.toLocaleString()}</b></div>
+    <div>Total shops<b>${totalShops.toLocaleString()}</b></div>
+    <div>State-licensed<b>${totalLicensed.toLocaleString()}</b></div>
+  </div>
+</section>
+
+<section class="headliners">
+  <div class="section-label">The 20 Largest · By Shop Count</div>
+  <div class="head-grid">
+    ${top20.map((n, i) => `
+      <a class="head-card" href="/n/${esc(slugify(n.neighborhood))}">
+        <div class="head-rank">№ ${String(i + 1).padStart(2, '0')}</div>
+        <div class="head-name">${esc(n.neighborhood)}</div>
+        <div class="head-count">${(n.count || 0).toLocaleString()} shops${n.licensed_count ? ` · ${(n.licensed_count).toLocaleString()} licensed` : ''}</div>
+      </a>
+    `).join('')}
+  </div>
+</section>
+
+<section class="index-section">
+  <div class="alpha-anchor">
+    ${letters.map(L => `<a href="#letter-${esc(L)}">${esc(L)}</a>`).join('')}
+  </div>
+  ${letters.map(L => `
+    <div class="letter-block" id="letter-${esc(L)}">
+      <div class="letter-mark">${esc(L)}</div>
+      <div class="hood-list">
+        ${byLetter.get(L).map(n => `
+          <a class="hood-row" href="/n/${esc(slugify(n.neighborhood))}">
+            <span class="hood-name">${esc(n.neighborhood)}</span>
+            <span class="hood-count"><b>${n.count || 0}</b> shops</span>
+          </a>
+        `).join('')}
+      </div>
+    </div>
+  `).join('')}
+</section>
+
+<footer class="footer">
+  <span>LA Salon Directory · ${new Date().getFullYear()}</span>
+  <span>${neighborhoods.length} neighborhoods · ${totalShops.toLocaleString()} businesses</span>
+  <span><a href="/">← Home</a></span>
+</footer>
+</body>
+</html>`;
+}
diff --git a/src/render/profile.js b/src/render/profile.js
index e4f9773..29aa931 100644
--- a/src/render/profile.js
+++ b/src/render/profile.js
@@ -7,7 +7,7 @@
 // Q&A is the centerpiece — that's the editorial moat. Each Q&A pair gets
 // schema.org FAQ markup so Google indexes them as featured-snippet candidates.
 
-import { esc } from '../lib/escape.js';
+import { esc, escJsonLd } from '../lib/escape.js';
 import { questionsBySection, localizeQuestion } from '../lib/interview-questions.js';
 
 const NEIGHBORHOOD_ACCENT = {
@@ -28,11 +28,7 @@ const CATEGORY_LABEL = {
   blowout:'Blowout', waxing:'Waxing', generic:'Salon',
 };
 
-// Escape a JSON string for safe embedding inside <script type="application/ld+json">.
-// Without this, an answer containing "</script>" can break out of the script tag (XSS).
-function escJsonLd(json) {
-  return String(json).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
-}
+// (escJsonLd lives in src/lib/escape.js — single source of truth.)
 
 // Safely extract a website hostname; the DB may contain bare domains like "foo.com"
 // without a scheme, which would cause `new URL(...)` to throw.
@@ -94,7 +90,15 @@ function localBusinessJsonLd(b) {
   return JSON.stringify(obj);
 }
 
-export function renderProfile({ business, interview, otherShopsInHood = [] } = {}) {
+// Map a platform key to a display label + icon glyph. Single source of
+// truth so the social row stays consistent with /biz/:slug/links UX.
+const PLATFORM_LABEL = {
+  instagram:'Instagram', tiktok:'TikTok', facebook:'Facebook', x:'X', twitter:'X',
+  bluesky:'Bluesky', pinterest:'Pinterest', yelp:'Yelp', threads:'Threads',
+  youtube:'YouTube', linkedin:'LinkedIn', website:'Website',
+};
+
+export function renderProfile({ business, interview, otherShopsInHood = [], socials = [], articles = [], reviewSources = [] } = {}) {
   const b = business;
   const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
   const cat = CATEGORY_LABEL[b.category] || 'Salon';
@@ -125,8 +129,26 @@ export function renderProfile({ business, interview, otherShopsInHood = [] } = {
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width,initial-scale=1">
-<title>${esc(b.name)} · ${esc(b.neighborhood || 'Los Angeles')} · LA Salon Directory</title>
-<meta name="description" content="${esc((b.about_text || philosophy || `${b.name} — ${cat} in ${b.neighborhood || 'Los Angeles'}`).slice(0, 160))}">
+${(() => {
+  const dba = (b.source_data_json && typeof b.source_data_json === 'object' && b.source_data_json.dba_name) ? String(b.source_data_json.dba_name).trim() : '';
+  const displayName = dba || b.name;
+  const desc = (b.about_text || philosophy || `${displayName} — ${cat} in ${b.neighborhood || 'Los Angeles'}${articles.length ? ` · covered in ${articles.length} press article${articles.length === 1 ? '' : 's'}` : ''}${b.source_data_json?.source === 'dca_bbcb' ? ' · State-licensed' : ''}`).slice(0, 200);
+  return `<title>${esc(displayName)} · ${esc(b.neighborhood || 'Los Angeles')} · LA Salon Directory</title>
+<meta name="description" content="${esc(desc.slice(0, 160))}">
+<link rel="alternate" type="application/rss+xml" title="Press feed" href="/feed.xml">
+<meta property="og:type" content="business.business">
+<meta property="og:title" content="${esc(displayName)} · ${esc(b.neighborhood || 'LA')}">
+<meta property="og:description" content="${esc(desc)}">
+<meta property="og:site_name" content="LA Salon Directory">
+<meta property="og:url" content="/biz/${esc(b.slug)}">
+<meta property="og:image" content="/api/biz/${esc(b.slug)}/og.svg">
+<meta property="og:image:width" content="1200">
+<meta property="og:image:height" content="630">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="${esc(displayName)}">
+<meta name="twitter:description" content="${esc(desc)}">
+<meta name="twitter:image" content="/api/biz/${esc(b.slug)}/og.svg">`;
+})()}
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
@@ -230,7 +252,16 @@ img{display:block;max-width:100%;height:auto}
   <div class="hero-grid">
     <div class="hero-meta">
       <span class="cat">${esc(cat)}</span>
-      <h1>${esc(b.name)}</h1>
+      ${(() => {
+        const dba = (b.source_data_json && typeof b.source_data_json === 'object' && b.source_data_json.dba_name) ? String(b.source_data_json.dba_name).trim() : '';
+        // When the BBCB licensee name and the public DBA differ, lead with
+        // the DBA — that's the brand customers know — and keep the licensee
+        // name in a smaller "registered as" label below.
+        if (dba && dba.toLowerCase() !== (b.name || '').toLowerCase()) {
+          return `<h1>${esc(dba)}</h1><div style="font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-top:6px;margin-bottom:4px">Registered as · ${esc(b.name)}</div>`;
+        }
+        return `<h1>${esc(b.name)}</h1>`;
+      })()}
       <div class="loc">${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : (b.neighborhood ? `${esc(b.neighborhood)}, ` : '')}${esc(b.city || 'Los Angeles')}</div>
       <div class="hero-stats">
         ${Number.isFinite(Number(b.years_in_business)) && Number(b.years_in_business) > 0 ? `<div>Established<b>${esc(String(Number(b.years_in_business)))} yrs</b></div>` : ''}
@@ -296,12 +327,72 @@ ${filled.length === 0 ? `
 </article>
 `}
 
+${(socials.length || (b.website && b.website.trim())) ? `
+<section class="online" style="padding:48px 32px;border-top:1px solid var(--rule);max-width:980px;margin:0 auto">
+  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:8px;border-bottom:1px solid var(--rule)">
+    <h2 style="font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;letter-spacing:-.01em">Find them online</h2>
+    <a href="/biz/${esc(b.slug)}/links" style="font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)">+ add a link →</a>
+  </div>
+  <div style="display:flex;flex-wrap:wrap;gap:8px">
+    ${(() => { const w = safeHref(b.website); return w ? `<a href="${esc(w)}" target="_blank" rel="noopener" style="padding:10px 14px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease">Website ↗</a>` : ''; })()}
+    ${socials.map(s => { const u = safeHref(s.url); if (!u) return ''; return `<a href="${esc(u)}" target="_blank" rel="noopener" style="padding:10px 14px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease">${esc(PLATFORM_LABEL[s.platform] || s.platform)}${s.handle ? ' · @' + esc(s.handle) : ''} ↗</a>`; }).join('')}
+  </div>
+</section>
+` : ''}
+
+${articles.length ? `
+<section class="press" style="padding:48px 32px;border-top:1px solid var(--rule);max-width:980px;margin:0 auto">
+  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:24px;padding-bottom:8px;border-bottom:1px solid var(--rule)">
+    <h2 style="font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;letter-spacing:-.01em">Press &amp; mentions</h2>
+    <span style="font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)">${articles.length} article${articles.length === 1 ? '' : 's'}</span>
+  </div>
+  <ul style="list-style:none;display:flex;flex-direction:column;gap:14px">
+    ${articles.map(a => `
+      ${(() => { const u = safeHref(a.url); if (!u) return ''; return `<li style="padding:14px 16px;border:1px solid var(--rule);background:#fff;transition:border-color 200ms ease">
+        <a href="${esc(u)}" target="_blank" rel="noopener" style="display:block;color:var(--ink);text-decoration:none">
+          <div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px">
+            <span style="font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)">${esc(a.publisher || 'press')}</span>
+            ${a.published_at ? `<span style="font-family:monospace;font-size:9px;color:var(--mute);letter-spacing:.06em">${new Date(a.published_at).toLocaleDateString('en-US',{month:'short',year:'numeric'})}</span>` : ''}
+          </div>
+          <div style="font-family:Cormorant Garamond,serif;font-size:19px;font-weight:500;line-height:1.3;margin-bottom:${a.excerpt ? '6px' : '0'}">${esc(a.title || a.url)}</div>
+          ${a.excerpt ? `<p style="font-size:13px;color:#3a3a3a;line-height:1.5">${esc(a.excerpt)}</p>` : ''}
+        </a>
+      </li>`; })()}
+    `).join('')}
+  </ul>
+</section>
+` : ''}
+
+${reviewSources.length ? `
+<section class="community" style="padding:48px 32px;border-top:1px solid var(--rule);max-width:980px;margin:0 auto">
+  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:8px;border-bottom:1px solid var(--rule)">
+    <h2 style="font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;letter-spacing:-.01em">Community discussion</h2>
+    <span style="font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)">${reviewSources.length} thread${reviewSources.length === 1 ? '' : 's'}</span>
+  </div>
+  <ul style="list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">
+    ${reviewSources.map(r => { const u = safeHref(r.url); if (!u) return ''; return `
+      <li style="padding:11px 14px;border:1px solid var(--rule);background:#fff">
+        <a href="${esc(u)}" target="_blank" rel="noopener" style="display:block;color:var(--ink);text-decoration:none">
+          <span style="font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-right:6px">${esc(r.source_type)}</span>
+          <span style="font-family:Cormorant Garamond,serif;font-size:15px">${esc(r.label || u.replace(/^https?:\/\//, '').slice(0, 40))}</span>
+        </a>
+      </li>`; }).join('')}
+  </ul>
+</section>
+` : ''}
+
+<section class="cta-strip" style="background:#1a1a1a;color:#facc15;padding:32px;border-top:1px solid var(--rule);text-align:center">
+  <a href="/biz/${esc(b.slug)}/analyze" style="display:inline-block;background:#facc15;color:#1a1a1a;padding:14px 28px;font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;letter-spacing:-.005em;text-decoration:none;border:3px solid #1a1a1a">★ View Analysis &amp; Suggestions →</a>
+  <div style="font-family:'Space Mono',monospace;font-size:11px;color:#888;letter-spacing:.16em;text-transform:uppercase;margin-top:14px">14-signal SEO score · top hood leaders · prioritized action list · price comparison</div>
+</section>
+
 ${otherShopsInHood.length ? `
 <section class="more">
   <div class="more-head">
     <h2>More in ${esc(b.neighborhood || 'Los Angeles')}</h2>
-    ${hoodSlug ? `<a href="/n/${esc(hoodSlug)}">See all in ${esc(b.neighborhood || 'LA')} →</a>` : ''}
+    ${hoodSlug ? `<a href="/best/${esc(hoodSlug)}">See best in ${esc(b.neighborhood || 'LA')} →</a>` : ''}
   </div>
+  ${hoodSlug ? `<div style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--mute);margin-bottom:18px;display:flex;gap:14px;flex-wrap:wrap"><a href="/best/${esc(hoodSlug)}/salons" style="color:var(--accent);border-bottom:1px solid var(--accent)">Best Salons in ${esc(b.neighborhood)}</a> · <a href="/best/${esc(hoodSlug)}/barbershops" style="color:var(--accent);border-bottom:1px solid var(--accent)">Best Barbers in ${esc(b.neighborhood)}</a> · <a href="/best/${esc(hoodSlug)}/spas" style="color:var(--accent);border-bottom:1px solid var(--accent)">Best Spas in ${esc(b.neighborhood)}</a></div>` : ''}
   <div class="more-grid">
     ${otherShopsInHood.slice(0, 4).map(s => `
       <a class="shop" href="/biz/${esc(s.slug)}">
@@ -392,6 +483,15 @@ h1{font-family:var(--serif);font-weight:500;font-size:48px;letter-spacing:-.02em
       <input name="recorded_by" placeholder="Your name (optional)" value="${esc(interview?.recorded_by || '')}">
     </div>
 
+    <div style="margin:0 0 32px;padding:14px 16px;background:#fff;border:1px dashed var(--accent);border-radius:4px;display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
+      <div style="font-family:var(--mono);font-size:11px;letter-spacing:.04em;color:var(--mute);line-height:1.5">
+        <strong style="color:var(--ink);text-transform:uppercase;letter-spacing:.18em">Cold start?</strong>
+        Have local AI draft starter answers from what we know about ${esc(b.name)}.
+        It only fills empty fields — anything you typed stays. Edit each <code>[VERIFY]</code> tag before saving.
+      </div>
+      <button type="button" id="aiDraftBtn" style="padding:11px 18px;background:var(--accent);color:var(--paper);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:600;cursor:pointer;white-space:nowrap;border-radius:3px">⚡ Draft with AI</button>
+    </div>
+
     ${sections.map(sec => `
       <div class="section-head">
         <span class="kicker">Section ${sec.order}</span>
@@ -430,6 +530,40 @@ h1{font-family:var(--serif);font-weight:500;font-size:48px;letter-spacing:-.02em
 <script>
 const form = document.getElementById('form');
 const status = document.getElementById('status');
+
+// AI draft — fills empty textareas with starter answers from local Ollama.
+// Won't overwrite anything the owner has already typed.
+const draftBtn = document.getElementById('aiDraftBtn');
+if (draftBtn) draftBtn.addEventListener('click', async () => {
+  if (!confirm('Draft starter answers with AI? Empty fields only — anything you typed stays.')) return;
+  draftBtn.disabled = true;
+  draftBtn.textContent = '⏳ Drafting (≈30-60s)…';
+  status.textContent = 'Drafting interview from what we know…';
+  try {
+    const res = await fetch('/api/biz/interview/draft', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ slug: form.querySelector('[name=slug]').value }),
+    });
+    const r = await res.json();
+    if (!r.ok) throw new Error(r.error || 'unknown');
+    let filled = 0, skipped = 0;
+    for (const [id, val] of Object.entries(r.qa || {})) {
+      const ta = document.getElementById('q_' + id);
+      if (!ta) continue;
+      if (ta.value && ta.value.trim()) { skipped++; continue; }
+      ta.value = val;
+      filled++;
+    }
+    draftBtn.textContent = '✓ Drafted ' + filled + ' fields';
+    status.textContent = 'AI filled ' + filled + ' fields (' + skipped + ' kept your text). Edit any [VERIFY] tags before saving — those need real local detail.';
+    setTimeout(() => { draftBtn.disabled = false; draftBtn.textContent = '⚡ Draft with AI'; }, 4500);
+  } catch (e) {
+    draftBtn.disabled = false;
+    draftBtn.textContent = '⚡ Draft with AI';
+    status.textContent = 'Draft failed: ' + e.message;
+  }
+});
+
 form.addEventListener('submit', async (e) => {
   e.preventDefault();
   status.textContent = 'Saving…';
diff --git a/src/render/proprietors.js b/src/render/proprietors.js
new file mode 100644
index 0000000..b411fe5
--- /dev/null
+++ b/src/render/proprietors.js
@@ -0,0 +1,156 @@
+// /proprietors — every sole-proprietor barbershop / salon flagged by the
+// owner-name heuristic, grouped by neighborhood. The differentiating angle:
+// chains, franchises, and salon collectives are filtered OUT. What remains
+// is the "the chair IS the brand" cohort — 5,000+ chairs across LA whose
+// state license reads as a person's name.
+//
+// Editorial framing: each section is a neighborhood, each row is "{NAME} ·
+// {category}" — no chain logos, no marketing copy. Reads like a phone book
+// of LA's actual sole-prop chairs, surfaced cleanly.
+
+import { esc, escJsonLd } from '../lib/escape.js';
+
+function slugify(s) {
+  return String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+}
+
+export function renderProprietors({ businesses = [], hoodCounts = [] } = {}) {
+  const baseUrl = process.env.SITE_URL || 'https://localhost';
+
+  // Group by neighborhood for the editorial run.
+  const byHood = new Map();
+  for (const b of businesses) {
+    const k = b.neighborhood || 'Unspecified';
+    if (!byHood.has(k)) byHood.set(k, []);
+    byHood.get(k).push(b);
+  }
+  // Sort neighborhoods by row count descending (biggest sole-prop counts lead).
+  const orderedHoods = [...byHood.entries()]
+    .sort((a, b) => b[1].length - a[1].length);
+
+  const itemList = {
+    '@context': 'https://schema.org',
+    '@type': 'ItemList',
+    name: `Sole-Proprietor Salons & Barbers · ${businesses.length}`,
+    description: 'Every sole-proprietor salon or barbershop in the LA Salon Directory whose state-licensed name reads as a person, not a brand.',
+    url: `${baseUrl}/proprietors`,
+    numberOfItems: businesses.length,
+    itemListElement: businesses.slice(0, 100).map((b, i) => ({
+      '@type': 'ListItem',
+      position: i + 1,
+      item: {
+        '@type': 'HairSalon',
+        name: b.name,
+        url: `${baseUrl}/biz/${b.slug}`,
+      },
+    })),
+  };
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>The Proprietors · ${businesses.length} sole-prop chairs · LA Salon Directory</title>
+<meta name="description" content="The LA salons and barbershops whose state-licensed name reads as a person's name. ${businesses.length.toLocaleString()} sole-proprietor chairs across ${orderedHoods.length} neighborhoods.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<script type="application/ld+json">${escJsonLd(JSON.stringify(itemList))}</script>
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
+  --accent:#b8945a; /* brushed-gold accent — the "proprietor" mark */
+  --gold:#b8945a;
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
+.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.topnav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav a:hover{color:var(--ink)}
+
+.hero{padding:80px 32px 56px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+.hero-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}
+.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,8vw,108px);line-height:.96;letter-spacing:-.02em;margin-bottom:22px;max-width:14ch}
+.hero h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:18px;line-height:1.6;color:#3a3a3a;max-width:62ch;margin-bottom:32px}
+.hero-lede em{font-family:var(--serif);font-style:italic;font-size:21px;color:var(--ink)}
+.hero-stats{display:flex;gap:48px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
+.hero-stats b{display:block;font-family:var(--serif);font-style:italic;font-size:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}
+
+.hood-block{padding:64px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule)}
+.hood-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:32px;padding-bottom:14px;border-bottom:1px solid var(--rule)}
+.hood-head h2{font-family:var(--serif);font-weight:500;font-size:clamp(36px,4.5vw,64px);letter-spacing:-.015em;line-height:1}
+.hood-head .meta{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
+
+.row-grid{column-count:3;column-gap:48px;column-rule:1px solid var(--rule)}
+@media (max-width:880px){.row-grid{column-count:1}}
+.prop-row{break-inside:avoid;display:grid;grid-template-columns:1fr auto;gap:14px;align-items:baseline;padding:9px 0;border-bottom:1px dotted var(--rule);transition:padding 150ms ease}
+.prop-row:hover{padding-left:6px;background:#fff}
+.prop-name{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em;line-height:1.25}
+.prop-cat{font-family:var(--mono);font-size:9px;letter-spacing:.16em;color:var(--accent);text-transform:uppercase;text-align:right;white-space:nowrap}
+
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
+@media (max-width:880px){
+  .topbar{padding:14px 20px}
+  .hero{padding:48px 20px 32px}
+  .hood-block{padding:48px 20px}
+}
+</style>
+</head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ the proprietors</span></div>
+  <nav class="topnav">
+    <a href="/neighborhoods">Neighborhoods</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/corridor/ventura-blvd">Ventura</a>
+    <a href="/corridor/sunset-blvd">Sunset</a>
+    <a href="/map">Map</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-eyebrow">The Proprietors · Sole-License Chairs</div>
+  <h1>The chair <em>is</em> the brand.</h1>
+  <p class="hero-lede">In <em>${businesses.length.toLocaleString()}</em> chairs across LA County, the state license isn't issued to a corporation, an LLC, or a salon collective — it's issued to a person. This is the directory of those people: the sole-proprietor barbers, stylists, and colorists whose name on the door is the same as the name on the license.</p>
+  <div class="hero-stats">
+    <div>Sole-prop chairs<b>${businesses.length.toLocaleString()}</b></div>
+    <div>Neighborhoods<b>${orderedHoods.length.toLocaleString()}</b></div>
+    <div>State-licensed<b>100%</b></div>
+  </div>
+</section>
+
+${orderedHoods.map(([hood, list]) => `
+  <section class="hood-block">
+    <div class="hood-head">
+      <h2>${esc(hood)}</h2>
+      <span class="meta">${list.length} chair${list.length===1?'':'s'}</span>
+    </div>
+    <div class="row-grid">
+      ${list.map(b => `
+        <a class="prop-row" href="/biz/${esc(b.slug)}">
+          <span class="prop-name">${esc(b.name)}</span>
+          <span class="prop-cat">${esc(b.category || 'salon')}</span>
+        </a>
+      `).join('')}
+    </div>
+  </section>
+`).join('')}
+
+<footer class="footer">
+  <span>The Proprietors · LA Salon Directory · ${new Date().getFullYear()}</span>
+  <span>${businesses.length.toLocaleString()} sole-prop chairs · sourced from CA BBCB</span>
+  <span><a href="/">← Home</a></span>
+</footer>
+</body>
+</html>`;
+}
diff --git a/src/render/templates/_helpers.js b/src/render/templates/_helpers.js
index 7d67cde..63c4c2e 100644
--- a/src/render/templates/_helpers.js
+++ b/src/render/templates/_helpers.js
@@ -1,9 +1,9 @@
-import { esc } from '../../lib/escape.js';
+import { esc, safeColor } from '../../lib/escape.js';
 
 export function pickColors(b) {
   return {
-    primary: b.color_primary || '#1f2937',
-    accent: b.color_accent || '#d97706',
+    primary: safeColor(b.color_primary, '#1f2937'),
+    accent: safeColor(b.color_accent, '#d97706'),
     bg: '#ffffff',
     fg: '#0b0b0b',
   };
@@ -11,7 +11,7 @@ export function pickColors(b) {
 
 export function safeHero(b) {
   return b.hero_image_url ||
-    `https://images.weserv.nl/?url=&w=1600&h=900&t=letterbox&bg=${encodeURIComponent((b.color_primary || '#1f2937').replace('#',''))}` ||
+    `https://images.weserv.nl/?url=&w=1600&h=900&t=letterbox&bg=${encodeURIComponent(safeColor(b.color_primary, '#1f2937').replace('#',''))}` ||
     '';
 }
 
@@ -22,7 +22,7 @@ export function placeholderHero(b, label) {
   return `data:image/svg+xml;utf8,` + encodeURIComponent(
     `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1600 900'>
       <defs><linearGradient id='g' x1='0' x2='1' y1='0' y2='1'>
-        <stop offset='0%' stop-color='${c.primary}'/><stop offset='100%' stop-color='${c.accent}'/>
+        <stop offset='0%' stop-color='${safeColor(c.primary, '#1f2937')}'/><stop offset='100%' stop-color='${safeColor(c.accent, '#d97706')}'/>
       </linearGradient></defs>
       <rect width='1600' height='900' fill='url(#g)'/>
       <text x='800' y='470' text-anchor='middle' font-family='Georgia, serif' font-size='90' fill='white' opacity='0.9'>${text}</text>
diff --git a/src/render/templates/template-1.js b/src/render/templates/template-1.js
index a3f30a5..96a8031 100644
--- a/src/render/templates/template-1.js
+++ b/src/render/templates/template-1.js
@@ -5,6 +5,9 @@
 
 const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
+// Hex color allowlist — blocks `</style><script>…` payloads from breaking out of the
+// `--primary: ${primary}` interpolation below into a stored-XSS gadget.
+const safeColor = (c, fb) => (typeof c === 'string' && /^#[0-9a-f]{3,8}$/i.test(c.trim())) ? c.trim() : fb;
 
 function socialIcon(platform) {
   const p = (platform || '').toLowerCase();
@@ -32,8 +35,8 @@ function hoursList(hours) {
 export function renderTemplate1(business, mockups = {}, options = {}) {
   const b = business || {};
   const hideAttribution = options.hideAttribution ?? b.tier === 'premium';
-  const primary = b.color_primary || '#5C1A1B';        // oxblood
-  const accent = b.color_accent || '#E8DCC4';          // cream
+  const primary = safeColor(b.color_primary, '#5C1A1B'); // oxblood
+  const accent = safeColor(b.color_accent, '#E8DCC4');   // cream
   const services = Array.isArray(b.services) ? b.services.slice(0, 6) : [];
   const gallery = Array.isArray(b.gallery_images) ? b.gallery_images.slice(0, 9) : [];
   const socials = Array.isArray(b.socials) ? b.socials : [];
diff --git a/src/render/templates/template-2.js b/src/render/templates/template-2.js
index 86a5ce7..8077a4a 100644
--- a/src/render/templates/template-2.js
+++ b/src/render/templates/template-2.js
@@ -5,6 +5,8 @@
 
 const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 const safeJSON = (o) => JSON.stringify(o).replace(/</g, '\\u003c');
+// Hex color allowlist — see template-1.js for rationale (stored-XSS via `</style>` break-out).
+const safeColor = (c, fb) => (typeof c === 'string' && /^#[0-9a-f]{3,8}$/i.test(c.trim())) ? c.trim() : fb;
 
 function socialIcon(platform) {
   const p = (platform || '').toLowerCase();
@@ -32,8 +34,8 @@ function hoursList(hours) {
 export function renderTemplate2(business, mockups = {}, options = {}) {
   const b = business || {};
   const hideAttribution = options.hideAttribution ?? b.tier === 'premium';
-  const primary = b.color_primary || '#FF1F8F';        // hot pink
-  const accent = b.color_accent || '#0046FF';          // electric blue
+  const primary = safeColor(b.color_primary, '#FF1F8F'); // hot pink
+  const accent = safeColor(b.color_accent, '#0046FF');   // electric blue
   const services = Array.isArray(b.services) ? b.services.slice(0, 6) : [];
   const gallery = Array.isArray(b.gallery_images) ? b.gallery_images.slice(0, 8) : [];
   const socials = Array.isArray(b.socials) ? b.socials : [];
diff --git a/src/render/wall.js b/src/render/wall.js
new file mode 100644
index 0000000..fe50883
--- /dev/null
+++ b/src/render/wall.js
@@ -0,0 +1,281 @@
+// /wall — public-facing brutalist gallery of every project that's been
+// audited + mockuped. Lives at livesiteaudit.agentabrams.com/wall (host
+// allowlisted in src/server/index.js). Filters out compliance_sensitive rows.
+//
+// Cards display: brutalist mockup mini-iframe, grade badge, name+vertical,
+// qwen3 roast (if generated), and a link to /wa/:slug.
+
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
+  '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+}[c]));
+
+function gradeColor(score) {
+  if (!Number.isFinite(score)) return '#a3a3a3';
+  return score >= 80 ? '#22c55e' : score >= 65 ? '#84cc16' : score >= 45 ? '#facc15' : score >= 25 ? '#f97316' : '#dc2626';
+}
+
+const FONTS = `
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap">
+`;
+
+const HEAD = (title, ogDesc) => `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)}</title>
+<meta name="description" content="${esc(ogDesc)}">
+<meta property="og:title" content="${esc(title)}">
+<meta property="og:description" content="${esc(ogDesc)}">
+<meta property="og:type" content="website">
+<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='%23000'/><text x='32' y='44' font-family='Archivo Black' font-size='40' fill='%23facc15' text-anchor='middle'>★</text></svg>">
+${FONTS}
+<style>
+  :root{--bg:#fafaf6;--ink:#111;--paper:#fff;--accent:#facc15;--muted:#666;--soft:#fef3c7;--green-bg:#f0fdf4;--red-bg:#fef2f2}
+  [data-theme="dark"]{--bg:#0a0a08;--ink:#fafaf6;--paper:#1a1a18;--accent:#facc15;--muted:#999;--soft:#1f1f1d;--green-bg:#0a2014;--red-bg:#1f0a0a}
+  *{box-sizing:border-box}
+  body{margin:0;font-family:'Space Mono',monospace;background:var(--bg);color:var(--ink);transition:background .15s,color .15s}
+  body .card{background:var(--paper);color:var(--ink)}
+  body .vert-row{background:var(--paper)}
+  body .filter,body .pager a,body .pager span{background:var(--paper);color:var(--ink)}
+  body .preview{background:var(--bg)}
+  body .body{background:var(--paper);color:var(--ink)}
+  .theme-toggle{background:var(--paper);color:var(--ink);border:3px solid var(--ink);padding:8px 12px;font-family:'Archivo Black',sans-serif;font-size:12px;cursor:pointer;text-transform:uppercase;letter-spacing:.04em}
+  .theme-toggle:hover{background:var(--accent);color:#000}
+  a{color:inherit}
+  h1,h2,h3{font-family:'Archivo Black',sans-serif;letter-spacing:-.02em;text-transform:uppercase;margin:0}
+  .container{max-width:1480px;margin:0 auto;padding:0 24px}
+  .marquee{overflow:hidden;background:#000;color:#facc15;border-bottom:4px solid #facc15;padding:12px 0;font-family:'Space Mono',monospace;font-size:14px}
+  .marquee-track{display:inline-block;white-space:nowrap;animation:scroll 90s linear infinite}
+  .marquee-track span{padding:0 32px;display:inline-block;border-right:2px solid #facc15;font-weight:700}
+  @keyframes scroll{from{transform:translateX(0)}to{transform:translateX(-50%)}}
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px}
+  .card{border:4px solid #000;background:#fff;display:flex;flex-direction:column;text-decoration:none;color:inherit;transition:transform .08s}
+  .card:hover{transform:translate(-3px,-3px);box-shadow:6px 6px 0 0 #000}
+  .preview{aspect-ratio:4/3;position:relative;overflow:hidden;background:#fafaf6;border-bottom:4px solid #000}
+  .preview iframe{position:absolute;inset:0;width:200%;height:200%;border:0;transform:scale(.5);transform-origin:0 0;pointer-events:none}
+  .badge{position:absolute;top:8px;right:8px;width:48px;height:48px;display:flex;align-items:center;justify-content:center;font-family:'Archivo Black',sans-serif;font-size:24px;border:3px solid #000;background:#facc15}
+  .body{padding:14px 16px;flex:1;display:flex;flex-direction:column;gap:10px}
+  .meta{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666}
+  .name{font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;line-height:1;letter-spacing:-.02em}
+  .roast{font-size:13px;line-height:1.4;color:#222;font-style:italic;border-top:2px solid #000;padding-top:10px;margin-top:auto}
+  .roast::before{content:"★ ";color:#facc15;-webkit-text-stroke:1px #000;font-weight:700;font-style:normal}
+  .pager{display:flex;gap:14px;align-items:center;justify-content:center;padding:48px 0;font-family:'Archivo Black',sans-serif;text-transform:uppercase;font-size:14px;letter-spacing:.04em}
+  .pager a, .pager span{padding:12px 22px;border:4px solid #000;text-decoration:none;color:#000;background:#fff}
+  .pager a:hover{background:#facc15}
+  .pager .cur{background:#000;color:#facc15}
+  .nav{display:flex;justify-content:space-between;align-items:center;padding:24px 0;border-bottom:4px solid #000}
+  .nav a{text-decoration:none;font-family:'Archivo Black',sans-serif;text-transform:uppercase;letter-spacing:.04em}
+  .filters{display:flex;gap:10px;flex-wrap:wrap;padding:18px 0;border-bottom:4px solid #000;font-family:'Space Mono',monospace;font-size:12px;font-weight:700}
+  .filter{padding:8px 14px;border:3px solid #000;background:#fff;text-decoration:none;color:#000;text-transform:uppercase;letter-spacing:.04em}
+  .filter.cur{background:#000;color:#facc15}
+  .filter:hover{background:#facc15}
+  .hero{padding:80px 0 48px;border-bottom:4px solid #000;position:relative;background:linear-gradient(135deg,#fafaf6 0%,#fafaf6 50%,#facc15 50%,#facc15 100%);background-size:24px 24px}
+  .hero h1{font-size:clamp(48px,9vw,128px);line-height:.85;color:#000}
+  .hero h1 mark{background:#facc15;padding:0 .15em;color:#000}
+  .hero p{font-size:18px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#444;margin-top:18px;max-width:60ch}
+  .cta{display:inline-block;background:#000;color:#facc15;padding:18px 32px;font-family:'Archivo Black',sans-serif;text-decoration:none;text-transform:uppercase;letter-spacing:.04em;border:4px solid #000;font-size:18px;margin-top:32px}
+  .cta:hover{background:#facc15;color:#000}
+</style>
+</head><body>`;
+
+const FOOT = `
+<footer style="border-top:4px solid #000;background:#000;color:#fafaf6;padding:48px 24px;margin-top:48px">
+  <div class="container">
+    <div style="display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:24px">
+      <div>
+        <div style="font-family:'Archivo Black',sans-serif;font-size:32px;text-transform:uppercase;color:#facc15">★ LIVE SITE AUDIT</div>
+        <div style="font-size:12px;font-weight:700;color:#999;margin-top:4px;letter-spacing:.06em">EVERY SITE GETS THE BRUTAL TREATMENT.</div>
+      </div>
+      <div style="display:flex;gap:24px;flex-wrap:wrap">
+        <a href="/website-analysis" style="color:#facc15;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em">★ AUDIT YOUR SITE →</a>
+        <a href="/wall" style="color:#fafaf6;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em">★ THE WALL</a>
+      </div>
+    </div>
+    <div style="font-size:11px;color:#666;margin-top:24px;font-family:'Space Mono',monospace">© 2026 · made with cheerio + qwen3:14b · roasts may sting · contact info@agentabrams.com</div>
+  </div>
+</footer>
+</body></html>`;
+
+export function renderWall({ rows, page = 1, perPage = 24, totalCount, vertical = 'all', verticals = [], roastsForMarquee = [], stats = null }) {
+  const pages = Math.max(1, Math.ceil(totalCount / perPage));
+  const cards = rows.map(r => {
+    const live = r.summary_json?.live_audit;
+    const liveLetter = live?.grade?.letter;
+    const liveScore = live?.grade?.score;
+    const cp = r.summary_json?.copy_pack;
+    const headline = cp?.headline || r.summary_json?.title || r.meta_json?.title || r.slug;
+    const vert = r.vertical && r.vertical !== 'generic' ? r.vertical : null;
+    const roast = r.summary_json?.roast?.text;
+    // Find a suitable mockup id — prefer brutalist if present, else first.
+    const mockupSrc = r.preview_mockup_id ? `/wa/${esc(r.slug)}/m/${esc(r.preview_mockup_id)}` : null;
+    const searchBlob = [r.slug, vert || '', liveLetter || '', headline, roast || ''].join(' ').toLowerCase();
+    return `<a class="card" data-search="${esc(searchBlob)}" href="/wa/${esc(r.slug)}" target="_blank" rel="noopener">
+      <div class="preview">
+        ${mockupSrc ? `<iframe src="${mockupSrc}" loading="lazy" sandbox="allow-same-origin"></iframe>` : `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:'Archivo Black',sans-serif;color:#999;font-size:14px;text-transform:uppercase">★ NO PREVIEW</div>`}
+        ${liveLetter ? `<div class="badge" style="background:${gradeColor(liveScore)};">${esc(liveLetter)}</div>` : ''}
+      </div>
+      <div class="body">
+        <div class="meta">${vert ? `★ ${esc(vert.toUpperCase())}` : '★ PROJECT'}${liveScore != null ? ` · LIVE ${esc(String(liveScore))}/100` : ''}</div>
+        <div class="name">${esc(headline.slice(0, 64))}</div>
+        ${roast ? `<div class="roast">${esc(roast.slice(0, 200))}</div>` : ''}
+      </div>
+    </a>`;
+  }).join('');
+
+  const verticalChips = ['all', ...verticals].map(v => {
+    const cur = v === vertical;
+    const href = v === 'all' ? '/wall' : `/wall?v=${encodeURIComponent(v)}`;
+    return `<a class="filter${cur ? ' cur' : ''}" href="${esc(href)}">${v === 'all' ? '★ ALL' : esc(v.toUpperCase())}</a>`;
+  }).join('');
+
+  const pagerLinks = (() => {
+    if (pages <= 1) return '';
+    const link = (n, label) => n === page
+      ? `<span class="cur">${esc(label || String(n))}</span>`
+      : `<a href="/wall?p=${n}${vertical !== 'all' ? `&v=${encodeURIComponent(vertical)}` : ''}">${esc(label || String(n))}</a>`;
+    const out = [];
+    if (page > 1) out.push(link(page - 1, '← PREV'));
+    const start = Math.max(1, page - 2);
+    const end = Math.min(pages, page + 2);
+    for (let n = start; n <= end; n += 1) out.push(link(n));
+    if (page < pages) out.push(link(page + 1, 'NEXT →'));
+    return `<div class="pager">${out.join('')}</div>`;
+  })();
+
+  const marquee = roastsForMarquee.length
+    ? `<div class="marquee"><div class="marquee-track">${
+        [...roastsForMarquee, ...roastsForMarquee].map(r => `<span>★ ${esc((r.text || '').slice(0, 130))}</span>`).join('')
+      }</div></div>`
+    : '';
+
+  const title = `★ THE WALL · ${totalCount} BUILDS UNDER THE MICROSCOPE`;
+  const desc = `A brutalist gallery of every site we've audited and rebuilt. Live HTML grades, qwen3-generated roasts, and three new direction mockups for each.`;
+
+  // JSON-LD structured data — collection page with each card as ItemListElement.
+  const jsonLd = {
+    '@context': 'https://schema.org',
+    '@type': 'CollectionPage',
+    name: title,
+    description: desc,
+    url: 'https://livesiteaudit.agentabrams.com/wall',
+    mainEntity: {
+      '@type': 'ItemList',
+      numberOfItems: totalCount,
+      itemListElement: rows.slice(0, 24).map((r, i) => ({
+        '@type': 'ListItem',
+        position: (page - 1) * perPage + i + 1,
+        url: `https://livesiteaudit.agentabrams.com/wa/${r.slug}`,
+        name: r.summary_json?.copy_pack?.headline || r.slug,
+      })),
+    },
+  };
+
+  return HEAD(title, desc) + `
+<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
+<script>(function(){try{var t=localStorage.getItem('lsa-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark')}catch(e){}})()</script>
+<nav class="nav container">
+  <a href="/" style="font-size:18px">★ LIVE SITE AUDIT</a>
+  <div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
+    <a href="/grades" style="font-size:13px">★ LEADERBOARD</a>
+    <button class="theme-toggle" id="theme-toggle" title="toggle dark/light">★ <span id="theme-icon">☾</span></button>
+    <a href="/website-analysis" style="font-size:13px;background:#facc15;border:4px solid #000;padding:10px 18px">★ AUDIT YOUR SITE</a>
+  </div>
+</nav>
+
+${marquee}
+
+<header class="hero">
+  <div class="container">
+    <h1>${totalCount}<br>BUILDS<br>UNDER THE<br><mark>MICROSCOPE.</mark></h1>
+    <p>Every project we've audited, scored, mocked-up, and roasted. Live HTML grades from a fresh fetch. Three rebuild mockups per project. No filters, no flattery.</p>
+    <a class="cta" href="/website-analysis">★ ROAST MY SITE →</a>
+  </div>
+</header>
+
+${stats ? `<section style="background:#000;color:#facc15;border-bottom:4px solid #000">
+  <div class="container" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:0;font-family:'Archivo Black',sans-serif">
+    <div style="padding:22px 18px;border-right:2px solid #facc15">
+      <div style="font-size:38px;line-height:1">${esc(String(stats.total))}</div>
+      <div style="font-size:11px;color:#fafaf6;letter-spacing:.06em;margin-top:4px">★ TOTAL BUILDS</div>
+    </div>
+    <div style="padding:22px 18px;border-right:2px solid #facc15">
+      <div style="font-size:38px;line-height:1">${esc(String(stats.graded))}</div>
+      <div style="font-size:11px;color:#fafaf6;letter-spacing:.06em;margin-top:4px">★ LIVE-AUDITED</div>
+    </div>
+    <div style="padding:22px 18px;border-right:2px solid #facc15">
+      <div style="font-size:38px;line-height:1">${esc(String(stats.roasted))}</div>
+      <div style="font-size:11px;color:#fafaf6;letter-spacing:.06em;margin-top:4px">★ ROASTED BY QWEN3</div>
+    </div>
+    <div style="padding:22px 18px;border-right:2px solid #facc15">
+      <div style="font-size:38px;line-height:1">${esc(String(stats.mockups))}</div>
+      <div style="font-size:11px;color:#fafaf6;letter-spacing:.06em;margin-top:4px">★ MOCKUPS RENDERED</div>
+    </div>
+    <div style="padding:22px 18px">
+      <div style="font-size:38px;line-height:1">${esc(stats.avgGrade)}</div>
+      <div style="font-size:11px;color:#fafaf6;letter-spacing:.06em;margin-top:4px">★ AVG LIVE GRADE</div>
+    </div>
+  </div>
+</section>` : ''}
+
+<section class="container" style="padding:32px 0 16px">
+  <div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:18px">
+    <div style="flex:1;min-width:240px;display:flex;border:4px solid #000;background:#fff">
+      <span style="background:#000;color:#facc15;padding:14px 16px;font-family:'Archivo Black',sans-serif;font-size:14px;letter-spacing:.04em">★ FIND</span>
+      <input id="wall-search" type="text" placeholder="filter by name, vertical, grade…" autocomplete="off"
+        style="flex:1;padding:14px 16px;border:0;font-family:'Space Mono',monospace;font-size:14px;font-weight:700;background:#fff;outline:none">
+      <button id="wall-clear" type="button" title="clear" style="background:#fff;color:#666;border:0;border-left:3px solid #000;padding:0 16px;cursor:pointer;font-family:'Archivo Black',sans-serif;font-size:18px">×</button>
+    </div>
+    <div id="wall-count" style="font-family:'Space Mono',monospace;font-size:12px;font-weight:700;color:#666;letter-spacing:.04em;text-transform:uppercase">★ ${rows.length} OF ${totalCount} ON THIS PAGE</div>
+  </div>
+  <div class="filters">${verticalChips}</div>
+</section>
+
+<main class="container">
+  <div class="grid">${cards}</div>
+  ${pagerLinks}
+</main>
+<script>
+(function(){
+  const input = document.getElementById('wall-search');
+  const clear = document.getElementById('wall-clear');
+  const count = document.getElementById('wall-count');
+  const cards = Array.from(document.querySelectorAll('.card'));
+  const totalShown = cards.length;
+  function apply(q) {
+    q = (q || '').trim().toLowerCase();
+    if (!q) {
+      cards.forEach(c => { c.style.display = ''; });
+      count.textContent = '★ ' + totalShown + ' OF ${totalCount} ON THIS PAGE';
+      return;
+    }
+    let n = 0;
+    for (const c of cards) {
+      const ok = (c.dataset.search || '').includes(q);
+      c.style.display = ok ? '' : 'none';
+      if (ok) n += 1;
+    }
+    count.textContent = '★ ' + n + ' MATCH' + (n === 1 ? '' : 'ES') + ' OF ' + totalShown;
+  }
+  input?.addEventListener('input', e => apply(e.target.value));
+  clear?.addEventListener('click', () => { input.value = ''; apply(''); input.focus(); });
+
+  // Dark/light toggle (per memory: feedback_dark_light_toggle_standard.md)
+  const tt = document.getElementById('theme-toggle');
+  const icon = document.getElementById('theme-icon');
+  function reflectTheme() {
+    const dark = document.documentElement.getAttribute('data-theme') === 'dark';
+    if (icon) icon.textContent = dark ? '☀' : '☾';
+  }
+  reflectTheme();
+  tt?.addEventListener('click', () => {
+    const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
+    if (isDark) { document.documentElement.removeAttribute('data-theme'); localStorage.setItem('lsa-theme','light'); }
+    else        { document.documentElement.setAttribute('data-theme','dark'); localStorage.setItem('lsa-theme','dark'); }
+    reflectTheme();
+  });
+})();
+</script>
+` + FOOT;
+}
diff --git a/src/render/website-analysis-themes.js b/src/render/website-analysis-themes.js
new file mode 100644
index 0000000..a56fff6
--- /dev/null
+++ b/src/render/website-analysis-themes.js
@@ -0,0 +1,581 @@
+// website-analysis themes — 3 starter front-page concepts shown to the user
+// as "see your site as a new website" cards.
+//
+// Each theme is a single-file HTML string, inline CSS, Google-Fonts CDN OK,
+// no JS frameworks, no external images (uses CSS gradients + the user's own
+// hero_image when present).
+//
+// Inputs (ctx):
+//   title          string  — site title or business name
+//   tagline        string  — short description / og:description
+//   palette        string[] — extracted hex colors, longest hits first
+//   hero_image     string|null
+//   vertical       string  — salon | restaurant | lawyer | vet | advocacy | ecom | saas-landing | generic
+//   ig_handle      string|null  — '@thing' or 'thing'
+//   url            string  — original site URL
+
+import { esc } from '../lib/escape.js';
+
+function clean(s, max = 240) {
+  if (!s) return '';
+  return String(s).replace(/\s+/g, ' ').trim().slice(0, max);
+}
+
+// Pull a vertical-aware CTA pair, preferring qwen3-enriched copy_pack values.
+function ctas(ctx) {
+  return {
+    primary:   ctx.cta_primary   || 'Get in touch',
+    secondary: ctx.cta_secondary || 'Learn more',
+  };
+}
+
+// Pick 3 cards from copy_pack if present, else nothing — caller falls back.
+function packCards(ctx) {
+  if (Array.isArray(ctx.cards) && ctx.cards.length >= 3) {
+    return ctx.cards.slice(0, 3).map(c => ({
+      title: clean(c.title || '', 32).toUpperCase(),
+      body:  clean(c.body  || '', 200),
+    }));
+  }
+  return null;
+}
+
+function igUrl(handle) {
+  if (!handle) return null;
+  const h = handle.replace(/^@/, '').trim();
+  return h ? `https://instagram.com/${encodeURIComponent(h)}` : null;
+}
+
+function pickColors(palette = [], fallback = ['#111111', '#facc15']) {
+  const p = palette.filter(c => /^#[0-9a-f]{6}$/i.test(c));
+  return [p[0] || fallback[0], p[1] || fallback[1], p[2] || '#ffffff'];
+}
+
+// Deterministic hash → number-in-range. Keeps the same project always rendering
+// the same SVG texture across reloads.
+function hashToInt(s, max) {
+  let h = 5381;
+  for (let i = 0; i < s.length; i += 1) h = (h * 33 + s.charCodeAt(i)) >>> 0;
+  return h % max;
+}
+
+// Inline-SVG hero "art" so every mockup has visual identity even when there's
+// no real hero_image. Picks one of N patterns hashed off the project title +
+// tints them with the project's palette. Returns a `data:image/svg+xml,...`
+// usable as a CSS background.
+function svgHero(ctx, [c1, c2]) {
+  const seed = ctx.title || ctx.url || 'x';
+  const variant = hashToInt(seed, 6);
+  const a = encodeURIComponent(c1);
+  const b = encodeURIComponent(c2);
+  const svgs = [
+    // 0 — concentric circles
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='none' stroke='${b}' stroke-width='2' opacity='.5'>${[...Array(12)].map((_,i)=>`<circle cx='300' cy='400' r='${30+i*32}'/>`).join('')}</g></svg>`,
+    // 1 — diagonal stripes
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='10' opacity='.6'>${[...Array(20)].map((_,i)=>`<line x1='${-200+i*60}' y1='0' x2='${200+i*60}' y2='800'/>`).join('')}</g></svg>`,
+    // 2 — grid of dots
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g fill='${b}'>${[...Array(10)].flatMap((_,r)=>[...Array(8)].map((__,c)=>`<circle cx='${30+c*75}' cy='${30+r*82}' r='8'/>`)).join('')}</g></svg>`,
+    // 3 — zigzag
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?100:50}`).join(' ')}'/><polyline fill='none' stroke='${b}' stroke-width='12' opacity='.7' points='${[...Array(40)].map((_,i)=>`${i*20},${i%2?500:450}`).join(' ')}'/></svg>`,
+    // 4 — overlapping rectangles
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><rect x='80' y='60' width='320' height='400' fill='${b}' opacity='.6'/><rect x='200' y='220' width='320' height='480' fill='${b}' opacity='.4'/><rect x='40' y='400' width='280' height='320' fill='${b}' opacity='.7'/></svg>`,
+    // 5 — sun rays
+    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 800'><rect width='600' height='800' fill='${a}'/><g stroke='${b}' stroke-width='4' opacity='.55'>${[...Array(36)].map((_,i)=>`<line x1='300' y1='400' x2='${300+Math.cos(i/36*Math.PI*2)*600}' y2='${400+Math.sin(i/36*Math.PI*2)*600}'/>`).join('')}</g><circle cx='300' cy='400' r='80' fill='${b}'/></svg>`,
+  ];
+  const svg = svgs[variant].replace(/'/g, '"');
+  return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 1 · EDITORIAL
+// Magazine-grade serif hero, generous whitespace, hairline rules, subtle warmth.
+// ---------------------------------------------------------------------------
+function renderEditorial(ctx) {
+  const [c1, c2] = pickColors(ctx.palette, ['#1a1a1a', '#b8956a']);
+  const T = clean(ctx.title, 80) || 'Untitled';
+  const sub = clean(ctx.tagline, 240) || 'A new way to see your business online.';
+  const ig = igUrl(ctx.ig_handle);
+  const heroBg = ctx.hero_image
+    ? `url(${esc(ctx.hero_image)}) center/cover`
+    : `${svgHero(ctx, [c1, c2])} center/cover, ${c1}11`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Editorial</title>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Inter',system-ui,sans-serif;background:#fafaf6;color:${c1};line-height:1.55}
+.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
+header{padding:32px 0;border-bottom:1px solid ${c1}22;display:flex;justify-content:space-between;align-items:center}
+.lg{font-family:'Cormorant Garamond',serif;font-size:28px;letter-spacing:.02em}
+nav a{margin-left:28px;font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:${c1};text-decoration:none}
+.hero{padding:80px 0 64px;display:grid;grid-template-columns:1.4fr 1fr;gap:64px;align-items:end}
+.hero h1{font-family:'Cormorant Garamond',serif;font-weight:500;font-size:88px;line-height:.95;letter-spacing:-.02em;margin-bottom:24px}
+.hero h1 em{color:${c2};font-style:italic}
+.hero p{font-size:19px;color:${c1}cc;max-width:42ch}
+.hero-img{aspect-ratio:4/5;background:${heroBg};border-radius:2px}
+.cta{display:inline-block;margin-top:28px;padding:14px 28px;border:1px solid ${c1};color:${c1};text-decoration:none;font-size:13px;letter-spacing:.12em;text-transform:uppercase}
+.cta:hover{background:${c1};color:#fff}
+.feat{padding:80px 0;border-top:1px solid ${c1}22;display:grid;grid-template-columns:repeat(3,1fr);gap:48px}
+.f h3{font-family:'Cormorant Garamond',serif;font-size:28px;font-weight:500;margin-bottom:12px}
+.f p{font-size:15px;color:${c1}aa}
+footer{margin-top:64px;padding:32px 0;border-top:1px solid ${c1}22;font-size:13px;color:${c1}88;display:flex;justify-content:space-between}
+</style></head><body>
+<header><div class="wrap" style="display:flex;justify-content:space-between;width:100%;align-items:center">
+  <div class="lg">${esc(T)}</div>
+  <nav><a>About</a><a>Services</a><a>Journal</a><a>Contact</a>${ig ? `<a href="${ig}">Instagram</a>` : ''}</nav>
+</div></header>
+<main class="wrap">
+  <section class="hero">
+    <div>
+      <h1>${esc(T)} <em>—</em><br>quietly considered.</h1>
+      <p>${esc(sub)}</p>
+      <a class="cta" href="#">${esc(ctas(ctx).primary)} →</a>
+    </div>
+    <div class="hero-img"></div>
+  </section>
+  <section class="feat">
+    ${(() => { const ec = packCards(ctx) || [{title:'Considered',body:'Every detail chosen, never templated. Materials, tone, light — held to the same bar.'},{title:'Familiar',body:'Repeat clients are the entire business. We learn the way you like things and keep them that way.'},{title:'Available',body:'Same-week appointments, calm rooms, and a real person who answers the phone.'}]; return ec.map(c=>`<div class="f"><h3>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</h3><p>${esc(c.body)}</p></div>`).join('\n    '); })()}
+  </section>
+</main>
+<footer><div class="wrap" style="display:flex;justify-content:space-between;width:100%">
+  <span>© ${new Date().getFullYear()} ${esc(T)}</span>
+  <span>${ig ? `<a href="${ig}" style="color:inherit">${esc(ctx.ig_handle)}</a>` : ''}</span>
+</div></footer>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 2 · BRUTALIST
+// Black/yellow blocks, hard borders, Archivo Black + Space Mono, tape-marquee.
+// Mirrors sdccanalysis 09-brutalist mockup.
+// ---------------------------------------------------------------------------
+function renderBrutalist(ctx) {
+  const T = (clean(ctx.title, 60) || 'Your business').toUpperCase();
+  const sub = clean(ctx.tagline, 200) || 'A new way to see your business online.';
+  const ig = igUrl(ctx.ig_handle);
+  // Build 3 brutalist cards from the actual description sentences instead of
+  // the static "NO TEMPLATE / NO FLUFF / NO BS" filler. If the project's
+  // tagline doesn't have 3 sentences, fall back to vertical-tinted defaults.
+  const VERT_DEFAULTS = {
+    salon:        ['EXPERT', 'TIMELESS', 'YOURS'],
+    restaurant:   ['SEASONAL', 'WALK-IN', 'FAMILY'],
+    lawyer:       ['DIRECT', 'PROVEN', 'CALL US'],
+    vet:          ['GENTLE', 'RAPID', 'TRUSTED'],
+    advocacy:     ['URGENT', 'FUNDED', 'NOW'],
+    'saas-landing': ['FAST', 'BUILT', 'OPEN'],
+    ecom:         ['CRAFTED', 'STOCKED', 'SHIPPED'],
+    generic:      ['NO TEMPLATE', 'NO FLUFF', 'NO BS'],
+  };
+  // ★ Prefer qwen3-enriched copy_pack cards when present.
+  const enriched = packCards(ctx);
+  const cardWords = enriched ? enriched.map(c => c.title) : (VERT_DEFAULTS[ctx.vertical] || VERT_DEFAULTS.generic);
+  const sentences = (ctx.tagline || '').split(/[.!?]+/).map(s => s.trim()).filter(s => s.length >= 16).slice(0, 3);
+  const cardBodies = enriched
+    ? enriched.map(c => c.body)
+    : [0, 1, 2].map(i => {
+        const sn = sentences[i];
+        if (sn) return clean(sn, 140);
+        return ['Hand-typeset. Hand-coded. Every block earned its place.',
+                'Real prices, real hours, real photos. No stock-photo theater.',
+                'If we say we\'ll call back, we call back. That\'s the whole pitch.'][i];
+      });
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Brutalist</title>
+<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Space Mono',monospace;background:#fff;color:#000;line-height:1.3}
+.tape{background:#facc15;border-bottom:4px solid #000;padding:8px 0;overflow:hidden;white-space:nowrap;font-size:13px;font-weight:700;letter-spacing:.04em}
+.tape span{display:inline-block;animation:scroll 40s linear infinite}
+.tape span::after{content:'  ★  ${esc(T)}  ★  ${esc(ctx.url)}  ★  ${ig ? `${esc(ctx.ig_handle)}  ★  ` : ''}  '}
+@keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
+.hd{background:#000;color:#fff;border-bottom:4px solid #000}
+.hd-inner{max-width:1320px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between}
+.lg{font-family:'Archivo Black',sans-serif;font-size:32px;letter-spacing:-.04em;text-transform:uppercase}
+.lg::after{content:'.';color:#facc15}
+.nv{display:flex;font-size:12px;font-weight:700;text-transform:uppercase}
+.nv a{padding:8px 16px;border:1px solid #fff;margin-right:-1px;color:#fff;text-decoration:none}
+.nv a.active{background:#facc15;color:#000}
+.hero{padding:64px 24px;border-bottom:4px solid #000}
+.hero-inner{max-width:1320px;margin:0 auto;display:grid;grid-template-columns:1.1fr 1fr;gap:32px;align-items:center}
+.hero h1{font-family:'Archivo Black',sans-serif;font-size:120px;line-height:.85;letter-spacing:-.05em;text-transform:uppercase}
+.hero h1 mark{background:#facc15;padding:0 8px}
+.hero-side{border-left:4px solid #000;padding-left:32px}
+.hero-side p{font-size:17px;line-height:1.4;font-weight:500;margin-bottom:14px}
+.cta{display:inline-block;background:#000;color:#fff;padding:18px 32px;font-family:'Archivo Black',sans-serif;font-size:18px;letter-spacing:.04em;text-transform:uppercase;border:4px solid #000;text-decoration:none}
+.cta:hover{background:#facc15;color:#000}
+.cards{padding:48px 24px;border-bottom:4px solid #000}
+.cards-inner{max-width:1320px;margin:0 auto}
+.cards-grid{display:grid;grid-template-columns:repeat(3,1fr);border:4px solid #000}
+.cd{padding:28px;border-right:4px solid #000}
+.cd:last-child{border-right:0}
+.cd:nth-child(even){background:#fef3c7}
+.cd-tag{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:#000;color:#fff;padding:3px 10px;margin-bottom:14px}
+.cd h3{font-family:'Archivo Black',sans-serif;font-size:26px;line-height:1;text-transform:uppercase;margin-bottom:14px}
+.ft{background:#000;color:#fff;padding:32px 24px;text-align:center;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
+</style></head><body>
+<div class="tape"><span></span></div>
+<header class="hd"><div class="hd-inner">
+  <div class="lg">${esc(T.split(' ')[0])}</div>
+  <nav class="nv"><a class="active">HOME</a><a>WORK</a><a>STORE</a><a>ABOUT</a>${ig ? `<a href="${ig}">IG</a>` : ''}</nav>
+</div></header>
+<section class="hero"><div class="hero-inner">
+  <h1>${esc(T.split(' ').slice(0,2).join(' '))}<br><mark>BOLD.</mark></h1>
+  <div class="hero-side">
+    <p>${esc(sub)}</p>
+    <a class="cta" href="#">${esc(ctas(ctx).primary.toUpperCase())} →</a>
+  </div>
+</div></section>
+<section class="cards"><div class="cards-inner">
+  <div class="cards-grid">
+    <div class="cd"><span class="cd-tag">★ 01</span><h3>${esc(cardWords[0])}</h3><p>${esc(cardBodies[0])}</p></div>
+    <div class="cd"><span class="cd-tag">★ 02</span><h3>${esc(cardWords[1])}</h3><p>${esc(cardBodies[1])}</p></div>
+    <div class="cd"><span class="cd-tag">★ 03</span><h3>${esc(cardWords[2])}</h3><p>${esc(cardBodies[2])}</p></div>
+  </div>
+</div></section>
+<footer class="ft">© ${new Date().getFullYear()} ${esc(T)} ★ ${esc(ctx.url)}</footer>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 3 · MINIMALIST
+// Single column, lots of whitespace, system font, off-white. Squarespace-killer.
+// ---------------------------------------------------------------------------
+function renderMinimalist(ctx) {
+  const [c1, c2] = pickColors(ctx.palette, ['#0f0f0f', '#6b6b6b']);
+  const T = clean(ctx.title, 80) || 'Untitled';
+  const sub = clean(ctx.tagline, 240) || 'A new way to see your business online.';
+  const ig = igUrl(ctx.ig_handle);
+  const heroBg = ctx.hero_image
+    ? `url(${esc(ctx.hero_image)}) center/cover`
+    : `${svgHero(ctx, ['#f5f5f5', c2])} center/cover`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Minimalist</title>
+<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Inter',system-ui,sans-serif;background:#fefefe;color:${c1};line-height:1.6;font-weight:300}
+.wrap{max-width:760px;margin:0 auto;padding:0 24px}
+header{padding:48px 0 32px;display:flex;justify-content:space-between;align-items:center;font-size:14px;letter-spacing:.02em}
+.lg{font-weight:500}
+nav a{margin-left:28px;color:${c2};text-decoration:none}
+nav a:hover{color:${c1}}
+.hero{padding:96px 0 80px;text-align:center}
+.hero-img{width:100%;aspect-ratio:16/9;background:${heroBg};margin-bottom:64px;border-radius:6px}
+.hero h1{font-size:56px;font-weight:300;letter-spacing:-.03em;line-height:1.05;margin-bottom:24px}
+.hero p{font-size:19px;color:${c2};max-width:52ch;margin:0 auto}
+.cta{display:inline-block;margin-top:40px;padding:14px 36px;background:${c1};color:#fff;text-decoration:none;border-radius:99px;font-size:14px;font-weight:500}
+.feat{padding:80px 0;display:grid;grid-template-columns:repeat(3,1fr);gap:48px;border-top:1px solid #eee}
+.f{font-size:15px;color:${c2}}
+.f h3{font-size:18px;font-weight:500;color:${c1};margin-bottom:8px}
+footer{padding:48px 0;border-top:1px solid #eee;font-size:13px;color:${c2};text-align:center}
+</style></head><body>
+<div class="wrap">
+<header>
+  <div class="lg">${esc(T)}</div>
+  <nav><a>Work</a><a>About</a><a>Contact</a>${ig ? `<a href="${ig}">Instagram</a>` : ''}</nav>
+</header>
+<section class="hero">
+  <div class="hero-img"></div>
+  <h1>${esc(T)}</h1>
+  <p>${esc(sub)}</p>
+  <a class="cta" href="#">${esc(ctas(ctx).primary)}</a>
+</section>
+<section class="feat">
+  ${(() => { const ec = packCards(ctx) || [{title:'Quiet',body:"No pop-ups. No banners. No cookies you didn't ask for."},{title:'Fast',body:'Loads in under a second on any phone, anywhere.'},{title:'Yours',body:'Owned, hosted, and editable by you — not us.'}]; return ec.map(c=>`<div class="f"><h3>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</h3><p>${esc(c.body)}</p></div>`).join('\n  '); })()}
+</section>
+<footer>© ${new Date().getFullYear()} · ${esc(T)} · <a href="${esc(ctx.url)}" style="color:inherit">${esc(ctx.url.replace(/^https?:\/\//,''))}</a></footer>
+</div>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 4 · MAGAZINE
+// Editorial weeklies: oversized hero number/date, alternating columns, big serif drop-cap.
+// ---------------------------------------------------------------------------
+function renderMagazine(ctx) {
+  const [c1, c2] = pickColors(ctx.palette, ['#0a0a0a', '#d63b3b']);
+  const T = clean(ctx.title, 80) || 'Untitled';
+  const sub = clean(ctx.tagline, 320) || 'A new way to see your business online.';
+  const ig = igUrl(ctx.ig_handle);
+  const heroBg = ctx.hero_image
+    ? `url(${esc(ctx.hero_image)}) center/cover`
+    : `${svgHero(ctx, [c1, c2])} center/cover`;
+  const issueNum = (() => { let h=0; for (const c of (ctx.title||'x')) h = (h*31 + c.charCodeAt(0)) >>> 0; return String(100 + (h % 899)); })();
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Magazine</title>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Inter',sans-serif;background:#fafaf6;color:${c1};line-height:1.5}
+.wrap{max-width:1280px;margin:0 auto;padding:0 32px}
+header{padding:24px 0;border-bottom:3px solid ${c1};display:flex;justify-content:space-between;align-items:baseline}
+.lg{font-family:'Playfair Display',serif;font-weight:900;font-size:36px;letter-spacing:-.02em}
+.issue{font-size:11px;font-weight:700;color:${c2};letter-spacing:.16em;text-transform:uppercase}
+nav a{margin-left:24px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:${c1};text-decoration:none;font-weight:600}
+.hero{padding:64px 0 48px;display:grid;grid-template-columns:120px 1fr;gap:48px}
+.hero-num{font-family:'Playfair Display',serif;font-weight:900;font-size:160px;line-height:.8;color:${c2}}
+.hero-num small{display:block;font-size:14px;font-weight:700;color:${c1};letter-spacing:.16em;text-transform:uppercase;margin-top:18px;font-family:'Inter',sans-serif}
+.hero-body h1{font-family:'Playfair Display',serif;font-weight:900;font-size:72px;line-height:.95;letter-spacing:-.02em;margin-bottom:18px}
+.hero-body p{font-size:19px;color:${c1}cc;max-width:60ch;line-height:1.5}
+.hero-img{aspect-ratio:21/9;background:${heroBg};margin-top:32px;border-bottom:3px solid ${c1}}
+.lead{padding:64px 0;display:grid;grid-template-columns:1fr 1fr 1fr;gap:48px;border-bottom:1px solid ${c1}33}
+.col p{font-size:15px;line-height:1.7;color:${c1}cc}
+.col p::first-letter{font-family:'Playfair Display',serif;font-weight:900;font-size:48px;color:${c2};float:left;line-height:1;margin:6px 8px 0 0}
+.col h3{font-family:'Playfair Display',serif;font-size:24px;font-weight:700;margin-bottom:12px;color:${c1}}
+.byline{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:${c2};font-weight:700;margin-bottom:14px}
+footer{padding:48px 0;border-top:3px solid ${c1};font-size:12px;color:${c1}99;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
+.hero-cta{display:inline-block;margin-top:20px;padding:12px 28px;background:${c1};color:#fafaf6;text-decoration:none;font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}
+.hero-cta:hover{background:${c2}}
+</style></head><body>
+<div class="wrap">
+<header>
+  <div class="lg">${esc(T)}</div>
+  <nav><a>The Issue</a><a>Features</a><a>Archive</a><a>Subscribe</a>${ig ? `<a href="${ig}">@${esc(ctx.ig_handle)}</a>` : ''}</nav>
+</header>
+<section class="hero">
+  <div class="hero-num">${issueNum}<small>Issue · ${new Date().getFullYear()}</small></div>
+  <div class="hero-body"><h1>${esc(T)}.</h1><p>${esc(sub)}</p><a class="hero-cta" href="#">${esc(ctas(ctx).primary)} →</a></div>
+</section>
+<div class="hero-img"></div>
+<section class="lead">
+  ${(() => { const ec = packCards(ctx); const bylines = ['Field Notes','In Practice','For Visitors']; const defaults = [{title:'What we make',body:sub.slice(0,200)},{title:'How it works',body:"Take it from the people who built it. Quiet rooms, the right tools, and the patience to do the same thing twice if it isn't right the first time."},{title:'Get in touch',body:'Calls return same-day. Email returns inside 24 hours. Walk-ins welcome between 11 and 5.'}]; const cards = ec ? ec.map(c=>({title:c.title.charAt(0)+c.title.slice(1).toLowerCase(),body:c.body})) : defaults; return cards.map((c,i)=>`<div class="col"><div class="byline">${esc(bylines[i])}</div><h3>${esc(c.title)}</h3><p>${esc(c.body)}</p></div>`).join('\n  '); })()}
+</section>
+<footer><div>${esc(T)} · Issue ${issueNum} · © ${new Date().getFullYear()}</div></footer>
+</div>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 5 · NEON
+// Saturated neon palette, glowing borders, dark bg, futurist sans, scanline detail.
+// ---------------------------------------------------------------------------
+function renderNeon(ctx) {
+  const [c1, c2] = pickColors(ctx.palette, ['#0d0721', '#e84393']);
+  const T = (clean(ctx.title, 80) || 'Untitled');
+  const sub = clean(ctx.tagline, 240) || 'A new way to see your business online.';
+  const ig = igUrl(ctx.ig_handle);
+  const accent2 = '#00f5d4';
+  const heroBg = ctx.hero_image
+    ? `url(${esc(ctx.hero_image)}) center/cover`
+    : `${svgHero(ctx, [c1, c2])} center/cover`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Neon</title>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Sora:wght@600;800&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'JetBrains Mono',monospace;background:${c1};color:#fff;line-height:1.5;min-height:100vh;overflow-x:hidden}
+body::before{content:'';position:fixed;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent 0 2px,#fff05 2px 3px);z-index:1}
+.wrap{max-width:1280px;margin:0 auto;padding:0 32px;position:relative;z-index:2}
+header{padding:32px 0;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid ${c2}66}
+.lg{font-family:'Sora',sans-serif;font-weight:800;font-size:28px;color:${c2};text-shadow:0 0 12px ${c2}88,0 0 24px ${c2}44;letter-spacing:-.02em}
+nav a{margin-left:18px;font-size:11px;color:${accent2};text-decoration:none;letter-spacing:.16em;text-transform:uppercase}
+nav a:hover{text-shadow:0 0 12px ${accent2}}
+.hero{padding:96px 0 64px;text-align:center}
+.hero-tag{display:inline-block;border:1px solid ${accent2};color:${accent2};padding:6px 14px;font-size:10px;letter-spacing:.16em;text-transform:uppercase;margin-bottom:24px;text-shadow:0 0 8px ${accent2}}
+.hero h1{font-family:'Sora',sans-serif;font-weight:800;font-size:104px;line-height:.9;letter-spacing:-.04em;margin-bottom:24px}
+.hero h1 span{color:${c2};text-shadow:0 0 24px ${c2}aa,0 0 48px ${c2}66}
+.hero p{font-size:17px;color:#fff8;max-width:60ch;margin:0 auto 36px}
+.hero a{display:inline-block;background:${c2};color:${c1};padding:18px 36px;text-decoration:none;font-family:'Sora',sans-serif;font-weight:800;font-size:14px;letter-spacing:.12em;text-transform:uppercase;border:0;box-shadow:0 0 24px ${c2}66}
+.hero a:hover{box-shadow:0 0 36px ${c2}aa}
+.hero-img{aspect-ratio:21/9;background:${heroBg};margin-top:64px;border-top:1px solid ${c2}66;border-bottom:1px solid ${c2}66;position:relative}
+.cards{padding:80px 0;display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
+.cd{border:1px solid ${c2}66;padding:24px;background:${c1}aa;backdrop-filter:blur(8px)}
+.cd:nth-child(2){border-color:${accent2}66}
+.cd-num{font-family:'Sora',sans-serif;font-weight:800;font-size:11px;letter-spacing:.16em;color:${c2};margin-bottom:14px}
+.cd:nth-child(2) .cd-num{color:${accent2}}
+.cd h3{font-family:'Sora',sans-serif;font-weight:800;font-size:22px;line-height:1;margin-bottom:14px;text-transform:uppercase;letter-spacing:-.01em}
+.cd p{font-size:13px;color:#fffb;line-height:1.6}
+footer{padding:48px 0;border-top:1px solid ${c2}66;font-size:11px;color:#fff66;letter-spacing:.16em;text-transform:uppercase;text-align:center}
+</style></head><body>
+<div class="wrap">
+<header><div class="lg">▲ ${esc(T)}</div><nav><a>Home</a><a>Work</a><a>Contact</a>${ig ? `<a href="${ig}">IG</a>` : ''}</nav></header>
+<section class="hero">
+  <div class="hero-tag">▶ Now Live · ${new Date().getFullYear()}</div>
+  <h1>${esc(T.split(' ').slice(0,2).join(' '))} <span>online.</span></h1>
+  <p>${esc(sub)}</p>
+  <a href="#">${esc(ctas(ctx).primary)} →</a>
+</section>
+<div class="hero-img"></div>
+<section class="cards">
+  ${(() => { const ec = packCards(ctx) || [{title:'BUILT IN',body:sub.slice(0,140)},{title:'ALWAYS ON',body:'Same-week response time. Late hours by appointment. Real human on the other end of every message.'},{title:'HAND BUILT',body:'No template. No fluff. Every detail tested by people who actually use it.'}]; const nums=['▲ 01','▲ 02','▲ 03']; return ec.map((c,i)=>`<div class="cd"><div class="cd-num">${nums[i]}</div><h3>${esc(c.title)}</h3><p>${esc(c.body)}</p></div>`).join('\n  '); })()}
+</section>
+<footer>${esc(T)} · © ${new Date().getFullYear()} ${ig ? `· ${esc(ctx.ig_handle)}` : ''}</footer>
+</div>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 6 · WARM
+// Veterinary / wellness / homey — soft sage + cream, rounded corners, hand-drawn feel.
+// ---------------------------------------------------------------------------
+function renderWarm(ctx) {
+  const [c1, c2] = pickColors(ctx.palette, ['#3f5645', '#e07a5f']);
+  const T = clean(ctx.title, 80) || 'Untitled';
+  const sub = clean(ctx.tagline, 280) || 'Care, on your schedule.';
+  const ig = igUrl(ctx.ig_handle);
+  const cream = '#f4ede0';
+  const sage = '#9aaf94';
+  const heroBg = ctx.hero_image
+    ? `url(${esc(ctx.hero_image)}) center/cover`
+    : `${svgHero(ctx, [sage, c2])} center/cover`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Warm</title>
+<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400;500;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Nunito',sans-serif;background:${cream};color:${c1};line-height:1.6}
+.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
+header{padding:28px 0;display:flex;justify-content:space-between;align-items:center}
+.lg{font-family:'Fraunces',serif;font-weight:700;font-size:30px;letter-spacing:-.01em}
+.lg::before{content:'❀ ';color:${c2}}
+nav a{margin-left:24px;font-size:14px;font-weight:600;color:${c1};text-decoration:none}
+.cta{background:${c2};color:#fff;padding:10px 22px;border-radius:99px;text-decoration:none;font-weight:700;font-size:14px}
+.hero{padding:80px 0 64px;display:grid;grid-template-columns:1.1fr 1fr;gap:64px;align-items:center}
+.hero h1{font-family:'Fraunces',serif;font-weight:700;font-size:72px;line-height:1;letter-spacing:-.02em;margin-bottom:24px}
+.hero h1 em{color:${c2};font-style:italic}
+.hero p{font-size:18px;color:${c1}cc;max-width:50ch;margin-bottom:28px}
+.hero-cta{display:inline-block;background:${c1};color:${cream};padding:16px 32px;border-radius:99px;text-decoration:none;font-weight:700;font-size:15px;margin-right:8px}
+.hero-link{color:${c1};text-decoration:underline;font-weight:600;font-size:14px}
+.hero-img{aspect-ratio:1;background:${heroBg};border-radius:36px;border:8px solid ${cream};box-shadow:0 8px 0 ${sage}66}
+.feat{padding:80px 0;background:${cream};display:grid;grid-template-columns:repeat(3,1fr);gap:32px;border-top:2px dashed ${sage}}
+.f{background:#fff;border-radius:24px;padding:28px;border:2px solid ${sage}33}
+.f-icon{width:48px;height:48px;background:${c2};color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Fraunces',serif;font-weight:700;font-size:22px;margin-bottom:14px}
+.f h3{font-family:'Fraunces',serif;font-size:22px;font-weight:700;margin-bottom:10px}
+.f p{font-size:14px;color:${c1}aa;line-height:1.55}
+footer{padding:48px 0;text-align:center;font-size:13px;color:${c1}88;border-top:2px dashed ${sage}}
+footer::before{content:'❀  ';color:${c2}}
+</style></head><body>
+<div class="wrap">
+<header>
+  <div class="lg">${esc(T)}</div>
+  <div><nav style="display:inline-flex"><a>About</a><a>Services</a><a>Hours</a>${ig ? `<a href="${ig}">${esc(ctx.ig_handle)}</a>` : ''}</nav>  <a class="cta" href="#">Book →</a></div>
+</header>
+<section class="hero">
+  <div>
+    <h1>${esc(T)}, <em>quietly cared for.</em></h1>
+    <p>${esc(sub)}</p>
+    <a class="hero-cta" href="#">${esc(ctas(ctx).primary)}</a>
+    <a class="hero-link" href="#">or ${esc(ctas(ctx).secondary.toLowerCase())}</a>
+  </div>
+  <div class="hero-img"></div>
+</section>
+<section class="feat">
+  ${(() => { const ec = packCards(ctx) || [{title:'WELCOME',body:'New visitors get a 15-minute orientation. No pressure, no upsell — just so you know what to expect.'},{title:'FAMILIAR',body:'We learn your preferences. Your file remembers what worked last time.'},{title:'AVAILABLE',body:'Same-week appointments. Saturday mornings. Real people who answer the phone.'}]; const icons=['A','B','C']; return ec.map((c,i)=>`<div class="f"><div class="f-icon">${icons[i]}</div><h3>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</h3><p>${esc(c.body)}</p></div>`).join('\n  '); })()}
+</section>
+<footer>${esc(T)} · © ${new Date().getFullYear()} ${ig ? `· ${esc(ctx.ig_handle)}` : ''}</footer>
+</div>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// THEME 7 · AUTHORITATIVE
+// Lawyer / professional / institutional — slate + gold, navy, narrow column, gravity.
+// ---------------------------------------------------------------------------
+function renderAuthoritative(ctx) {
+  const [c1, c2] = pickColors(ctx.palette, ['#0e1c36', '#b8965c']);
+  const T = clean(ctx.title, 80) || 'Untitled';
+  const sub = clean(ctx.tagline, 280) || 'Trusted counsel since 2000.';
+  const ig = igUrl(ctx.ig_handle);
+  const heroBg = ctx.hero_image
+    ? `url(${esc(ctx.hero_image)}) center/cover`
+    : `${svgHero(ctx, [c1, c2])} center/cover`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(T)} — Authoritative</title>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'IBM Plex Sans',sans-serif;background:#fff;color:${c1};line-height:1.6}
+.wrap{max-width:1180px;margin:0 auto;padding:0 40px}
+.topbar{background:${c1};color:#fffaf0;padding:8px 0;font-size:12px;letter-spacing:.04em}
+.topbar-inner{max-width:1180px;margin:0 auto;padding:0 40px;display:flex;justify-content:space-between}
+.topbar a{color:${c2};text-decoration:none;margin-left:24px}
+header{padding:36px 0;border-bottom:2px solid ${c2};display:flex;justify-content:space-between;align-items:baseline}
+.lg{font-family:'Cormorant Garamond',serif;font-weight:700;font-size:36px;letter-spacing:.02em}
+.lg em{font-style:italic;color:${c2};font-weight:500}
+nav a{margin-left:28px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:${c1};text-decoration:none;font-weight:500}
+.hero{padding:96px 0 80px;display:grid;grid-template-columns:1fr 1fr;gap:64px;align-items:center}
+.hero h1{font-family:'Cormorant Garamond',serif;font-weight:700;font-size:72px;line-height:1.05;letter-spacing:-.01em;margin-bottom:18px}
+.hero h1 em{color:${c2};font-style:italic;font-weight:500}
+.eyebrow{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:${c2};font-weight:600;margin-bottom:18px}
+.hero p{font-size:17px;color:${c1}cc;max-width:46ch;margin-bottom:28px}
+.hero-cta{display:inline-block;background:${c1};color:#fffaf0;padding:18px 40px;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;border:1px solid ${c1}}
+.hero-cta:hover{background:${c2};border-color:${c2}}
+.hero-img{aspect-ratio:4/5;background:${heroBg};border:8px solid ${c1}}
+.creds{padding:64px 0;border-top:1px solid ${c1}22;border-bottom:1px solid ${c1}22;display:grid;grid-template-columns:repeat(4,1fr);gap:32px;text-align:center}
+.cr-num{font-family:'Cormorant Garamond',serif;font-size:54px;font-weight:700;color:${c2};line-height:1;margin-bottom:8px}
+.cr-label{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:${c1}99;font-weight:600}
+.areas{padding:80px 0;display:grid;grid-template-columns:1fr 2fr;gap:64px}
+.areas h2{font-family:'Cormorant Garamond',serif;font-weight:700;font-size:48px;line-height:1;letter-spacing:-.01em}
+.areas h2 em{color:${c2};font-style:italic;font-weight:500}
+.areas-list{display:grid;grid-template-columns:1fr 1fr;gap:8px 32px}
+.areas-list li{list-style:none;padding:14px 0;border-bottom:1px solid ${c1}22;font-size:15px;font-weight:500}
+.areas-list li::before{content:'§ ';color:${c2};font-family:'Cormorant Garamond',serif;font-weight:700;font-size:18px}
+footer{background:${c1};color:#fffaf0aa;padding:40px 0;font-size:12px;letter-spacing:.08em;text-align:center}
+footer span{color:${c2}}
+</style></head><body>
+<div class="topbar"><div class="topbar-inner"><span>Established 2000 · Licensed in this jurisdiction</span><span><a href="#">Free Consultation</a><a href="#">Schedule Call</a></span></div></div>
+<div class="wrap">
+<header>
+  <div class="lg">${esc(T)}<em>.</em></div>
+  <nav><a>Practice Areas</a><a>Attorneys</a><a>Results</a><a>Contact</a>${ig ? `<a href="${ig}">${esc(ctx.ig_handle)}</a>` : ''}</nav>
+</header>
+<section class="hero">
+  <div>
+    <div class="eyebrow">Trusted Counsel · Discreet Service</div>
+    <h1>${esc(T)} <em>—</em> when it matters most.</h1>
+    <p>${esc(sub)}</p>
+    <a class="hero-cta" href="#">${esc(ctas(ctx).primary)} →</a>
+  </div>
+  <div class="hero-img"></div>
+</section>
+<section class="creds">
+  <div><div class="cr-num">25+</div><div class="cr-label">Years Practice</div></div>
+  <div><div class="cr-num">∞</div><div class="cr-label">Confidential</div></div>
+  <div><div class="cr-num">9.6</div><div class="cr-label">Avvo Rating</div></div>
+  <div><div class="cr-num">★★★</div><div class="cr-label">Peer Reviewed</div></div>
+</section>
+<section class="areas">
+  <div><h2>Practice <em>areas.</em></h2></div>
+  <ul class="areas-list">
+    ${(() => { const ec = packCards(ctx); if (ec) { return ec.map(c=>`<li>${esc(c.title.charAt(0)+c.title.slice(1).toLowerCase())}</li>`).join(''); } return '<li>Initial consultation</li><li>Case evaluation</li><li>Document review</li><li>Court representation</li><li>Settlement negotiation</li><li>Appeal preparation</li>'; })()}
+  </ul>
+</section>
+</div>
+<footer>${esc(T)} · © ${new Date().getFullYear()} · <span>Attorney Advertising</span> · Past results do not guarantee future outcomes</footer>
+</body></html>`;
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+const RENDERERS = {
+  editorial:        renderEditorial,
+  brutalist:        renderBrutalist,
+  minimalist:       renderMinimalist,
+  // aliases per pickThemesFor() — all map to one of the 3 above for now.
+  'minimalist-spa': renderMinimalist,
+  // ★ Distinct renderers (4 new) — were aliases, now real
+  magazine:         renderMagazine,
+  neon:             renderNeon,
+  warm:             renderWarm,
+  authoritative:    renderAuthoritative,
+  // ★ Still aliases (low-traffic verticals reuse closest match)
+  'tools-forward':  renderBrutalist,
+};
+
+export function renderTheme(theme, ctx) {
+  const fn = RENDERERS[theme] || RENDERERS.editorial;
+  return fn(ctx);
+}
+
+export function listThemes() {
+  return Object.keys(RENDERERS);
+}
diff --git a/src/render/website-analysis.js b/src/render/website-analysis.js
new file mode 100644
index 0000000..f270671
--- /dev/null
+++ b/src/render/website-analysis.js
@@ -0,0 +1,480 @@
+// website-analysis app — entry page + result page (brutalist theme).
+//
+// Brutalist design system:
+//   bg #fff, ink #000, accent #facc15, secondary-accent #dc2626 (sparingly)
+//   typefaces: 'Archivo Black' (display) + 'Space Mono' (UI/body)
+//   hard borders, no rounded corners, tape-marquee, no-nonsense.
+
+import { esc } from '../lib/escape.js';
+import { computeGrade } from '../lib/audit-grade.js';
+
+const HEAD = (title) => `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)}</title>
+<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>
+:root{--bg:#fff;--ink:#000;--paper:#fafaf6;--accent:#facc15;--muted:#666;--soft:#fef3c7;--soft-hi:#fef2f2;--green:#f0fdf4}
+[data-theme="dark"]{--bg:#0a0a08;--ink:#fafaf6;--paper:#1a1a18;--accent:#facc15;--muted:#999;--soft:#1f1f1d;--soft-hi:#1f0a0a;--green:#0a2014}
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Space Mono',monospace;background:var(--bg);color:var(--ink);line-height:1.4;min-height:100vh;display:flex;flex-direction:column;transition:background .15s,color .15s}
+a{color:inherit}
+.theme-toggle{background:transparent;color:#fff;border:1px solid #fff;padding:8px 12px;font-family:'Archivo Black',sans-serif;font-size:12px;cursor:pointer;text-transform:uppercase;letter-spacing:.04em;margin-left:8px}
+.theme-toggle:hover{background:var(--accent);color:#000;border-color:var(--accent)}
+.tape{background:#facc15;border-bottom:4px solid #000;padding:8px 0;overflow:hidden;white-space:nowrap;font-size:13px;font-weight:700;letter-spacing:.04em}
+.tape span{display:inline-block;animation:scroll 40s linear infinite}
+.tape span::after{content:'  ★  WEBSITE ANALYSIS  ★  POWERED BY 7 SKILLS  ★  AGENT ABRAMS  ★  '}
+@keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
+.hd{background:#000;color:#fff;border-bottom:4px solid #000}
+.hd-inner{max-width:1320px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px}
+.lg{font-family:'Archivo Black',sans-serif;font-size:28px;letter-spacing:-.04em;text-transform:uppercase}
+.lg::after{content:'.';color:#facc15}
+.nv{display:flex;font-size:12px;font-weight:700;text-transform:uppercase}
+.nv a{padding:8px 14px;border:1px solid #fff;margin-right:-1px;text-decoration:none}
+.nv a.active{background:#facc15;color:#000}
+main{flex:1}
+.ft{background:#000;color:#aaa;padding:24px;text-align:center;font-size:11px;letter-spacing:.04em;text-transform:uppercase;border-top:4px solid #000}
+.ft a{color:#facc15;text-decoration:none}
+</style>
+<script>(function(){try{var t=localStorage.getItem('lsa-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark')}catch(e){}})()</script>
+</head><body>
+<div class="tape"><span></span></div>
+<header class="hd"><div class="hd-inner">
+  <div class="lg">WEBSITE / ANALYSIS</div>
+  <nav class="nv"><a class="active" href="/website-analysis">START</a><a href="/wall">THE WALL</a><a href="/grades">GRADES</a><button class="theme-toggle" id="theme-toggle" title="dark/light">★ <span id="theme-icon">☾</span></button></nav>
+</div></header>
+<script>(function(){const t=document.getElementById('theme-toggle'),i=document.getElementById('theme-icon');function r(){const d=document.documentElement.getAttribute('data-theme')==='dark';if(i)i.textContent=d?'☀':'☾'}r();t?.addEventListener('click',()=>{const d=document.documentElement.getAttribute('data-theme')==='dark';if(d){document.documentElement.removeAttribute('data-theme');localStorage.setItem('lsa-theme','light')}else{document.documentElement.setAttribute('data-theme','dark');localStorage.setItem('lsa-theme','dark')}r()})})()</script>
+<main>`;
+
+const FOOT = `</main>
+<footer class="ft">© ${new Date().getFullYear()} AGENT ABRAMS ★ ALL ANALYSIS RUNS LOCALLY ★ <a href="https://sdccanalysis.agentabrams.com">REFERENCE PATTERN</a></footer>
+</body></html>`;
+
+// ---------------------------------------------------------------------------
+// /website-analysis — entry page
+// ---------------------------------------------------------------------------
+export function renderEntry({ error = '' } = {}) {
+  return HEAD('Website Analysis — Get Started') + `
+<section style="padding:96px 24px;border-bottom:4px solid #000">
+  <div style="max-width:880px;margin:0 auto">
+    <div style="display:inline-block;border:3px solid #000;padding:8px 14px;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin-bottom:24px;transform:rotate(-1.5deg);background:#dc2626;color:#fff">★ ANALYZE ANY SITE ★</div>
+    <h1 style="font-family:'Archivo Black',sans-serif;font-size:120px;line-height:.85;letter-spacing:-.05em;text-transform:uppercase;margin-bottom:24px">SEE YOUR<br>SITE — <mark style="background:#facc15;padding:0 8px">REBUILT.</mark></h1>
+    <p style="font-size:18px;line-height:1.5;font-weight:500;max-width:60ch;margin-bottom:48px">Drop in your website. Add your Instagram. We'll show you your site as it is — and three new directions you could take it. No signup. No spam.</p>
+
+    ${error ? `<div style="border:4px solid #dc2626;background:#fef2f2;color:#dc2626;padding:16px;font-weight:700;margin-bottom:24px;text-transform:uppercase;font-size:13px">★ ${esc(error)}</div>` : ''}
+
+    <form method="post" action="/api/website-analysis" style="display:grid;gap:0;border:4px solid #000;background:#fff">
+      <div style="display:grid;grid-template-columns:200px 1fr;border-bottom:4px solid #000">
+        <label for="url" style="background:#000;color:#facc15;padding:24px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;border-right:4px solid #000;display:flex;align-items:center">★ WEBSITE</label>
+        <input id="url" name="url" type="text" required placeholder="yourbusiness.com" autocomplete="url"
+          style="padding:24px;border:0;font-family:'Space Mono',monospace;font-size:18px;font-weight:700;background:#fff;outline:none">
+      </div>
+      <div style="display:grid;grid-template-columns:200px 1fr;border-bottom:4px solid #000">
+        <label for="ig" style="background:#000;color:#facc15;padding:24px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;border-right:4px solid #000;display:flex;align-items:center">★ INSTAGRAM</label>
+        <input id="ig" name="ig_handle" type="text" placeholder="@yourbusiness  (optional)"
+          style="padding:24px;border:0;font-family:'Space Mono',monospace;font-size:18px;font-weight:700;background:#fff;outline:none">
+      </div>
+      <button type="submit"
+        style="background:#facc15;color:#000;padding:28px;border:0;font-family:'Archivo Black',sans-serif;font-size:24px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;border-top:0">
+        GET STARTED →
+      </button>
+    </form>
+
+    <p style="margin-top:32px;font-size:12px;color:#666;font-weight:700;text-transform:uppercase;letter-spacing:.04em">★ NO ACCOUNT ★ NO STORED PII ★ ANALYSIS RUNS IN ~30 SECONDS · <a href="/wall" style="color:#000;text-decoration:none;border-bottom:2px solid #facc15">SEE THE WALL →</a></p>
+  </div>
+</section>
+
+<section style="padding:48px 24px;background:#000;color:#facc15;border-bottom:4px solid #000">
+  <div style="max-width:880px;margin:0 auto">
+    <div style="display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:18px;margin-bottom:18px">
+      <div>
+        <div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#facc15;margin-bottom:6px">★ NO COMMITMENT</div>
+        <h2 style="font-family:'Archivo Black',sans-serif;font-size:40px;text-transform:uppercase;letter-spacing:-.02em;color:#fafaf6;margin:0">JUST WANT A ROAST?</h2>
+      </div>
+      <div style="font-size:13px;color:#aaa;font-weight:500;max-width:32ch">qwen3:14b will read your URL and write a stand-up paragraph dragging your design choices. Loving but savage. Free. Local. ~10 sec.</div>
+    </div>
+    <form id="roast-form" style="display:flex;gap:0;border:4px solid #facc15;background:#000">
+      <input id="roast-url" type="text" required placeholder="https://yoursite.com" autocomplete="url"
+        style="flex:1;padding:18px;border:0;font-family:'Space Mono',monospace;font-size:16px;font-weight:700;background:#000;color:#facc15;outline:none">
+      <button type="submit"
+        style="background:#facc15;color:#000;padding:18px 28px;border:0;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;border-left:4px solid #facc15">★ ROAST ME →</button>
+    </form>
+    <div id="roast-out" style="margin-top:18px;font-family:'Space Mono',monospace;font-size:16px;line-height:1.5;color:#fafaf6;min-height:24px"></div>
+  </div>
+</section>
+
+<script>
+(function(){
+  const form = document.getElementById('roast-form');
+  const out = document.getElementById('roast-out');
+  if (!form || !out) return;
+  form.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const url = document.getElementById('roast-url').value.trim();
+    if (!url) return;
+    const u = /^https?:\\/\\//i.test(url) ? url : 'https://' + url;
+    out.style.color = '#aaa';
+    out.textContent = '★ qwen3 is judging you…';
+    try {
+      const r = await fetch('/api/roast-me', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: u }) });
+      const data = await r.json();
+      if (!r.ok) { out.style.color = '#f87171'; out.textContent = '★ ' + (data.error || 'something broke'); return; }
+      out.style.color = '#fafaf6';
+      const safe = (s) => s.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
+      const tweet = encodeURIComponent('★ qwen3 just roasted ' + data.host + ':\\n\\n"' + data.roast.slice(0, 200) + (data.roast.length > 200 ? '…' : '') + '"\\n\\n— roasted free by livesiteaudit.agentabrams.com');
+      const tweetUrl = 'https://twitter.com/intent/tweet?text=' + tweet;
+      out.innerHTML = '<div style="border-left:4px solid #facc15;padding:8px 16px;font-style:italic">' + safe(data.roast) + '</div>' +
+        '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:10px">' +
+          '<div style="font-size:11px;color:#aaa;letter-spacing:.06em;text-transform:uppercase">★ ROAST OF ' + safe(data.host) + '</div>' +
+          '<a href="' + tweetUrl + '" target="_blank" rel="noopener" style="background:#facc15;color:#000;padding:8px 14px;text-decoration:none;font-family:Archivo Black,sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em;border:3px solid #facc15">★ TWEET THIS ROAST →</a>' +
+        '</div>';
+    } catch (err) {
+      out.style.color = '#f87171';
+      out.textContent = '★ network error — try again';
+    }
+  });
+})();
+</script>
+
+<section style="padding:64px 24px;background:var(--soft)">
+  <div style="max-width:1180px;margin:0 auto">
+    <h2 style="font-family:'Archivo Black',sans-serif;font-size:40px;text-transform:uppercase;letter-spacing:-.02em;margin-bottom:32px">★ HOW IT WORKS</h2>
+    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0;border:4px solid var(--ink);background:var(--paper);color:var(--ink)">
+      <div style="padding:32px;border-right:4px solid #000">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:64px;color:#facc15;background:#000;display:inline-block;padding:0 12px;line-height:1;margin-bottom:16px">01</div>
+        <h3 style="font-family:'Archivo Black',sans-serif;font-size:22px;text-transform:uppercase;margin-bottom:12px">YOUR SITE</h3>
+        <p style="font-size:14px;line-height:1.5;font-weight:500">We pull your homepage, your colors, your images, your Instagram. Nothing private. All public stuff.</p>
+      </div>
+      <div style="padding:32px;border-right:4px solid #000;background:var(--soft)">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:64px;color:#facc15;background:#000;display:inline-block;padding:0 12px;line-height:1;margin-bottom:16px">02</div>
+        <h3 style="font-family:'Archivo Black',sans-serif;font-size:22px;text-transform:uppercase;margin-bottom:12px">3 REBUILDS</h3>
+        <p style="font-size:14px;line-height:1.5;font-weight:500">Three new directions — your same content, three completely different aesthetic systems. Pick one.</p>
+      </div>
+      <div style="padding:32px">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:64px;color:#facc15;background:#000;display:inline-block;padding:0 12px;line-height:1;margin-bottom:16px">03</div>
+        <h3 style="font-family:'Archivo Black',sans-serif;font-size:22px;text-transform:uppercase;margin-bottom:12px">SHIP IT</h3>
+        <p style="font-size:14px;line-height:1.5;font-weight:500">Like one? Push the button. We'll launch it on a free subdomain — or your own domain if you have one.</p>
+      </div>
+    </div>
+  </div>
+</section>
+` + FOOT;
+}
+
+// ---------------------------------------------------------------------------
+// /wa/:slug — result page (your site iframe + 3 mockup cards)
+// ---------------------------------------------------------------------------
+export function renderResult({ analysis, mockups }) {
+  const url = analysis.url;
+  const ig = analysis.ig_handle ? `@${String(analysis.ig_handle).replace(/^@/, '')}` : null;
+  const iframeBlocked = !!(analysis.meta_json && analysis.meta_json.iframe_blocked);
+  const grade = computeGrade({ analysis });
+  // Synthesize a palette deterministically from the slug + vertical when none
+  // was scraped, so the result page is never empty. Vertical-tinted base hue.
+  function synthPalette(slug, vertical) {
+    const VERT_HUE = { salon:330, restaurant:18, lawyer:215, vet:160, advocacy:0, 'saas-landing':210, ecom:285, generic:265 };
+    const base = VERT_HUE[vertical] ?? 220;
+    let h = 0; for (let i = 0; i < slug.length; i += 1) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
+    const start = (base + (h % 60) - 30 + 360) % 360;
+    const out = [];
+    for (let i = 0; i < 5; i += 1) {
+      const hue = (start + i * 24) % 360;
+      const sat = 55 + ((h >> i) & 0xf) * 2;
+      const lig = 32 + i * 12;
+      out.push(`hsl(${hue} ${sat}% ${lig}%)`);
+    }
+    return out;
+  }
+  const rawPalette = (analysis.meta_json?.palette || []).slice(0, 5);
+  const palette = rawPalette.length ? rawPalette : synthPalette(analysis.slug, analysis.vertical);
+  const paletteSource = rawPalette.length ? 'scraped' : 'synthesized';
+  const swatches = palette.map(c => `<div title="${esc(c)}" style="width:28px;height:28px;background:${esc(c)};border:3px solid #000"></div>`).join('');
+  const verticalPill = analysis.vertical
+    ? `<div style="display:inline-block;background:#000;color:#facc15;padding:4px 10px;font-family:'Archivo Black',sans-serif;font-size:12px;text-transform:uppercase;letter-spacing:.04em">★ ${esc(analysis.vertical)}</div>`
+    : '';
+
+  const cards = mockups.map(m => `
+    <div class="cd" style="display:block;border:4px solid var(--ink);background:var(--paper)">
+      <a href="/wa/${esc(analysis.slug)}/m/${esc(m.mockup_id)}" target="_blank" rel="noopener" style="display:block;text-decoration:none;color:#000">
+        <div style="background:#000;color:#facc15;padding:14px 20px;font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;border-bottom:4px solid #000;display:flex;justify-content:space-between;align-items:center">
+          <span>★ OPTION ${esc(m.mockup_id.split('-')[0])}</span>
+          <span>${esc(m.theme.toUpperCase())}</span>
+        </div>
+        <div style="aspect-ratio:4/3;position:relative;overflow:hidden;background:#fafaf6">
+          <iframe src="/wa/${esc(analysis.slug)}/m/${esc(m.mockup_id)}"
+                  style="position:absolute;inset:0;width:200%;height:200%;border:0;transform:scale(.5);transform-origin:0 0;pointer-events:none"
+                  loading="lazy" sandbox="allow-same-origin"></iframe>
+        </div>
+        <div style="padding:18px 20px;border-top:4px solid #000;background:var(--soft)">
+          <div style="font-family:'Archivo Black',sans-serif;font-size:20px;text-transform:uppercase;letter-spacing:-.01em;margin-bottom:6px">${esc(m.title)}</div>
+          <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666">★ CLICK TO PREVIEW FULL SIZE</div>
+        </div>
+      </a>
+      <form method="post" action="/api/wa/${esc(analysis.slug)}/ship/${esc(m.mockup_id)}" style="margin:0">
+        <button type="submit"
+          style="width:100%;background:#facc15;color:#000;border:0;border-top:4px solid #000;padding:18px;font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer">
+          ★ SHIP THIS REBUILD →
+        </button>
+      </form>
+    </div>
+  `).join('');
+
+  // We must ALWAYS see the screen. When iframe is blocked, fall back to a Playwright
+  // screenshot served from /wa/:slug/screenshot.png (disk-cached, 7-day TTL).
+  const shotUrl = `/wa/${esc(analysis.slug)}/screenshot.png`;
+  const yourSite = iframeBlocked
+    ? `<div style="border:4px solid #000;background:#fff;position:relative;overflow:hidden">
+        <a href="${esc(url)}" target="_blank" rel="noopener" style="display:block;line-height:0">
+          <img src="${shotUrl}" alt="screenshot of ${esc(new URL(url).hostname)}"
+               style="width:100%;height:auto;display:block;background:#f4f4f4 url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%2240%22><text x=%2260%22 y=%2225%22 font-family=%22monospace%22 font-size=%2212%22 text-anchor=%22middle%22 fill=%22%23999%22>capturing screenshot…</text></svg>') no-repeat center;min-height:240px"
+               onerror="this.parentElement.parentElement.innerHTML='<div style=&quot;padding:48px 32px;background:#000;color:#facc15;text-align:left&quot;><div style=&quot;font-size:11px;letter-spacing:.06em;text-transform:uppercase&quot;>★ COULD NOT CAPTURE PREVIEW</div><div style=&quot;font-family:Archivo Black,sans-serif;font-size:24px;text-transform:uppercase;color:#fff;margin-top:12px&quot;>OPEN ${esc(new URL(url).hostname).replace(/'/g,"&apos;")} TO SEE THE SITE</div><a href=&quot;${esc(url)}&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; style=&quot;display:inline-block;margin-top:18px;background:#facc15;color:#000;padding:12px 20px;text-decoration:none;font-family:Archivo Black,sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em&quot;>★ OPEN ↗</a></div>'">
+        </a>
+        <div style="position:absolute;top:14px;left:14px;background:#000;color:#facc15;padding:6px 12px;font-family:'Archivo Black',sans-serif;font-size:10px;letter-spacing:.06em;text-transform:uppercase;border:2px solid #facc15">★ STATIC SCREENSHOT — IFRAME BLOCKED</div>
+        <a href="${esc(url)}" target="_blank" rel="noopener" style="position:absolute;top:14px;right:14px;background:#facc15;color:#000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em;text-decoration:none;border:2px solid #000">OPEN LIVE ↗</a>
+      </div>`
+    : `<div style="border:4px solid #000;background:#fff;aspect-ratio:16/9;position:relative;overflow:hidden">
+        <img src="${shotUrl}" alt="" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top">
+        <iframe src="${esc(url)}" style="position:absolute;inset:0;width:100%;height:100%;border:0;background:transparent" loading="lazy" referrerpolicy="no-referrer"></iframe>
+      </div>`;
+
+  // Live audit panel — only present when audit-public-sites.mjs has run on this row.
+  const live = analysis.summary_json?.live_audit;
+  const liveColor = live?.grade?.score >= 80 ? '#22c55e' : live?.grade?.score >= 65 ? '#84cc16' : live?.grade?.score >= 45 ? '#facc15' : live?.grade?.score >= 25 ? '#f97316' : '#dc2626';
+  const fetchedAt = live?.fetched_at ? new Date(live.fetched_at) : null;
+  const fetchedAtStr = fetchedAt && !Number.isNaN(fetchedAt.getTime()) ? fetchedAt.toLocaleString() : 'unknown';
+  const liveScoreStr = String(live?.grade?.score ?? '');
+  const liveBytesKB  = Number.isFinite(live?.bytes) ? Math.round(live.bytes / 1024) : 0;
+  const liveFetchMs  = Number.isFinite(live?.fetch_ms) ? live.fetch_ms : 0;
+  const livePanel = live ? `
+<section style="padding:48px 24px;border-bottom:4px solid var(--ink);background:var(--paper)">
+  <div style="max-width:1320px;margin:0 auto">
+    <div style="display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:14px">
+      <div>
+        <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666;margin-bottom:6px">★ LIVE HTML AUDIT · FROM A FRESH FETCH</div>
+        <h2 style="font-family:'Archivo Black',sans-serif;font-size:32px;text-transform:uppercase;letter-spacing:-.02em">WHAT YOUR SITE ACTUALLY SAYS</h2>
+      </div>
+      <div title="Real HTML signals — title length, meta description, og:image, viewport, alt-text coverage, JSON-LD, etc."
+           style="display:flex;align-items:center;gap:14px;background:${liveColor};color:#000;border:4px solid #000;padding:8px 18px">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:42px;line-height:1">${esc(live.grade.letter)}</div>
+        <div>
+          <div style="font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em">★ LIVE HTML</div>
+          <div style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700">${esc(liveScoreStr)}/100</div>
+        </div>
+      </div>
+    </div>
+    <p style="font-size:13px;color:#444;margin-bottom:22px;font-weight:500;max-width:80ch">Signals from a live fetch: title, meta description, og:image, viewport, alt-text coverage, JSON-LD, HTTPS, lang attribute, canonical, h1 count.</p>
+    <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px">
+      <div style="border:4px solid #000;background:#fef2f2;padding:18px 22px">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink);margin-bottom:12px;border-bottom:2px solid var(--ink);padding-bottom:8px">TOP ISSUES</div>
+        ${(live.top_3_issues?.length ? live.top_3_issues : ['(none — site is well-tagged)']).map(i => `<div style="font-size:14px;font-weight:600;line-height:1.5;margin-bottom:6px">→ ${esc(i)}</div>`).join('')}
+      </div>
+      <div style="border:4px solid #000;background:#f0fdf4;padding:18px 22px">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink);margin-bottom:12px;border-bottom:2px solid var(--ink);padding-bottom:8px">WHAT'S WORKING</div>
+        ${(live.top_3_wins?.length ? live.top_3_wins : ['(none yet)']).map(w => `<div style="font-size:14px;font-weight:600;line-height:1.5;margin-bottom:6px">→ ${esc(w)}</div>`).join('')}
+      </div>
+    </div>
+    <div style="margin-top:14px;font-size:11px;font-weight:700;color:#666;font-family:'Space Mono',monospace">★ FETCHED ${esc(fetchedAtStr)} · ${esc(String(liveBytesKB))}KB · ${esc(String(liveFetchMs))}MS</div>
+  </div>
+</section>` : '';
+
+  // JSON-LD for /wa/:slug — review-style structured data with grade as rating.
+  const jsonLd = {
+    '@context': 'https://schema.org',
+    '@type': 'Review',
+    itemReviewed: { '@type': 'WebSite', url: analysis.url, name: analysis.url ? new URL(analysis.url).hostname : analysis.slug },
+    author: { '@type': 'Organization', name: 'Live Site Audit', url: 'https://livesiteaudit.agentabrams.com' },
+    ...(live ? { reviewRating: { '@type': 'Rating', ratingValue: live.grade?.score, bestRating: 100, worstRating: 0 } } : {}),
+    reviewBody: analysis.summary_json?.copy_pack?.tagline || analysis.summary_json?.tagline || analysis.meta_json?.tagline || '',
+    url: `https://livesiteaudit.agentabrams.com/wa/${analysis.slug}`,
+  };
+  return HEAD(`${analysis.slug} — Website Analysis`).replace('</head>', `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script></head>`) + `
+<section style="padding:48px 24px 24px;border-bottom:4px solid #000">
+  <div style="max-width:1320px;margin:0 auto">
+    <div style="display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:24px;flex-wrap:wrap">
+      <div>
+        <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666;margin-bottom:8px">★ ANALYZING</div>
+        <h1 style="font-family:'Archivo Black',sans-serif;font-size:56px;line-height:.9;letter-spacing:-.03em;text-transform:uppercase">${esc(url.replace(/^https?:\/\//,'').replace(/\/$/,''))}</h1>
+        ${ig ? `<div style="margin-top:8px;font-size:14px;font-weight:700">★ <a href="https://instagram.com/${esc(ig.replace(/^@/, ''))}" style="text-decoration:none;border-bottom:2px solid #000">${esc(ig)}</a></div>` : ''}
+      </div>
+      <div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
+        <div title="Audit grade based on metadata completeness — title, tagline, palette, hero, IG, vertical detection, embedability"
+             style="display:flex;align-items:center;gap:14px;background:${grade.color};color:#000;border:4px solid #000;padding:8px 18px">
+          <div style="font-family:'Archivo Black',sans-serif;font-size:48px;line-height:1">${grade.letter}</div>
+          <div>
+            <div style="font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em">★ AUDIT</div>
+            <div style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700">${grade.score}/100</div>
+          </div>
+        </div>
+        <a href="/website-analysis" style="background:#000;color:#fff;padding:14px 24px;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;border:4px solid #000">← ANALYZE ANOTHER</a>
+        <form method="post" action="/api/wa/${esc(analysis.slug)}/deep-dive" style="display:inline;margin:0">
+          <button type="submit" title="Synthesize a mini site-audit report (SEO + content + composition checks, vertical-specific tips, suggested headlines)" style="background:#facc15;color:#000;padding:14px 24px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;border:4px solid #000;cursor:pointer">★ DEEP DIVE →</button>
+        </form>
+      </div>
+    </div>
+
+    <div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:14px 0;border-top:2px solid #000;border-bottom:2px solid #000">
+      ${verticalPill}
+      ${swatches ? `<div style="display:flex;align-items:center;gap:8px"><div style="font-size:11px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:.06em">PALETTE${paletteSource === 'synthesized' ? ' (SYNTH)' : ''}</div><div style="display:flex;gap:0">${swatches}</div></div>` : ''}
+      ${iframeBlocked ? '<div style="display:inline-block;background:#dc2626;color:#fff;padding:4px 10px;font-family:\'Archivo Black\',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em">★ NO IFRAME</div>' : '<div style="display:inline-block;background:#22c55e;color:#000;padding:4px 10px;font-family:\'Archivo Black\',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em">★ EMBEDABLE</div>'}
+      ${analysis.business_id ? '<div style="display:inline-block;background:#facc15;color:#000;padding:4px 10px;font-family:\'Archivo Black\',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em">★ SHIPPED</div>' : ''}
+    </div>
+  </div>
+</section>
+${livePanel}
+<section style="padding:32px 24px;background:var(--soft);border-bottom:4px solid #000">
+  <div style="max-width:1320px;margin:0 auto">
+    <h2 style="font-family:'Archivo Black',sans-serif;font-size:32px;text-transform:uppercase;letter-spacing:-.02em;margin-bottom:24px">★ YOUR SITE — RIGHT NOW</h2>
+    ${yourSite}
+  </div>
+</section>
+
+<section style="padding:64px 24px">
+  <div style="max-width:1320px;margin:0 auto">
+    <h2 style="font-family:'Archivo Black',sans-serif;font-size:48px;text-transform:uppercase;letter-spacing:-.03em;margin-bottom:8px">★ 3 NEW DIRECTIONS</h2>
+    <p style="font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;margin-bottom:32px">★ SAME CONTENT — THREE COMPLETELY DIFFERENT AESTHETIC SYSTEMS</p>
+    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:24px">${cards}</div>
+  </div>
+</section>
+
+${mockups.length >= 2 ? `
+<section style="padding:64px 24px;background:#000;color:#fafaf6;border-top:4px solid #facc15;border-bottom:4px solid #000">
+  <div style="max-width:1320px;margin:0 auto">
+    <div style="display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:18px;margin-bottom:24px">
+      <div>
+        <div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#facc15;margin-bottom:6px">★ A/B SLIDER</div>
+        <h2 style="font-family:'Archivo Black',sans-serif;font-size:40px;text-transform:uppercase;letter-spacing:-.02em;color:#fff">DRAG TO COMPARE</h2>
+      </div>
+      <div style="display:flex;gap:8px;flex-wrap:wrap">
+        ${mockups.slice(0, 6).map((m, i) => `<button class="ab-pick" data-side="left" data-id="${esc(m.mockup_id)}" data-label="${esc(m.theme.toUpperCase())}" style="background:${i === 0 ? '#facc15' : '#222'};color:${i === 0 ? '#000' : '#facc15'};padding:8px 12px;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;border:2px solid #facc15;cursor:pointer;text-transform:uppercase;letter-spacing:.04em">${esc(m.theme.toUpperCase())}</button>`).join('')}
+      </div>
+    </div>
+    <div id="ab-slider" style="position:relative;border:4px solid #facc15;background:#fff;aspect-ratio:16/9;overflow:hidden;user-select:none">
+      <iframe id="ab-left"  src="/wa/${esc(analysis.slug)}/m/${esc(mockups[0].mockup_id)}" style="position:absolute;inset:0;width:100%;height:100%;border:0"  loading="lazy" sandbox="allow-same-origin"></iframe>
+      <div id="ab-right-wrap" style="position:absolute;inset:0;width:50%;overflow:hidden;border-right:3px solid #facc15">
+        <iframe id="ab-right" src="/wa/${esc(analysis.slug)}/m/${esc(mockups[1].mockup_id)}" style="position:absolute;inset:0;width:200%;height:100%;border:0" loading="lazy" sandbox="allow-same-origin"></iframe>
+      </div>
+      <div id="ab-handle" style="position:absolute;top:0;bottom:0;left:50%;width:6px;background:#facc15;cursor:ew-resize;z-index:10;transform:translateX(-50%)">
+        <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#000;color:#facc15;width:48px;height:48px;border:3px solid #facc15;display:flex;align-items:center;justify-content:center;font-family:'Archivo Black',sans-serif;font-size:24px">↔</div>
+      </div>
+      <div style="position:absolute;top:14px;left:14px;background:#000;color:#facc15;padding:6px 10px;font-family:'Archivo Black',sans-serif;font-size:11px;letter-spacing:.04em;border:2px solid #facc15;z-index:5">★ <span id="ab-label-left">${esc(mockups[0].theme.toUpperCase())}</span></div>
+      <div style="position:absolute;top:14px;right:14px;background:#000;color:#facc15;padding:6px 10px;font-family:'Archivo Black',sans-serif;font-size:11px;letter-spacing:.04em;border:2px solid #facc15;z-index:5"><span id="ab-label-right">${esc(mockups[1].theme.toUpperCase())}</span> ★</div>
+    </div>
+    <p style="margin-top:14px;font-size:12px;font-weight:700;letter-spacing:.04em;color:#aaa;text-transform:uppercase">★ DRAG THE GOLD HANDLE LEFT/RIGHT — TAP THE THEME CHIPS TO SWAP EITHER SIDE</p>
+  </div>
+</section>
+
+<script>
+(function(){
+  const wrap = document.getElementById('ab-right-wrap');
+  const handle = document.getElementById('ab-handle');
+  const slider = document.getElementById('ab-slider');
+  const leftIfr = document.getElementById('ab-left');
+  const rightIfr = document.getElementById('ab-right');
+  const labL = document.getElementById('ab-label-left');
+  const labR = document.getElementById('ab-label-right');
+  if (!wrap || !handle || !slider) return;
+
+  let dragging = false;
+  function setPos(pct) {
+    pct = Math.max(2, Math.min(98, pct));
+    wrap.style.width = pct + '%';
+    handle.style.left = pct + '%';
+    rightIfr.style.width = (10000 / pct) + '%';
+  }
+  function onMove(e) {
+    if (!dragging) return;
+    const rect = slider.getBoundingClientRect();
+    const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
+    setPos((x / rect.width) * 100);
+  }
+  handle.addEventListener('mousedown', () => { dragging = true; });
+  handle.addEventListener('touchstart', () => { dragging = true; }, {passive:true});
+  document.addEventListener('mousemove', onMove);
+  document.addEventListener('touchmove', onMove, {passive:true});
+  document.addEventListener('mouseup',   () => { dragging = false; });
+  document.addEventListener('touchend',  () => { dragging = false; });
+  slider.addEventListener('click', (e) => {
+    if (e.target === handle || handle.contains(e.target)) return;
+    const rect = slider.getBoundingClientRect();
+    setPos(((e.clientX - rect.left) / rect.width) * 100);
+  });
+
+  // Theme chip clicks: alternate left/right slot.
+  let nextSide = 'right';
+  document.querySelectorAll('.ab-pick').forEach(btn => {
+    btn.addEventListener('click', () => {
+      const id = btn.dataset.id;
+      const label = btn.dataset.label;
+      const slug = '${esc(analysis.slug)}';
+      if (nextSide === 'right') {
+        rightIfr.src = '/wa/' + slug + '/m/' + id;
+        labR.textContent = label;
+        nextSide = 'left';
+      } else {
+        leftIfr.src = '/wa/' + slug + '/m/' + id;
+        labL.textContent = label;
+        nextSide = 'right';
+      }
+      document.querySelectorAll('.ab-pick').forEach(b => {
+        b.style.background = '#222'; b.style.color = '#facc15';
+      });
+      btn.style.background = '#facc15'; btn.style.color = '#000';
+    });
+  });
+})();
+</script>
+` : ''}
+
+${analysis.summary_json?.audit_report ? renderAuditReport(analysis.summary_json.audit_report) : ''}
+` + FOOT;
+}
+
+function renderAuditReport(r) {
+  if (!r || !r.sections) return '';
+  const lvl = r.score >= 70 ? '#22c55e' : r.score >= 40 ? '#facc15' : '#dc2626';
+  const findings = (label, sec) => {
+    if (!sec.findings || !sec.findings.length) return '';
+    return `<div style="border-top:2px solid #000;padding:16px 0">
+      <div style="font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;display:flex;justify-content:space-between"><span>★ ${esc(label)}</span><span style="color:${sec.score_delta >= 0 ? '#22c55e' : '#dc2626'}">${sec.score_delta >= 0 ? '+' : ''}${sec.score_delta}</span></div>
+      <ul style="list-style:none;font-size:13px;line-height:1.5">
+        ${sec.findings.map(f => `<li style="padding:4px 0;display:flex;gap:10px"><span style="color:${f.pts >= 0 ? '#22c55e' : '#dc2626'};font-weight:700;min-width:32px">${f.pts >= 0 ? '+' : ''}${f.pts}</span><span>${esc(f.msg)}</span></li>`).join('')}
+      </ul>
+    </div>`;
+  };
+  return `<section id="audit-report" style="padding:64px 24px;background:#000;color:#fff;border-top:4px solid #000">
+    <div style="max-width:980px;margin:0 auto">
+      <div style="display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:32px;flex-wrap:wrap">
+        <div>
+          <div style="font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#facc15;margin-bottom:8px">★ DEEP DIVE</div>
+          <h2 style="font-family:'Archivo Black',sans-serif;font-size:56px;text-transform:uppercase;letter-spacing:-.03em">AUDIT REPORT.</h2>
+          <div style="font-size:11px;font-family:'Space Mono',monospace;color:#888;margin-top:4px">★ GENERATED ${esc(new Date(r.generated_at).toISOString().slice(0,16).replace('T',' '))}</div>
+        </div>
+        <div style="display:flex;align-items:center;gap:14px;background:${lvl};color:#000;border:4px solid #fff;padding:14px 24px">
+          <div style="font-family:'Archivo Black',sans-serif;font-size:64px;line-height:1">${r.score}</div>
+          <div>
+            <div style="font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.06em">★ AUDIT SCORE</div>
+            <div style="font-family:'Space Mono',monospace;font-size:11px;font-weight:700">/100</div>
+          </div>
+        </div>
+      </div>
+      <div style="background:#fff;color:#000;padding:24px;border:4px solid #fff">
+        ${findings('SEO',         r.sections.seo)}
+        ${findings('CONTENT',     r.sections.content)}
+        ${findings('COMPOSITION', r.sections.composition)}
+      </div>
+      <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:32px">
+        <div style="background:#facc15;color:#000;padding:24px;border:4px solid #fff">
+          <div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;margin-bottom:14px">★ HEADLINES TO TRY</div>
+          ${(r.suggested_headlines||[]).map((h, i) => `<div style="padding:10px 0;border-bottom:2px solid #000;font-size:14px;font-weight:700;line-height:1.35"><span style="font-family:'Archivo Black',sans-serif;margin-right:8px">${i+1}.</span>${esc(h)}</div>`).join('')}
+        </div>
+        <div style="background:#fff;color:#000;padding:24px;border:4px solid #fff">
+          <div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;margin-bottom:14px">★ VERTICAL TIPS</div>
+          ${(r.vertical_tips||[]).map((t, i) => `<div style="padding:10px 0;border-bottom:2px solid #000;font-size:13px;line-height:1.5"><span style="font-family:'Archivo Black',sans-serif;margin-right:8px">${String.fromCharCode(65+i)}.</span>${esc(t)}</div>`).join('')}
+        </div>
+      </div>
+      ${r.quick_wins?.length ? `<div style="margin-top:32px;background:#dc2626;color:#fff;padding:24px;border:4px solid #fff">
+        <div style="font-family:'Archivo Black',sans-serif;font-size:18px;text-transform:uppercase;margin-bottom:14px">★ ${r.quick_wins.length} QUICK WINS</div>
+        <ul style="list-style:none;font-size:13px;line-height:1.5">${r.quick_wins.map(q => `<li style="padding:6px 0;border-bottom:1px solid #fff8">→ ${esc(q)}</li>`).join('')}</ul>
+      </div>` : ''}
+    </div>
+  </section>`;
+}
diff --git a/src/scrape/website_meta.js b/src/scrape/website_meta.js
index 5cfae43..f945b49 100644
--- a/src/scrape/website_meta.js
+++ b/src/scrape/website_meta.js
@@ -10,25 +10,99 @@
 
 import { request } from 'undici';
 import * as cheerio from 'cheerio';
+import { promises as dns } from 'node:dns';
+import net from 'node:net';
 
 const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 ' +
            '(KHTML, like Gecko) Chrome/124.0 Safari/537.36 small-business-builder/0.1';
 
+// SSRF guard — reject loopback / link-local / RFC1918 / IPv6-equivalent. Without
+// this, the scrape endpoint becomes a probe for internal services
+// (127.0.0.1, 169.254.169.254 cloud metadata, 10/8, 172.16/12, 192.168/16, ::1, fc00::/7).
+function isPrivateAddr(addr) {
+  if (!addr) return true;
+  if (net.isIPv4(addr)) {
+    const [a, b] = addr.split('.').map(Number);
+    if (a === 10) return true;
+    if (a === 127) return true;
+    if (a === 0) return true;
+    if (a === 169 && b === 254) return true;
+    if (a === 172 && b >= 16 && b <= 31) return true;
+    if (a === 192 && b === 168) return true;
+    if (a === 100 && b >= 64 && b <= 127) return true;       // CGNAT
+    if (a >= 224) return true;                                 // multicast / reserved
+    return false;
+  }
+  if (net.isIPv6(addr)) {
+    const lo = addr.toLowerCase();
+    if (lo === '::1' || lo === '::') return true;
+    if (lo.startsWith('fe80:') || lo.startsWith('fc') || lo.startsWith('fd')) return true;
+    if (lo.startsWith('::ffff:')) {
+      const v4 = lo.slice(7);
+      if (net.isIPv4(v4)) return isPrivateAddr(v4);
+    }
+    return false;
+  }
+  return true;
+}
+
+async function assertPublicHost(hostname) {
+  // If literal IP, check directly. Otherwise resolve all A/AAAA and reject if ANY
+  // address is private (defends against rebinding-style multi-record DNS).
+  if (net.isIP(hostname)) {
+    if (isPrivateAddr(hostname)) throw new Error('host not allowed');
+    return;
+  }
+  let addrs = [];
+  try {
+    addrs = await dns.lookup(hostname, { all: true, verbatim: true });
+  } catch {
+    throw new Error('host not resolvable');
+  }
+  if (!addrs.length) throw new Error('host not resolvable');
+  for (const { address } of addrs) {
+    if (isPrivateAddr(address)) throw new Error('host not allowed');
+  }
+}
+
 export async function fetchHtml(url, timeoutMs = 15000) {
+  let parsed;
+  try { parsed = new URL(url); } catch { throw new Error('invalid url'); }
+  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+    throw new Error('only http/https supported');
+  }
+  await assertPublicHost(parsed.hostname);
+
+  // undici v7 dropped maxRedirections on the low-level request(); use global
+  // fetch() which transparently follows up to 20 redirects and re-validates the
+  // final hostname is public via assertPublicHost only at the entry. Anything
+  // sketchier gets rejected.
   const ac = new AbortController();
   const t = setTimeout(() => ac.abort(), timeoutMs);
   try {
-    const res = await request(url, {
+    const res = await fetch(url, {
       method: 'GET',
-      headers: { 'user-agent': UA, 'accept': 'text/html,*/*' },
-      maxRedirections: 5,
+      headers: { 'user-agent': UA, accept: 'text/html,*/*' },
+      redirect: 'follow',
       signal: ac.signal,
     });
-    if (res.statusCode >= 400) {
-      throw new Error(`HTTP ${res.statusCode}`);
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    const finalUrl = res.url || url;
+    if (finalUrl !== url) {
+      try { await assertPublicHost(new URL(finalUrl).hostname); } catch { throw new Error('redirect to non-public host'); }
     }
-    const body = await res.body.text();
-    return { html: body, url, status: res.statusCode };
+    // Capture iframe-embedability so the website-analysis result page can show a
+    // "preview blocked — open in new tab" placeholder instead of a blank white
+    // iframe when X-Frame-Options/CSP frame-ancestors disallow embedding.
+    const xfo = (res.headers.get('x-frame-options') || '').toLowerCase();
+    const csp = (res.headers.get('content-security-policy') || '').toLowerCase();
+    const fa = csp.match(/frame-ancestors\s+([^;]+)/);
+    const faVal = fa ? fa[1].trim() : '';
+    let iframeBlocked = false;
+    if (xfo.includes('deny') || xfo.includes('sameorigin')) iframeBlocked = true;
+    if (faVal && !faVal.includes('*')) iframeBlocked = true; // anything other than wildcard blocks us cross-origin
+    const body = await res.text();
+    return { html: body, url: finalUrl, status: res.status, iframe_blocked: !!iframeBlocked };
   } finally {
     clearTimeout(t);
   }
@@ -54,11 +128,18 @@ function extractHexColors(text, limit = 32) {
 }
 
 function extractPhone(text) {
-  // tel: first, then 10-digit pattern
+  // tel: first — that's the only unambiguous signal. Then look for an area-code
+  // pattern that's clearly a phone (parens around area code OR explicit
+  // `-`/`.` separator). The previous fallback matched any three groups of digits
+  // separated by whitespace — timestamps, image dimensions, and CSS values
+  // (`200 300 4000`) all qualified.
   const tel = text.match(/tel:([+\d\-\s().]{7,})/i);
   if (tel) return tel[1].trim();
-  const m = text.match(/(\+?1[\s.\-]?)?\(?(\d{3})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})/);
-  if (m) return `(${m[2]}) ${m[3]}-${m[4]}`;
+  const m = text.match(/(?:\+?1[\s.\-]?)?(?:\((\d{3})\)\s*|(\d{3})[.\-])(\d{3})[.\-](\d{4})/);
+  if (m) {
+    const area = m[1] || m[2];
+    return `(${area}) ${m[3]}-${m[4]}`;
+  }
   return null;
 }
 
@@ -71,7 +152,7 @@ function extractEmail(text) {
 
 export async function scrapeWebsiteMeta(url) {
   if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
-  const { html } = await fetchHtml(url);
+  const { html, iframe_blocked } = await fetchHtml(url);
   const $ = cheerio.load(html);
 
   const meta = (name) =>
@@ -86,15 +167,18 @@ export async function scrapeWebsiteMeta(url) {
   let heroImage = meta('og:image') || meta('twitter:image') || null;
   if (heroImage) heroImage = abs(url, heroImage);
   if (!heroImage) {
-    // first reasonably-large <img>
+    // first reasonably-large <img> — must declare width >=400 AND height >=300.
+    // (Previous predicate inverted the truthiness so that a missing width/height
+    // accepted 1x1 tracking pixels as the hero.)
     $('img').each((_, el) => {
+      if (heroImage) return false;
       const src = $(el).attr('src');
       if (!src) return;
       const w = parseInt($(el).attr('width') || '0', 10);
       const h = parseInt($(el).attr('height') || '0', 10);
-      if (heroImage) return;
-      if (!w || w >= 400 || !h || h >= 300) {
+      if (w >= 400 && h >= 300) {
         heroImage = abs(url, src);
+        return false;
       }
     });
   }
@@ -119,5 +203,6 @@ export async function scrapeWebsiteMeta(url) {
     color_accent: palette[1] || null,
     phone,
     email,
+    iframe_blocked: !!iframe_blocked,
   };
 }
diff --git a/src/server/index.js b/src/server/index.js
index f4eb035..0ddceb2 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -19,21 +19,25 @@
 
 import 'dotenv/config';
 import express from 'express';
+import helmet from 'helmet';
 import compression from 'compression';
 import cookieParser from 'cookie-parser';
 
 import { many, one, query } from '../lib/db.js';
 import { slugify } from '../lib/slug.js';
 import { log } from '../lib/log.js';
+import { esc, escJsonLd } from '../lib/escape.js';
 import { renderGrid } from '../render/grid.js';
 import { renderEdit } from '../render/edit.js';
 import { renderHome } from '../render/home.js';
 import { renderProfile, renderInterviewForm } from '../render/profile.js';
 import { renderInterviewLive } from '../render/interview-live.js';
 import { QUESTIONS } from '../lib/interview-questions.js';
+import { draftInterview } from '../lib/ollama-draft.js';
 
 const QUESTION_IDS = new Set(QUESTIONS.map(q => q.id));
 import fs from 'node:fs';
+import { promises as fsp } from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 
@@ -45,14 +49,79 @@ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
 import { renderTemplate } from '../render/templates/index.js';
 import { scrapeWebsiteMeta } from '../scrape/website_meta.js';
 import { scrapeInstagramPublic } from '../scrape/instagram_public.js';
+import { renderEntry as renderWAEntry, renderResult as renderWAResult } from '../render/website-analysis.js';
+import { renderWall } from '../render/wall.js';
+import { detectVertical, pickThemesFor } from '../lib/vertical.js';
+import { renderTheme } from '../render/website-analysis-themes.js';
+import { listBuilds } from '../lib/builds-index.js';
+import { renderBuildsIndex } from '../render/builds-index.js';
+import { renderMap } from '../render/map.js';
+import { computeGrade } from '../lib/audit-grade.js';
 
 const app = express();
+// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
+app.use(helmet({ contentSecurityPolicy: false }));
 const PORT = parseInt(process.env.PORT || '9760', 10);
 const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
 
 app.use(compression());
 // Bumped to 8MB so webcam-frame data URLs can POST without 413.
 app.use(express.json({ limit: '8mb' }));
+
+// LAN/tailnet auth gate. BASIC_AUTH must come from env — fail to start if
+// missing. Reviewer (HIGH-severity) flagged the prior hardcoded fallback as
+// shipping a real credential into source + MEMORY.md.
+const BASIC_AUTH = process.env.BASIC_AUTH;
+if (!BASIC_AUTH || !/^[^:]+:[^:].+$/.test(BASIC_AUTH)) {
+  console.error('FATAL: BASIC_AUTH env var is required (format user:pass)');
+  process.exit(1);
+}
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+
+// Public consumer surface — these hosts get a stripped-down view of the app
+// (no /builds, no admin APIs, no basic-auth). Anything else is admin and
+// requires basic-auth. Trust proxy is set so req.hostname reflects nginx X-Forwarded-Host.
+app.set('trust proxy', 1);
+const PUBLIC_HOSTS = new Set([
+  'livesiteaudit.agentabrams.com',
+  'livesiteaudit.com',           // future, if registered
+]);
+const PUBLIC_PATH_ALLOW = [
+  /^\/$/,                                 // landing → /website-analysis
+  /^\/website-analysis(\/|$)/,            // entry form
+  /^\/api\/website-analysis(\/|$)/,       // form POST endpoint
+  /^\/wa\/[^/]+$/,                        // analysis result page (read-only)
+  /^\/wa\/[^/]+\/m\/[^/]+$/,              // mockup preview (read-only)
+  /^\/wall$/,                             // brutalist gallery of all builds
+  /^\/grades$/,                           // top graded + most-roasted (/leaderboard taken by LA Salon)
+  /^\/roasts\.rss$/,                      // RSS feed of latest roasts
+  /^\/api\/roast-me$/,                    // qwen3 roast-on-demand
+  /^\/health$/, /^\/healthz$/,
+  /^\/uploads\//,
+];
+function isPublicPathAllowed(p) {
+  return PUBLIC_PATH_ALLOW.some(re => re.test(p));
+}
+
+app.use((req, res, next) => {
+  const host = (req.hostname || '').toLowerCase();
+  const isPublicHost = PUBLIC_HOSTS.has(host);
+  if (req.path === '/health' || req.path === '/healthz') return next();
+
+  if (isPublicHost) {
+    if (!isPublicPathAllowed(req.path)) {
+      // Hide admin surfaces from public host — no leak of route names via 401.
+      return res.status(404).type('text/plain').send('not found');
+    }
+    return next();  // public path on public host: NO basic auth
+  }
+
+  // Admin host — require basic auth.
+  if (req.get('authorization') === AUTH_HEADER) return next();
+  res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+  res.status(401).send('auth required');
+});
+
 app.use(express.urlencoded({ extended: true, limit: '512kb' }));
 app.use(cookieParser());
 // Serve uploaded webcam captures + any other public assets.
@@ -77,6 +146,28 @@ async function uniqueSlug(base) {
   return s;
 }
 
+// JSON-API admin gate. Different from `requireAdmin` (HTML) so XHR clients get
+// a JSON 401 instead of a login form.
+function requireAdminJson(req, res) {
+  const tok = req.cookies?.smb_admin || req.body?.admin_token || req.query?.t || '';
+  if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
+    res.status(401).json({ error: 'unauthorized' });
+    return false;
+  }
+  return true;
+}
+
+// Generic 500 handler — log full err locally, return a vague message to the
+// client so pg / undici / DNS errors don't leak hostnames or connection
+// strings.
+function fail(res, where, e) {
+  log.err(`${where} failed`, { error: String(e?.stack || e?.message || e) });
+  res.status(500).json({ error: 'internal error' });
+}
+
+// Postgres unique-violation code; surfaces as `e.code === '23505'`.
+const PG_UNIQUE_VIOLATION = '23505';
+
 async function loadBySlug(slug) {
   return one('SELECT * FROM businesses WHERE slug=$1', [slug]);
 }
@@ -109,7 +200,8 @@ app.get('/healthz', async (_req, res) => {
 // Public editorial homepage — neighborhood-led, barber/beard featured first.
 app.get('/', async (_req, res) => {
   const all = await many(
-    `SELECT slug,name,owner_name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
+    `SELECT slug, COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
+            owner_name, category, neighborhood, city, state, tier, hero_image_url, ranking_score
      FROM businesses
      ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
      LIMIT 200`
@@ -139,8 +231,2656 @@ app.get('/', async (_req, res) => {
   }
   const neighborhoods = [...hoodMap.entries()].map(([neighborhood, count]) => ({ neighborhood, count }));
 
+  // Latest 8 enrichment finds — articles + social handles — for the "just
+  // discovered" rail. Hits the same tables /timeline does but shape's slim.
+  const latestFinds = await many(
+    `WITH recent_articles AS (
+       SELECT a.url, a.title, a.publisher, a.created_at AS ts, b.slug,
+              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.neighborhood, 'article' AS kind
+         FROM business_articles a JOIN businesses b ON b.id = a.business_id
+        ORDER BY a.created_at DESC LIMIT 16
+     ), recent_socials AS (
+       SELECT s.url, s.handle AS title, s.platform AS publisher, s.scraped_at AS ts,
+              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.neighborhood, 'social' AS kind
+         FROM business_socials s JOIN businesses b ON b.id = s.business_id
+        WHERE s.url IS NOT NULL
+        ORDER BY s.scraped_at DESC LIMIT 16
+     )
+     SELECT * FROM recent_articles UNION ALL SELECT * FROM recent_socials
+      ORDER BY ts DESC LIMIT 8`
+  );
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, featured: featuredBlock, totalCount: all.length, latestFinds }));
+});
+
+// /best — curated "data-complete" shops: website + Instagram + at least one
+// press article. Intended as a top-of-funnel page that surfaces only the
+// shops where we know enough to send a customer. Live-recomputed from
+// the enrichment tables on every request (cheap; small set).
+// /best — neighborhood × category hub. Lists every /best/:hood/:cat URL in a
+// scannable grid so Google can crawl the long-tail entry points. The old
+// "data-complete cohort" view moved to /best/curated (preserved verbatim).
+app.get('/best', async (_req, res) => {
+  try {
+    const grid = await many(
+      `SELECT neighborhood,
+              COUNT(*) FILTER (WHERE category = 'salon')      AS salons,
+              COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops,
+              COUNT(*) FILTER (WHERE category = 'spa')        AS spas,
+              COUNT(*) FILTER (WHERE category = 'nail')       AS nails,
+              COUNT(*)::int AS total
+         FROM businesses
+        WHERE neighborhood IS NOT NULL AND neighborhood <> ''
+        GROUP BY neighborhood
+        HAVING COUNT(*) >= 5
+        ORDER BY total DESC`
+    );
+    const slug = s => String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
+    const totalShops = grid.reduce((a, r) => a + Number(r.total), 0);
+    res.set('content-type', 'text/html; charset=utf-8');
+    res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Best Salons & Barbers by Neighborhood · ${grid.length} LA neighborhoods · LA Salon Directory</title>
+<meta name="description" content="Browse the best salons, barbershops, spas, and nail salons across ${grid.length} Los Angeles neighborhoods. ${totalShops.toLocaleString()} ranked shops.">
+<link rel="canonical" href="${esc(process.env.SITE_URL || 'https://localhost')}/best">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}
+*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
+.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}
+.brand em{color:var(--accent);font-style:italic}
+.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav a:hover{color:var(--ink)}
+.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:1;letter-spacing:-.02em;margin-bottom:14px}
+h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch;margin-bottom:20px}
+.stats{display:flex;gap:32px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
+.stats b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:30px;color:var(--ink);font-weight:500;text-transform:none}
+.cta{padding:18px 32px;background:#fff;border-bottom:1px solid var(--rule);text-align:center;font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
+.cta a{color:var(--accent);border-bottom:1px solid var(--accent);padding-bottom:1px}
+.grid{padding:48px 32px;max-width:1240px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
+.cell{background:var(--paper);padding:18px 22px;display:flex;flex-direction:column;gap:6px;transition:background 150ms ease}
+.cell:hover{background:#fff}
+.cell-name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.1}
+.cell-name a{color:var(--ink)}
+.cell-name a:hover{color:var(--accent)}
+.cats{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
+.cat{display:inline-block;padding:3px 8px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:9px;letter-spacing:.08em;text-transform:uppercase}
+.cat:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.cat-tot{font-family:monospace;font-size:9px;color:var(--mute);letter-spacing:.06em;margin-top:4px}
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}
+</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ best by neighborhood</span></div>
+  <nav class="topnav">
+    <a href="/best/curated">Curated</a><a href="/proprietors">Proprietors</a><a href="/fresh">Just Opened</a><a href="/press">Press</a><a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <h1>The <em>best</em> by neighborhood.</h1>
+  <p class="hero-lede">Ranked salons, barbershops, spas, and nail salons across ${grid.length} Los Angeles neighborhoods. Built from California state licensure data, press coverage, social presence, and website signals — ${totalShops.toLocaleString()} shops in total.</p>
+  <div class="stats">
+    <div>Neighborhoods<b>${grid.length}</b></div>
+    <div>Total shops<b>${totalShops.toLocaleString()}</b></div>
+    <div>Ranked pages<b>${(grid.length * 5).toLocaleString()}</b></div>
+  </div>
+</section>
+<div class="cta">Looking for the <a href="/best/curated">data-complete cohort →</a> (website + IG + press)</div>
+<section class="grid">
+${grid.map(g => {
+  const s = slug(g.neighborhood);
+  return `<div class="cell">
+    <div class="cell-name"><a href="/best/${esc(s)}">${esc(g.neighborhood)}</a></div>
+    <div class="cat-tot">${Number(g.total).toLocaleString()} shop${Number(g.total)===1?'':'s'} ranked</div>
+    <div class="cats">
+      ${Number(g.salons) > 0 ? `<a class="cat" href="/best/${esc(s)}/salons">Salons · ${g.salons}</a>` : ''}
+      ${Number(g.barbershops) > 0 ? `<a class="cat" href="/best/${esc(s)}/barbershops">Barbers · ${g.barbershops}</a>` : ''}
+      ${Number(g.spas) > 0 ? `<a class="cat" href="/best/${esc(s)}/spas">Spas · ${g.spas}</a>` : ''}
+      ${Number(g.nails) > 0 ? `<a class="cat" href="/best/${esc(s)}/nail-salons">Nails · ${g.nails}</a>` : ''}
+    </div>
+  </div>`;
+}).join('')}
+</section>
+<footer class="footer">
+  <a href="/">← Home</a> · ${grid.length} neighborhoods · sourced from CA BBCB
+</footer>
+</body></html>`);
+  } catch (e) { fail(res, '/best', e); }
+});
+
+// /best/curated — original "data-complete cohort" view.
+app.get('/best/curated', async (_req, res) => {
+  const rows = await many(
+    `SELECT b.slug,
+            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+            b.category, b.neighborhood, b.city, b.address,
+            b.website,
+            (b.source_data_json->>'source' = 'dca_bbcb') AS licensed,
+            ig.url AS ig_url, ig.handle AS ig_handle,
+            ye.url AS yelp_url,
+            COUNT(DISTINCT a.id)::int AS article_count,
+            COUNT(DISTINCT s.id)::int AS social_count,
+            ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
+       FROM businesses b
+       LEFT JOIN business_socials ig ON ig.business_id = b.id AND ig.platform = 'instagram'
+       LEFT JOIN business_socials ye ON ye.business_id = b.id AND ye.platform = 'yelp'
+       LEFT JOIN business_articles a ON a.business_id = b.id
+       LEFT JOIN business_socials s  ON s.business_id  = b.id
+      WHERE b.website IS NOT NULL AND b.website <> ''
+      GROUP BY b.id, b.slug, b.name, b.source_data_json, b.category, b.neighborhood, b.city, b.address, b.website,
+               ig.url, ig.handle, ye.url
+     HAVING COUNT(DISTINCT a.id) >= 1 AND COUNT(DISTINCT s.id) >= 1
+      ORDER BY article_count DESC, social_count DESC, name`
+  );
+  // Group by neighborhood for the editorial run.
+  const byHood = new Map();
+  for (const r of rows) {
+    const k = r.neighborhood || r.city || 'Other';
+    if (!byHood.has(k)) byHood.set(k, []);
+    byHood.get(k).push(r);
+  }
+  const ordered = [...byHood.entries()].sort((a, b) => b[1].length - a[1].length);
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>The Best · Data-Complete Shops · LA Salon Directory</title>
+<meta name="description" content="${rows.length} LA salons we know enough about to send you to — website, Instagram, and at least one editorial press mention. The data-complete cohort.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.topnav a:hover{color:var(--ink)}.hero{padding:80px 32px 40px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch;margin-bottom:24px}.stats-row{display:flex;gap:32px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}.stats-row b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}.hood-section{padding:64px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule);scroll-margin-top:80px}.hood-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid var(--rule)}.hood-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(32px,4vw,52px);letter-spacing:-.015em;line-height:1}.hood-head .meta{font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.shops{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0 1px;background:var(--rule);border:1px solid var(--rule)}.shop{background:var(--paper);padding:22px 24px;display:flex;flex-direction:column;gap:8px;transition:background 200ms ease;border-bottom:1px solid var(--rule)}.shop:hover{background:#fff}.s-cat{font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);display:flex;justify-content:space-between;align-items:center}.s-cat .lic{background:var(--gold);color:var(--ink);padding:1px 6px;font-weight:700}.s-name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;line-height:1.1;letter-spacing:-.005em}.s-name a{color:var(--ink)}.s-where{font-family:monospace;font-size:10px;letter-spacing:.04em;color:var(--mute);text-transform:uppercase;line-height:1.4}.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}.chip{display:inline-block;padding:4px 10px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink);line-height:1.3}.chip:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.chip-press{background:var(--gold);color:var(--ink);border-color:var(--gold);font-weight:700}.pubs{font-family:monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;margin-top:4px;line-height:1.5}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ the best</span></div>
+  <nav class="topnav">
+    <a href="/press">Press</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/near">Near Me</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Data-Complete · Website + Instagram + Press</div>
+  <h1>The shops we know <em>enough</em> about.</h1>
+  <p class="hero-lede">${rows.length} LA salons and barbershops where we've found a real website, an active Instagram presence, AND at least one editorial press mention. The cohort we're confident enough to send you to.</p>
+  <div class="stats-row">
+    <div>Curated<b>${rows.length}</b></div>
+    <div>Neighborhoods<b>${ordered.length}</b></div>
+    <div>State-licensed<b>${rows.filter(r => r.licensed).length}</b></div>
+  </div>
+</section>
+
+${ordered.length === 0 ? `
+<div class="empty">No fully-enriched shops yet. The agents are still working — refresh later.</div>
+` : ordered.map(([hood, list]) => `
+  <section class="hood-section">
+    <div class="hood-head">
+      <h2>${esc(hood)}</h2>
+      <span class="meta">${list.length} shop${list.length === 1 ? '' : 's'}</span>
+    </div>
+    <div class="shops">
+      ${list.map(s => `
+        <article class="shop">
+          <div class="s-cat">
+            <span>${esc(s.category || 'business')}</span>
+            ${s.licensed ? '<span class="lic">◆ Licensed</span>' : ''}
+          </div>
+          <div class="s-name"><a href="/biz/${esc(s.slug)}">${esc(s.name)}</a></div>
+          ${s.address ? `<div class="s-where">${esc(s.address)}</div>` : ''}
+          <div class="chips">
+            <a class="chip" href="${esc(s.website)}" target="_blank" rel="noopener">🌐 Website</a>
+            ${s.ig_url ? `<a class="chip" href="${esc(s.ig_url)}" target="_blank" rel="noopener">📷 ${s.ig_handle ? '@' + esc(s.ig_handle) : 'Instagram'}</a>` : ''}
+            ${s.yelp_url ? `<a class="chip" href="${esc(s.yelp_url)}" target="_blank" rel="noopener">⭐ Yelp</a>` : ''}
+            <a class="chip chip-press" href="/biz/${esc(s.slug)}#press">📰 ${s.article_count} Press</a>
+          </div>
+          ${s.publishers && s.publishers.length ? `<div class="pubs">${s.publishers.slice(0, 5).map(p => esc(p)).join(' · ')}${s.publishers.length > 5 ? ' +' + (s.publishers.length - 5) : ''}</div>` : ''}
+        </article>
+      `).join('')}
+    </div>
+  </section>
+`).join('')}
+
+<footer class="footer">
+  <a href="/" style="color:var(--accent)">← Home</a>
+  · ${rows.length.toLocaleString()} curated · live-computed from enrichment tables
+</footer>
+</body></html>`);
+});
+
+// /best/:hoodSlug/:catSlug? — Yelp-killer "Best [category] in [neighborhood]" pages.
+// The URL slug IS the SEO target — H1 mirrors the verbatim Google query, and the URL
+// stays stable forever even as rankings rotate. JSON-LD ItemList wraps the top 10.
+const CAT_SLUG_TO_DB = {
+  'salons': 'salon', 'salon': 'salon',
+  'barbershops': 'barbershop', 'barbers': 'barbershop', 'barbershop': 'barbershop',
+  'spas': 'spa', 'spa': 'spa',
+  'nail-salons': 'nail', 'nail': 'nail',
+};
+const CAT_SLUG_TO_LABEL = {
+  'salons': 'Hair Salons', 'salon': 'Hair Salons',
+  'barbershops': 'Barbershops', 'barbers': 'Barbershops', 'barbershop': 'Barbershops',
+  'spas': 'Spas', 'spa': 'Spas',
+  'nail-salons': 'Nail Salons', 'nail': 'Nail Salons',
+};
+app.get('/best/:hoodSlug/:catSlug?', async (req, res) => {
+  try {
+    const hoodSlug = String(req.params.hoodSlug || '').toLowerCase();
+    const catSlug = String(req.params.catSlug || '').toLowerCase();
+    const dbCat = catSlug ? CAT_SLUG_TO_DB[catSlug] : null;
+    const catLabel = catSlug ? (CAT_SLUG_TO_LABEL[catSlug] || 'Salons & Barbers') : 'Salons & Barbers';
+    if (catSlug && !dbCat) return res.status(404).send('Unknown category');
+
+    // Resolve hoodSlug → canonical neighborhood (case-insensitive, hyphen-aware).
+    const hoodRow = await one(
+      `SELECT neighborhood, COUNT(*)::int AS n
+         FROM businesses
+        WHERE neighborhood IS NOT NULL
+          AND LOWER(REGEXP_REPLACE(neighborhood, '\\s+', '-', 'g')) = $1
+        GROUP BY neighborhood
+        ORDER BY n DESC LIMIT 1`,
+      [hoodSlug]
+    );
+    if (!hoodRow) return res.status(404).send(`Neighborhood "${hoodSlug}" not found`);
+    const hood = hoodRow.neighborhood;
+
+    const rows = await many(
+      `SELECT b.slug,
+              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.category, b.address, b.website, b.phone,
+              (b.source_data_json->>'source' = 'dca_bbcb') AS licensed,
+              ig.url AS ig_url, ig.handle AS ig_handle,
+              ye.url AS yelp_url,
+              COUNT(DISTINCT a.id)::int AS article_count,
+              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers,
+              (
+                CASE WHEN b.website IS NOT NULL AND b.website <> '' THEN 15 ELSE 0 END
+              + CASE WHEN ig.url IS NOT NULL THEN 10 ELSE 0 END
+              + CASE WHEN ye.url IS NOT NULL THEN 9 ELSE 0 END
+              + COUNT(DISTINCT a.id)::int * 14
+              + CASE WHEN b.source_data_json->>'source' = 'dca_bbcb' THEN 8 ELSE 0 END
+              + CASE WHEN b.address IS NOT NULL AND b.address <> '' THEN 6 ELSE 0 END
+              + CASE WHEN b.phone IS NOT NULL AND b.phone <> '' THEN 5 ELSE 0 END
+              + CASE WHEN b.latitude IS NOT NULL THEN 5 ELSE 0 END
+              ) AS score
+         FROM businesses b
+         LEFT JOIN business_socials  ig ON ig.business_id = b.id AND ig.platform = 'instagram'
+         LEFT JOIN business_socials  ye ON ye.business_id = b.id AND ye.platform = 'yelp'
+         LEFT JOIN business_articles a  ON a.business_id  = b.id
+        WHERE b.neighborhood = $1
+          ${dbCat ? 'AND b.category = $2' : ''}
+        GROUP BY b.id, ig.url, ig.handle, ye.url
+        ORDER BY score DESC, name
+        LIMIT 100`,
+      dbCat ? [hood, dbCat] : [hood]
+    );
+
+    if (rows.length === 0) return res.status(404).send(`No ${catLabel} found in ${hood}`);
+
+    const baseUrl = process.env.SITE_URL || 'https://localhost';
+    const canonUrl = `${baseUrl}/best/${hoodSlug}${catSlug ? '/' + catSlug : ''}`;
+    const top10 = rows.slice(0, 10);
+    const itemList = {
+      '@context': 'https://schema.org',
+      '@type': 'ItemList',
+      name: `Best ${catLabel} in ${hood}, Los Angeles`,
+      description: `${rows.length} ${catLabel.toLowerCase()} ranked in ${hood}, Los Angeles — based on website presence, press coverage, social, and state licensure.`,
+      url: canonUrl,
+      numberOfItems: rows.length,
+      itemListElement: top10.map((r, i) => ({
+        '@type': 'ListItem',
+        position: i + 1,
+        item: {
+          '@type': 'HairSalon',
+          name: r.name,
+          url: `${baseUrl}/biz/${r.slug}`,
+          ...(r.address ? { address: r.address } : {}),
+          ...(r.phone ? { telephone: r.phone } : {}),
+          ...(r.website ? { sameAs: r.website } : {}),
+        },
+      })),
+    };
+    const breadcrumb = {
+      '@context': 'https://schema.org',
+      '@type': 'BreadcrumbList',
+      itemListElement: [
+        { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
+        { '@type': 'ListItem', position: 2, name: 'Best', item: `${baseUrl}/best` },
+        { '@type': 'ListItem', position: 3, name: hood, item: `${baseUrl}/best/${hoodSlug}` },
+        ...(catSlug ? [{ '@type': 'ListItem', position: 4, name: catLabel, item: canonUrl }] : []),
+      ],
+    };
+
+    res.set('content-type', 'text/html; charset=utf-8');
+    res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Best ${esc(catLabel)} in ${esc(hood)}, Los Angeles · ${rows.length} ranked · LA Salon Directory</title>
+<meta name="description" content="${rows.length} ${esc(catLabel.toLowerCase())} ranked in ${esc(hood)}, Los Angeles. Top picks based on website, press coverage, Instagram, Yelp, and state licensure.">
+<link rel="canonical" href="${esc(canonUrl)}">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<script type="application/ld+json">${escJsonLd(JSON.stringify(itemList))}</script>
+<script type="application/ld+json">${escJsonLd(JSON.stringify(breadcrumb))}</script>
+<style>
+:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}
+.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}
+.brand em{color:var(--accent);font-style:italic}
+.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
+.topnav a:hover{color:var(--ink)}
+.crumb{padding:14px 32px;font-family:monospace;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute);max-width:1240px;margin:0 auto}
+.crumb a:hover{color:var(--ink)}
+.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:1;letter-spacing:-.02em;margin-bottom:14px}
+h1 em{font-style:italic;color:var(--accent)}
+.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch;margin-bottom:20px}
+.review-count{font-family:Cormorant Garamond,serif;font-size:21px;color:var(--accent);font-style:italic}
+.list{padding:32px 32px 64px;max-width:1240px;margin:0 auto}
+.row{display:grid;grid-template-columns:60px 1fr auto;gap:20px;align-items:center;padding:20px 0;border-bottom:1px solid var(--rule);transition:padding 150ms ease}
+.row:hover{padding-left:8px;background:#fff}
+.rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:36px;color:var(--accent);font-weight:500;line-height:1;text-align:center}
+.r-name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.2}
+.r-meta{font-family:monospace;font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;margin-top:4px;line-height:1.5}
+.r-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
+.chip{display:inline-block;padding:3px 9px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:9px;letter-spacing:.08em;text-transform:uppercase}
+.chip-press{background:var(--gold);color:var(--ink);border-color:var(--gold);font-weight:700}
+.chip-lic{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.r-score{font-family:monospace;font-size:11px;color:var(--accent);text-align:right;letter-spacing:.06em}
+.r-score b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--ink);font-weight:500;letter-spacing:-.01em}
+.cat-nav{padding:18px 32px;background:#fff;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;display:flex;gap:18px;flex-wrap:wrap;justify-content:center}
+.cat-nav a{padding:6px 12px;border:1px solid var(--rule)}
+.cat-nav a:hover,.cat-nav a.active{background:var(--accent);color:var(--paper);border-color:var(--accent)}
+.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}
+@media (max-width:880px){.row{grid-template-columns:40px 1fr;gap:14px}.r-score{grid-column:2}}
+</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ best ${esc(hood)}</span></div>
+  <nav class="topnav">
+    <a href="/best">All Best</a>
+    <a href="/press">Press</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<nav class="crumb"><a href="/">Home</a> / <a href="/best">Best</a> / <a href="/best/${esc(hoodSlug)}">${esc(hood)}</a>${catSlug ? ` / <span style="color:var(--ink)">${esc(catLabel)}</span>` : ''}</nav>
+<section class="hero">
+  <h1>Best <em>${esc(catLabel)}</em><br>in ${esc(hood)}, Los Angeles</h1>
+  <p class="hero-lede">The ${rows.length} ${esc(catLabel.toLowerCase())} ranked in ${esc(hood)} based on website presence, press coverage, Instagram, Yelp listings, and California state licensure. Top ${Math.min(rows.length, 10)} below; full list of ${rows.length} continues underneath.</p>
+  <p class="review-count">${rows.length} ${esc(catLabel.toLowerCase())} ranked in this neighborhood</p>
+</section>
+<nav class="cat-nav">
+  <a href="/best/${esc(hoodSlug)}" class="${!catSlug ? 'active' : ''}">All</a>
+  <a href="/best/${esc(hoodSlug)}/salons" class="${catSlug==='salons'?'active':''}">Salons</a>
+  <a href="/best/${esc(hoodSlug)}/barbershops" class="${catSlug==='barbershops'?'active':''}">Barbershops</a>
+  <a href="/best/${esc(hoodSlug)}/spas" class="${catSlug==='spas'?'active':''}">Spas</a>
+  <a href="/best/${esc(hoodSlug)}/nail-salons" class="${catSlug==='nail-salons'?'active':''}">Nail Salons</a>
+</nav>
+<section class="list">
+${rows.map((r, i) => `
+  <a class="row" href="/biz/${esc(r.slug)}">
+    <div class="rank">${i + 1}</div>
+    <div>
+      <div class="r-name">${esc(r.name)}</div>
+      <div class="r-meta">${esc(r.address || hood)}${r.publishers && r.publishers.length ? ' · ' + r.publishers.slice(0,3).map(p=>esc(p)).join(' · ') : ''}</div>
+      <div class="r-chips">
+        ${r.website ? '<span class="chip">🌐 Website</span>' : ''}
+        ${r.ig_url ? `<span class="chip">📷 ${r.ig_handle ? '@'+esc(r.ig_handle) : 'IG'}</span>` : ''}
+        ${r.yelp_url ? '<span class="chip">⭐ Yelp</span>' : ''}
+        ${r.article_count > 0 ? `<span class="chip chip-press">📰 ${r.article_count} Press</span>` : ''}
+        ${r.licensed ? '<span class="chip chip-lic">◆ Licensed</span>' : ''}
+      </div>
+    </div>
+    <div class="r-score">SCORE<b>${r.score}</b></div>
+  </a>
+`).join('')}
+</section>
+<footer class="footer">
+  <a href="/best">← All Best</a> · ${rows.length} ranked in ${esc(hood)} · scoring: website 15 · press 14 · IG 10 · Yelp 9 · licensed 8 · address 6 · phone 5 · geocoded 5
+</footer>
+</body></html>`);
+  } catch (e) { fail(res, '/best/:hood/:cat', e); }
+});
+
+// /press/shops — same press data sliced by SHOP rather than by publisher.
+// The shops with the most article hits — useful for "who's getting written
+// about this season". Sorts by article_count desc.
+app.get('/press/shops', async (_req, res) => {
+  const rows = await many(
+    `SELECT b.slug,
+            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+            b.neighborhood, b.category,
+            COUNT(a.id)::int AS article_count,
+            ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
+       FROM businesses b
+       JOIN business_articles a ON a.business_id = b.id
+      GROUP BY b.id, b.slug, b.name, b.source_data_json, b.neighborhood, b.category
+      ORDER BY article_count DESC, name
+      LIMIT 200`
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Most-Covered Shops · Press · LA Salon Directory</title>
+<meta name="description" content="The LA salons and barbershops with the most editorial press coverage — ranked by article count.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:80px 32px 40px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:17px;color:#3a3a3a;max-width:60ch}.list{padding:48px 32px;max-width:1240px;margin:0 auto}.row{display:grid;grid-template-columns:60px 1fr 80px;gap:24px;align-items:baseline;padding:18px 0;border-bottom:1px solid var(--rule);transition:padding 200ms ease}.row:hover{padding-left:8px;background:#fff}.rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:36px;color:var(--mute);font-weight:500;letter-spacing:-.02em}.r-info .name{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;line-height:1.1}.r-info .meta{font-family:monospace;font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;margin-top:4px}.r-info .pubs{font-family:monospace;font-size:11px;color:var(--accent);margin-top:6px;letter-spacing:.04em}.count{font-family:monospace;font-size:11px;color:var(--mute);text-align:right;letter-spacing:.06em;text-transform:uppercase}.count b{font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;color:var(--ink);font-weight:500;letter-spacing:-.02em;display:block;font-style:italic}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ press / most-covered</span></div>
+  <nav class="topnav">
+    <a href="/press">By publisher</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/near">Near me</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Most-Covered Shops · By Article Count</div>
+  <h1>Who they're <em>writing about.</em></h1>
+  <p class="hero-lede">The salons and barbershops with the most editorial press coverage in our index. Ranked by total article count across publishers like Modern Salon, Fashionista, Voyage LA, Hollywood Reporter, Spa &amp; Beauty Today, and others.</p>
+</section>
+<section class="list">
+  ${rows.length === 0 ? '<div class="empty">No press articles indexed yet. Enrichment fleet still working.</div>' :
+    rows.map((r, i) => `
+      <a class="row" href="/biz/${esc(r.slug)}">
+        <span class="rank">${i + 1}</span>
+        <div class="r-info">
+          <div class="name">${esc(r.name)}</div>
+          <div class="meta">${esc(r.category || '')}${r.neighborhood ? ' · ' + esc(r.neighborhood) : ''}</div>
+          ${r.publishers && r.publishers.length ? `<div class="pubs">${r.publishers.slice(0, 6).map(p => esc(p)).join(' · ')}${r.publishers.length > 6 ? ' +' + (r.publishers.length - 6) : ''}</div>` : ''}
+        </div>
+        <div class="count"><b>${r.article_count}</b>articles</div>
+      </a>
+    `).join('')
+  }
+</section>
+<footer class="footer"><a href="/" style="color:var(--accent)">← Home</a></footer>
+</body></html>`);
+});
+
+// /near — universal "near me" drive-mode map. Same UX as /corridor/:slug/map
+// but unbounded — pulls all geocoded shops (capped 6,000) so the user can
+// drive anywhere in LA county and see the directory live.
+app.get('/near', async (_req, res) => {
+  const businesses = await many(
+    `SELECT slug,
+            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
+            category, neighborhood, latitude, longitude, source_data_json
+       FROM businesses
+      WHERE latitude IS NOT NULL AND longitude IS NOT NULL
+      ORDER BY (source_data_json->>'source' = 'dca_bbcb') DESC,
+               ranking_score DESC NULLS LAST
+      LIMIT 6000`
+  );
+  const totalGeocoded = await one(
+    `SELECT COUNT(*)::int AS n FROM businesses WHERE latitude IS NOT NULL`
+  );
+  const { renderNear } = await import('../render/near.js');
   res.set('content-type', 'text/html; charset=utf-8');
-  res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, featured: featuredBlock, totalCount: all.length }));
+  res.send(renderNear({ businesses, totalGeocoded: totalGeocoded?.n || 0 }));
+});
+
+// /feed.xml — RSS 2.0 of the most-recent press articles. Subscribable in
+// any reader. New shop coverage as it lands — auto-publishes when an
+// agent persists a finding.
+app.get('/feed.xml', async (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  const base = `${proto}://${host}`;
+  const articles = await many(
+    `SELECT a.url, a.title, a.publisher, a.published_at, a.created_at, a.excerpt,
+            b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS biz_name,
+            b.neighborhood
+       FROM business_articles a JOIN businesses b ON b.id = a.business_id
+      ORDER BY COALESCE(a.published_at, a.created_at) DESC LIMIT 50`
+  );
+  const escXml = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+  const items = articles.map(a => {
+    const pubDate = new Date(a.published_at || a.created_at).toUTCString();
+    return `  <item>
+    <title>${escXml(a.title || a.publisher || a.url)}</title>
+    <link>${escXml(a.url)}</link>
+    <guid isPermaLink="false">${escXml(base + '/biz/' + a.slug + '#' + a.url.slice(-32))}</guid>
+    <pubDate>${pubDate}</pubDate>
+    <category>${escXml(a.publisher || 'press')}</category>
+    <description>${escXml(`${a.biz_name}${a.neighborhood ? ' · ' + a.neighborhood : ''} — ${a.publisher || 'press'}: ${(a.excerpt || a.title || '').slice(0, 240)}`)}</description>
+    <source url="${escXml(base + '/biz/' + a.slug)}">${escXml(a.biz_name)}</source>
+  </item>`;
+  }).join('\n');
+  res.set('content-type', 'application/rss+xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+<channel>
+  <title>LA Salon Directory · Press Feed</title>
+  <link>${escXml(base + '/press')}</link>
+  <atom:link href="${escXml(base + '/feed.xml')}" rel="self" type="application/rss+xml"/>
+  <description>Editorial press, blog mentions, and Reddit threads about LA salons + barbers — newest first. ${articles.length} most-recent items.</description>
+  <language>en-us</language>
+  <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
+${items}
+</channel>
+</rss>`);
+});
+
+// /press — public feed of all article + Reddit + blog mentions surfaced by
+// the Exa enrichment fleet. Grouped by publisher (LA Times, Eater, etc).
+// Updates as the agents persist new finds.
+app.get('/press', async (_req, res) => {
+  const articles = await many(
+    `SELECT a.url, a.title, a.publisher, a.published_at, a.excerpt,
+            b.slug, b.name AS biz_name, b.neighborhood, b.category
+       FROM business_articles a
+       JOIN businesses b ON b.id = a.business_id
+      ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC
+      LIMIT 500`
+  );
+  // Group by publisher for the masthead.
+  const byPub = new Map();
+  for (const a of articles) {
+    const p = a.publisher || 'other';
+    if (!byPub.has(p)) byPub.set(p, []);
+    byPub.get(p).push(a);
+  }
+  const orderedPubs = [...byPub.entries()].sort((a, b) => b[1].length - a[1].length);
+  const totalArticles = articles.length;
+  const distinctShops = new Set(articles.map(a => a.slug)).size;
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Press · ${totalArticles} articles · LA Salon Directory</title>
+<meta name="description" content="${totalArticles} press mentions across ${orderedPubs.length} publishers — LA Times, Eater, Hollywood Reporter, NAILS Magazine, and more.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.topnav a:hover{color:var(--ink)}.hero{padding:80px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}.hero h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,96px);line-height:.96;letter-spacing:-.02em;margin-bottom:20px;max-width:14ch}.hero h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:62ch;margin-bottom:32px}.hero-stats{display:flex;gap:48px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}.hero-stats b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}.pub-section{padding:64px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule);scroll-margin-top:80px}.pub-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid var(--rule)}.pub-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(32px,4vw,52px);letter-spacing:-.015em;line-height:1}.pub-head .meta{font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.articles{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0 1px;background:var(--rule);border:1px solid var(--rule)}.article{background:var(--paper);padding:22px 24px;display:block;transition:background 200ms ease;border-bottom:1px solid var(--rule)}.article:hover{background:#fff}.a-header{display:flex;justify-content:space-between;gap:8px;margin-bottom:8px;align-items:baseline}.a-shop{font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}.a-date{font-family:monospace;font-size:9px;color:var(--mute);letter-spacing:.06em}.a-title{font-family:Cormorant Garamond,serif;font-size:21px;font-weight:500;line-height:1.2;letter-spacing:-.005em;color:var(--ink);margin-bottom:6px}.a-excerpt{font-size:13px;line-height:1.5;color:#4a4a4a;margin-bottom:8px}.a-bizlink{font-family:monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;text-transform:uppercase}.empty{text-align:center;padding:96px 32px;color:var(--mute);font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px}.pub-toc{padding:48px 32px;max-width:1240px;margin:0 auto;display:flex;flex-wrap:wrap;gap:6px}.pub-toc a{font-family:monospace;font-size:11px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;padding:6px 12px;border:1px solid var(--rule);transition:all 150ms ease}.pub-toc a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.pub-toc a b{color:var(--ink);margin-right:4px}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}@media(max-width:880px){.topbar{padding:14px 20px}.hero{padding:48px 20px 32px}.pub-section{padding:48px 20px}}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ press</span></div>
+  <nav class="topnav">
+    <a href="/neighborhoods">Neighborhoods</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/fresh">Just Opened</a>
+    <a href="/corridor/ventura-blvd">Corridors</a>
+    <a href="/map">Map</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-eyebrow">The Press File · Articles &amp; Mentions</div>
+  <h1>What they're <em>writing.</em></h1>
+  <p class="hero-lede">Every editorial mention, profile piece, review, and Reddit thread we've found across the LA salon corridor. From the Hollywood Reporter to NAILS Magazine to your local secret-LA blog.</p>
+  <div class="hero-stats">
+    <div>Articles<b>${totalArticles.toLocaleString()}</b></div>
+    <div>Publishers<b>${orderedPubs.length}</b></div>
+    <div>Shops covered<b>${distinctShops.toLocaleString()}</b></div>
+  </div>
+</section>
+
+${orderedPubs.length === 0 ? `
+<div class="empty">No press articles indexed yet. Enrichment fleet is still running.</div>
+` : `
+<nav class="pub-toc">
+  ${orderedPubs.map(([pub, list]) => `<a href="#${esc((pub || '').replace(/[^a-z0-9]/gi, '-').toLowerCase())}"><b>${list.length}</b>${esc(pub)}</a>`).join('')}
+</nav>
+
+${orderedPubs.map(([pub, list]) => `
+  <section class="pub-section" id="${esc((pub || '').replace(/[^a-z0-9]/gi, '-').toLowerCase())}">
+    <div class="pub-head">
+      <h2>${esc(pub)}</h2>
+      <span class="meta">${list.length} article${list.length===1?'':'s'}</span>
+    </div>
+    <div class="articles">
+      ${list.map(a => `
+        <a class="article" href="${esc(a.url)}" target="_blank" rel="noopener">
+          <div class="a-header">
+            <span class="a-shop">${esc(a.biz_name)}${a.neighborhood ? ' · ' + esc(a.neighborhood) : ''}</span>
+            ${a.published_at ? `<span class="a-date">${new Date(a.published_at).toLocaleDateString('en-US',{month:'short',year:'numeric'})}</span>` : ''}
+          </div>
+          <div class="a-title">${esc(a.title || a.url)}</div>
+          ${a.excerpt ? `<p class="a-excerpt">${esc(a.excerpt)}</p>` : ''}
+          <div class="a-bizlink">${esc(a.publisher)} → /biz/${esc(a.slug)}</div>
+        </a>
+      `).join('')}
+    </div>
+  </section>
+`).join('')}
+`}
+
+<footer class="footer">
+  <span>LA Salon Directory · Press File · ${new Date().getFullYear()}</span>
+  <span>${totalArticles.toLocaleString()} articles · ${orderedPubs.length} publishers</span>
+  <span><a href="/">← Home</a></span>
+</footer>
+</body>
+</html>`);
+});
+
+// /api/timeline.json — programmatic feed of recent enrichment events.
+// Same data shape /timeline renders into HTML; useful for embeds and bots.
+app.get('/api/timeline.json', async (_req, res) => {
+  const limit = 100;
+  const [articles, socials] = await Promise.all([
+    many(`SELECT a.url, a.title, a.publisher, a.created_at AS ts,
+            b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+            b.neighborhood, b.category
+       FROM business_articles a JOIN businesses b ON b.id = a.business_id
+      ORDER BY a.created_at DESC LIMIT $1`, [limit]),
+    many(`SELECT s.platform, s.url, s.handle, s.scraped_at AS ts,
+            b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+            b.neighborhood, b.category
+       FROM business_socials s JOIN businesses b ON b.id = s.business_id
+       WHERE s.url IS NOT NULL ORDER BY s.scraped_at DESC LIMIT $1`, [limit]),
+  ]);
+  const events = [
+    ...articles.map(a => ({ kind:'article', ts:a.ts, slug:a.slug, name:a.name, neighborhood:a.neighborhood, category:a.category, url:a.url, title:a.title, publisher:a.publisher })),
+    ...socials.map(s => ({ kind:'social', ts:s.ts, slug:s.slug, name:s.name, neighborhood:s.neighborhood, category:s.category, url:s.url, platform:s.platform, handle:s.handle })),
+  ].sort((a, b) => new Date(b.ts) - new Date(a.ts)).slice(0, limit);
+  res.set('content-type', 'application/json; charset=utf-8');
+  res.set('access-control-allow-origin', '*');
+  res.json({ ok: true, count: events.length, events });
+});
+
+// /api/best.json — programmatic feed of the data-complete /best cohort.
+app.get('/api/best.json', async (_req, res) => {
+  const rows = await many(
+    `SELECT b.slug,
+            COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+            b.category, b.neighborhood, b.city, b.address, b.website,
+            (b.source_data_json->>'source' = 'dca_bbcb') AS licensed,
+            COUNT(DISTINCT a.id)::int AS article_count,
+            COUNT(DISTINCT s.id)::int AS social_count,
+            ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers,
+            ARRAY_AGG(DISTINCT s.platform) FILTER (WHERE s.platform IS NOT NULL) AS platforms
+       FROM businesses b
+       LEFT JOIN business_articles a ON a.business_id = b.id
+       LEFT JOIN business_socials s  ON s.business_id  = b.id
+      WHERE b.website IS NOT NULL AND b.website <> ''
+      GROUP BY b.id, b.slug, b.name, b.source_data_json, b.category, b.neighborhood, b.city, b.address, b.website
+     HAVING COUNT(DISTINCT a.id) >= 1 AND COUNT(DISTINCT s.id) >= 1
+      ORDER BY article_count DESC, social_count DESC, name`
+  );
+  res.set('content-type', 'application/json; charset=utf-8');
+  res.set('access-control-allow-origin', '*');
+  res.json({ ok: true, count: rows.length, shops: rows });
+});
+
+// /api/biz/:slug.json — clean public JSON API for one shop. Joins
+// businesses + business_socials + business_articles + business_review_sources
+// into a single response. Perfect for embeddable widgets and aggregator feeds.
+app.get('/api/biz/:slug.json', async (req, res) => {
+  const biz = await loadBySlug(req.params.slug);
+  if (!biz) return res.status(404).json({ error: 'unknown business' });
+  const [socials, articles, reviewSources] = await Promise.all([
+    many(`SELECT platform, handle, url FROM business_socials WHERE business_id=$1 AND url IS NOT NULL ORDER BY platform`, [biz.id]),
+    many(`SELECT url, title, publisher, published_at, excerpt FROM business_articles WHERE business_id=$1 ORDER BY published_at DESC NULLS LAST, created_at DESC`, [biz.id]),
+    many(`SELECT source_type, url, label FROM business_review_sources WHERE business_id=$1 AND verified=true ORDER BY source_type`, [biz.id]),
+  ]);
+  const dba = biz.source_data_json?.dba_name || null;
+  const licensed = biz.source_data_json?.source === 'dca_bbcb';
+  const corridor = biz.source_data_json?.corridor || null;
+  const sole_prop = biz.source_data_json?.shop_personality === 'sole_prop';
+  res.set('content-type', 'application/json; charset=utf-8');
+  res.set('access-control-allow-origin', '*');  // public read
+  res.json({
+    ok: true,
+    slug: biz.slug,
+    name: biz.name,
+    dba_name: dba,
+    display_name: dba || biz.name,
+    category: biz.category,
+    address: biz.address,
+    city: biz.city,
+    state: biz.state,
+    zip: biz.zip,
+    neighborhood: biz.neighborhood,
+    latitude: biz.latitude ? Number(biz.latitude) : null,
+    longitude: biz.longitude ? Number(biz.longitude) : null,
+    phone: biz.phone,
+    website: biz.website,
+    licensed,
+    license_number: biz.source_data_json?.license_number || null,
+    sole_proprietor: sole_prop,
+    corridor,
+    socials,
+    articles,
+    review_sources: reviewSources,
+    profile_url: `/biz/${biz.slug}`,
+    og_image: `/api/biz/${biz.slug}/og.svg`,
+    feed_url: '/feed.xml',
+  });
+});
+
+// /api/biz/:slug/og.svg — 1200×630 social-share card. Pure SVG so we don't
+// need imagemagick / canvas / sharp. Twitter + FB + iMessage all rasterize
+// SVG embed well. Hand-tuned to match the editorial brand.
+app.get('/api/biz/:slug/og.svg', async (req, res) => {
+  const biz = await loadBySlug(req.params.slug);
+  if (!biz) return res.status(404).send('Not found');
+  const articles = await many(
+    `SELECT publisher FROM business_articles WHERE business_id=$1 LIMIT 5`,
+    [biz.id]
+  );
+  const socials = await many(
+    `SELECT platform FROM business_socials WHERE business_id=$1`,
+    [biz.id]
+  );
+
+  const dba = biz.source_data_json?.dba_name || biz.name;
+  const cat = String(biz.category || 'salon').toUpperCase();
+  const hood = biz.neighborhood || biz.city || 'Los Angeles';
+  const licensed = biz.source_data_json?.source === 'dca_bbcb';
+  const escSvg = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
+  // Wrap long shop names. Approximate: 22 chars per line at the chosen size.
+  const wrap = (s, max) => {
+    if (!s || s.length <= max) return [s || ''];
+    const words = s.split(/\s+/);
+    const lines = []; let cur = '';
+    for (const w of words) {
+      if ((cur + ' ' + w).trim().length > max) { if (cur) lines.push(cur); cur = w; }
+      else cur = (cur ? cur + ' ' : '') + w;
+    }
+    if (cur) lines.push(cur);
+    return lines.slice(0, 3); // cap at 3 lines
+  };
+  const titleLines = wrap(dba, 22);
+  const titleY = 280 - (titleLines.length - 1) * 50;
+  const pubsLine = articles.map(a => a.publisher).filter(Boolean).slice(0, 4).join(' · ');
+  const socsLine = socials.map(s => s.platform).filter(Boolean).join(' · ');
+
+  res.set('content-type', 'image/svg+xml; charset=utf-8');
+  res.set('cache-control', 'public, max-age=600');
+  res.send(`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0%" stop-color="#f5f5f5"/>
+      <stop offset="100%" stop-color="#fafaf6"/>
+    </linearGradient>
+  </defs>
+  <rect width="1200" height="630" fill="url(#bg)"/>
+  <!-- accent bar -->
+  <rect x="0" y="0" width="1200" height="14" fill="#5a6e3a"/>
+  <!-- brand -->
+  <text x="60" y="80" font-family="Georgia, serif" font-size="22" fill="#1a1a1a" font-weight="500">LA <tspan font-style="italic" fill="#5a6e3a">Salon</tspan> Directory</text>
+  <!-- Eyebrow -->
+  <text x="60" y="170" font-family="ui-monospace, Menlo, monospace" font-size="18" fill="#5a6e3a" letter-spacing="3" font-weight="700">${escSvg(cat)} · ${escSvg(hood.toUpperCase())}</text>
+  <!-- Title -->
+  ${titleLines.map((line, i) => `<text x="60" y="${titleY + i * 100}" font-family="Georgia, serif" font-size="96" fill="#1a1a1a" font-weight="500" letter-spacing="-3">${escSvg(line)}</text>`).join('')}
+  <!-- chips bar -->
+  <g transform="translate(60, 470)">
+    ${licensed ? `<rect x="0" y="0" width="180" height="46" fill="#b8945a" stroke="#1a1a1a" stroke-width="2"/>
+    <text x="90" y="31" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" font-weight="700" fill="#1a1a1a" letter-spacing="2">◆ LICENSED</text>` : ''}
+    ${articles.length ? `<rect x="${licensed ? 200 : 0}" y="0" width="160" height="46" fill="#1a1a1a"/>
+    <text x="${licensed ? 280 : 80}" y="31" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" font-weight="700" fill="#facc15" letter-spacing="2">📰 ${articles.length} PRESS</text>` : ''}
+    ${socials.length ? `<rect x="${(licensed?200:0) + (articles.length?180:0)}" y="0" width="180" height="46" fill="#fff" stroke="#1a1a1a" stroke-width="2"/>
+    <text x="${(licensed?290:90) + (articles.length?180:0)}" y="31" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" font-weight="700" fill="#1a1a1a" letter-spacing="2">📷 ${socials.length} SOCIAL${socials.length===1?'':'S'}</text>` : ''}
+  </g>
+  <!-- pubs -->
+  ${pubsLine ? `<text x="60" y="568" font-family="ui-monospace, monospace" font-size="16" fill="#5a6e3a" letter-spacing="2">PRESS · ${escSvg(pubsLine)}</text>` : ''}
+  <!-- footer URL -->
+  <text x="60" y="600" font-family="ui-monospace, monospace" font-size="14" fill="#7a766f" letter-spacing="2">la-salon-directory · /biz/${escSvg(biz.slug)}</text>
+  <!-- right-hand decorative seal (rotates "LICENSED" in a ring or just brand mark) -->
+  <g transform="translate(1040, 100)">
+    <circle r="80" fill="none" stroke="#5a6e3a" stroke-width="3" opacity="0.4"/>
+    <text x="0" y="-20" text-anchor="middle" font-family="ui-monospace, monospace" font-size="11" letter-spacing="3" fill="#5a6e3a" font-weight="700">EST.</text>
+    <text x="0" y="8" text-anchor="middle" font-family="Georgia, serif" font-size="34" font-style="italic" fill="#5a6e3a" font-weight="500">${new Date().getFullYear()}</text>
+    <text x="0" y="28" text-anchor="middle" font-family="ui-monospace, monospace" font-size="9" letter-spacing="2" fill="#5a6e3a">DIRECTORY</text>
+  </g>
+</svg>`);
+});
+
+// /loop.log — serve the loop-tick log over HTTP so Steve can stream it from
+// any machine (MacBookPro, phone, etc.) without ssh'ing in. Plain text.
+//   curl -Ns http://localhost:9760/loop.log     — last 200 lines, then live tail
+//   curl -s  http://localhost:9760/loop.log?n=50 — just last 50 lines
+app.get('/loop.log', async (req, res) => {
+  const n = Math.min(2000, Math.max(10, parseInt(req.query.n || '200', 10)));
+  const live = req.query.live !== '0';
+  const fsMod = await import('node:fs');
+  const path = await import('node:path');
+  const logPath = path.join(__dirname, '..', '..', 'logs', 'loop.log');
+  res.set('content-type', 'text/plain; charset=utf-8');
+  res.set('access-control-allow-origin', '*');
+  // Initial dump of last N lines.
+  let lastSize = 0;
+  try {
+    const data = fsMod.readFileSync(logPath, 'utf8');
+    const lines = data.split('\n').filter(Boolean);
+    res.write(lines.slice(-n).join('\n') + '\n');
+    lastSize = Buffer.byteLength(data);
+  } catch {
+    res.write('# log not found yet — first tick will create it\n');
+  }
+  if (!live) return res.end();
+  // Live tail: poll every 1.5s, push appended bytes only.
+  res.write('# --- live tail ---\n');
+  const interval = setInterval(() => {
+    try {
+      const stat = fsMod.statSync(logPath);
+      if (stat.size > lastSize) {
+        const fd = fsMod.openSync(logPath, 'r');
+        const buf = Buffer.alloc(stat.size - lastSize);
+        fsMod.readSync(fd, buf, 0, buf.length, lastSize);
+        fsMod.closeSync(fd);
+        res.write(buf.toString('utf8'));
+        lastSize = stat.size;
+      } else if (stat.size < lastSize) {
+        // Log was rotated; reset.
+        lastSize = 0;
+      }
+    } catch (_) {}
+  }, 1500);
+  req.on('close', () => clearInterval(interval));
+});
+
+// /leaderboard — ranked feed of every shop with at least one signal,
+// ordered by SQL-approximated score. Same 14-signal rubric as /analyze
+// but computed in SQL for speed (instead of Node-side per-row).
+//   /leaderboard                        — top 200 by score
+//   /leaderboard?hood=Beverly+Hills     — neighborhood-scoped
+//   /leaderboard?cat=barbershop         — category-scoped
+//   /leaderboard?from=200&to=400        — pagination
+// /api/leaderboard.json — programmatic feed of the SQL-scored leaderboard.
+// Same scoring as /leaderboard but JSON shape. Supports ?hood=X&cat=Y&limit=N.
+app.get('/api/leaderboard.json', async (req, res) => {
+  const hood = req.query.hood ? String(req.query.hood).slice(0, 80) : null;
+  const cat = req.query.cat ? String(req.query.cat).slice(0, 40) : null;
+  const limit = Math.min(500, Math.max(1, parseInt(req.query.limit || '100', 10)));
+  const params = [];
+  let where = '1=1';
+  if (hood) { params.push(hood); where += ` AND b.neighborhood = $${params.length}`; }
+  if (cat)  { params.push(cat);  where += ` AND b.category = $${params.length}`; }
+  const rows = await many(
+    `WITH agg AS (
+       SELECT b.id, b.slug,
+              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.category, b.neighborhood, b.website,
+              (b.source_data_json->>'source' = 'dca_bbcb')          AS licensed,
+              (b.source_data_json->>'dba_name' IS NOT NULL)         AS has_dba,
+              COUNT(DISTINCT a.id)                                   AS article_count,
+              BOOL_OR(s.platform = 'instagram')                      AS has_ig,
+              BOOL_OR(s.platform = 'yelp')                           AS has_yelp,
+              BOOL_OR(s.platform = 'facebook')                       AS has_fb,
+              BOOL_OR(s.platform = 'tiktok')                         AS has_tiktok,
+              EXISTS (SELECT 1 FROM business_review_sources r WHERE r.business_id = b.id AND r.verified) AS has_review,
+              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
+         FROM businesses b
+         LEFT JOIN business_articles a ON a.business_id = b.id
+         LEFT JOIN business_socials s  ON s.business_id  = b.id AND s.url IS NOT NULL
+        WHERE ${where}
+        GROUP BY b.id
+     )
+     SELECT *,
+            (CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
+           + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
+           + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
+           + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
+           + CASE WHEN licensed                               THEN  8 ELSE 0 END
+           + CASE WHEN has_dba                                THEN  5 ELSE 0 END
+           + CASE WHEN has_fb                                 THEN  5 ELSE 0 END
+           + CASE WHEN has_tiktok                             THEN  5 ELSE 0 END
+           + CASE WHEN has_review                             THEN  5 ELSE 0 END
+           )::int AS score
+       FROM agg
+      ORDER BY score DESC, article_count DESC, name
+      LIMIT ${limit}`,
+    params
+  );
+  res.set('content-type', 'application/json; charset=utf-8');
+  res.set('access-control-allow-origin', '*');
+  res.json({ ok: true, count: rows.length, hood, category: cat, shops: rows });
+});
+
+// /leaderboard/by-category — at-a-glance view: top 5 by score in each
+// of the major categories. Useful as a competitive scan without filtering
+// the main leaderboard category-by-category.
+app.get('/leaderboard/by-category', async (_req, res) => {
+  // Compute scored top-5 per category in one query.
+  const rows = await many(
+    `WITH agg AS (
+       SELECT b.id, b.slug,
+              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.category, b.neighborhood, b.address, b.website,
+              (b.source_data_json->>'source' = 'dca_bbcb')          AS licensed,
+              (b.source_data_json->>'dba_name' IS NOT NULL)         AS has_dba,
+              COUNT(DISTINCT a.id)                                   AS article_count,
+              BOOL_OR(s.platform = 'instagram')                      AS has_ig,
+              BOOL_OR(s.platform = 'yelp')                           AS has_yelp,
+              BOOL_OR(s.platform = 'facebook')                       AS has_fb,
+              BOOL_OR(s.platform = 'tiktok')                         AS has_tiktok,
+              EXISTS (SELECT 1 FROM business_review_sources r WHERE r.business_id = b.id AND r.verified) AS has_review,
+              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
+         FROM businesses b
+         LEFT JOIN business_articles a ON a.business_id = b.id
+         LEFT JOIN business_socials s  ON s.business_id  = b.id AND s.url IS NOT NULL
+        WHERE b.category IS NOT NULL
+        GROUP BY b.id
+     ),
+     scored AS (
+       SELECT *,
+              (CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
+             + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
+             + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
+             + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
+             + CASE WHEN licensed                               THEN  8 ELSE 0 END
+             + CASE WHEN address IS NOT NULL AND address <> '' THEN  6 ELSE 0 END
+             + CASE WHEN has_dba                                THEN  5 ELSE 0 END
+             + CASE WHEN has_fb                                 THEN  5 ELSE 0 END
+             + CASE WHEN has_tiktok                             THEN  5 ELSE 0 END
+             + CASE WHEN has_review                             THEN  5 ELSE 0 END
+             )::int AS score,
+              ROW_NUMBER() OVER (PARTITION BY category ORDER BY
+                CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
+              + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
+              + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
+              + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
+              + CASE WHEN licensed                               THEN  8 ELSE 0 END
+              + CASE WHEN address IS NOT NULL AND address <> '' THEN  6 ELSE 0 END DESC,
+                article_count DESC, name) AS rnk
+         FROM agg
+     )
+     SELECT * FROM scored WHERE rnk <= 5
+      ORDER BY category, rnk`
+  );
+  // Group by category for the rendered layout.
+  const byCat = new Map();
+  for (const r of rows) {
+    if (!byCat.has(r.category)) byCat.set(r.category, []);
+    byCat.get(r.category).push(r);
+  }
+  // Sort categories by total shop count (more = featured first).
+  const catSizes = await many(
+    `SELECT category, COUNT(*)::int AS n FROM businesses
+      WHERE category IS NOT NULL GROUP BY category ORDER BY n DESC`
+  );
+  const ordered = catSizes.filter(c => byCat.has(c.category)).map(c => [c.category, byCat.get(c.category), c.n]);
+
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+  const colorFor = (score) => score >= 70 ? '#22c55e' : score >= 50 ? '#84cc16' : score >= 30 ? '#facc15' : score >= 15 ? '#f97316' : '#dc2626';
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Leaderboard by Category · LA Salon Directory</title>
+<meta name="description" content="Top 5 highest-scoring shops in each category — barbershops, salons, spas, nails, and more.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased;line-height:1.45}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.cat-section{padding:48px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule)}.cat-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid var(--rule)}.cat-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:36px;text-transform:capitalize;letter-spacing:-.015em}.cat-head .meta{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.cat-head .more{color:var(--accent);font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;text-decoration:underline}.top5{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.spot{background:var(--paper);padding:18px 20px;display:flex;flex-direction:column;gap:6px;transition:background 200ms}.spot:hover{background:#fff}.spot-rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:36px;color:var(--accent);font-weight:500;line-height:1;letter-spacing:-.02em}.spot-name{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500;line-height:1.15;letter-spacing:-.005em}.spot-where{font-family:Space Mono,monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em}.spot-score{display:inline-flex;align-items:center;gap:6px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.04em;font-weight:700;color:#000;padding:2px 8px;width:fit-content}.spot-pubs{font-family:Space Mono,monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;line-height:1.5;margin-top:2px}.empty{padding:48px;font-family:Cormorant Garamond,serif;font-style:italic;color:var(--mute);text-align:center}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ leaderboard / by category</span></div>
+  <nav class="topnav">
+    <a href="/grades">Full leaderboard</a>
+    <a href="/best">★ Best</a>
+    <a href="/timeline">Live Feed</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Top 5 per category · ranked by 14-signal score</div>
+  <h1>The <em>category</em> leaders.</h1>
+  <p style="font-size:16px;color:#3a3a3a;max-width:60ch">Pick your battle. The shops with the strongest SEO + press + social profile in each service vertical.</p>
+</section>
+${ordered.length === 0 ? '<div class="empty">No data yet.</div>' : ordered.map(([cat, list, total]) => `
+  <section class="cat-section" id="${safe(cat)}">
+    <div class="cat-head">
+      <h2>${safe(cat)}</h2>
+      <span class="meta">${list.length} top of ${total.toLocaleString()} ${safe(cat)}s · <a class="more" href="/leaderboard?cat=${encodeURIComponent(cat)}">full ranking →</a></span>
+    </div>
+    <div class="top5">
+      ${list.map(s => `
+        <a class="spot" href="/biz/${safe(s.slug)}/analyze">
+          <div class="spot-rank">№ ${s.rnk}</div>
+          <div class="spot-name">${safe(s.name)}</div>
+          <div class="spot-where">${safe(s.neighborhood || s.address || '')}</div>
+          <div class="spot-score" style="background:${colorFor(s.score)}">${s.score} · ${s.score >= 70 ? 'A' : s.score >= 50 ? 'B' : s.score >= 30 ? 'C' : s.score >= 15 ? 'D' : 'F'}</div>
+          ${s.publishers && s.publishers.length ? `<div class="spot-pubs">📰 ${s.publishers.slice(0, 2).map(p => safe(p)).join(' · ')}${s.publishers.length > 2 ? ' +' + (s.publishers.length - 2) : ''}</div>` : ''}
+        </a>
+      `).join('')}
+    </div>
+  </section>
+`).join('')}
+<footer class="footer"><a href="/" style="color:var(--accent)">← Home</a></footer>
+</body></html>`);
+});
+
+app.get('/leaderboard', async (req, res) => {
+  const hood = req.query.hood ? String(req.query.hood).slice(0, 80) : null;
+  const cat = req.query.cat ? String(req.query.cat).slice(0, 40) : null;
+  const from = Math.max(0, parseInt(req.query.from || '0', 10));
+  const to = Math.min(from + 500, parseInt(req.query.to || (from + 200).toString(), 10));
+  const limit = Math.min(500, to - from);
+  const params = [];
+  let where = '1=1';
+  if (hood) { params.push(hood); where += ` AND b.neighborhood = $${params.length}`; }
+  if (cat)  { params.push(cat);  where += ` AND b.category = $${params.length}`; }
+  const rows = await many(
+    `WITH agg AS (
+       SELECT b.id, b.slug,
+              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.category, b.neighborhood, b.city, b.address, b.website,
+              b.latitude, b.longitude, b.phone, b.email, b.about_text,
+              (b.source_data_json->>'source' = 'dca_bbcb')          AS licensed,
+              (b.source_data_json->>'dba_name' IS NOT NULL)         AS has_dba,
+              COUNT(DISTINCT a.id)                                   AS article_count,
+              BOOL_OR(s.platform = 'instagram')                      AS has_ig,
+              BOOL_OR(s.platform = 'yelp')                           AS has_yelp,
+              BOOL_OR(s.platform = 'facebook')                       AS has_fb,
+              BOOL_OR(s.platform = 'tiktok')                         AS has_tiktok,
+              EXISTS (SELECT 1 FROM business_review_sources r WHERE r.business_id = b.id AND r.verified)
+                                                                     AS has_review,
+              COUNT(DISTINCT s.id)                                   AS social_count,
+              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
+         FROM businesses b
+         LEFT JOIN business_articles a ON a.business_id = b.id
+         LEFT JOIN business_socials s  ON s.business_id  = b.id AND s.url IS NOT NULL
+        WHERE ${where}
+        GROUP BY b.id
+     )
+     SELECT *,
+            (CASE WHEN website IS NOT NULL AND website <> ''  THEN 15 ELSE 0 END
+           + CASE WHEN article_count >= 1                     THEN 14 ELSE 0 END
+           + CASE WHEN has_ig                                 THEN 10 ELSE 0 END
+           + CASE WHEN has_yelp                               THEN  9 ELSE 0 END
+           + CASE WHEN licensed                               THEN  8 ELSE 0 END
+           + CASE WHEN address IS NOT NULL AND address <> '' THEN  6 ELSE 0 END
+           + CASE WHEN phone IS NOT NULL AND phone <> ''     THEN  5 ELSE 0 END
+           + CASE WHEN latitude IS NOT NULL                  THEN  5 ELSE 0 END
+           + CASE WHEN has_dba                                THEN  5 ELSE 0 END
+           + CASE WHEN has_fb                                 THEN  5 ELSE 0 END
+           + CASE WHEN has_tiktok                             THEN  5 ELSE 0 END
+           + CASE WHEN has_review                             THEN  5 ELSE 0 END
+           + CASE WHEN email IS NOT NULL AND email <> ''     THEN  4 ELSE 0 END
+           + CASE WHEN about_text IS NOT NULL AND about_text <> '' THEN  4 ELSE 0 END
+           )::int AS score
+       FROM agg
+      ORDER BY score DESC, article_count DESC, social_count DESC, name
+      LIMIT ${limit} OFFSET ${from}`,
+    params
+  );
+  const totalQ = await one(
+    `SELECT COUNT(*)::int AS n FROM businesses b WHERE ${where}`,
+    params
+  );
+
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+  const colorFor = (score) => score >= 80 ? '#22c55e' : score >= 60 ? '#84cc16' : score >= 40 ? '#facc15' : score >= 20 ? '#f97316' : '#dc2626';
+  const labelFor = (score) => score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : score >= 20 ? 'D' : 'F';
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Leaderboard · LA Salon Directory${hood ? ' · ' + safe(hood) : ''}${cat ? ' · ' + safe(cat) : ''}</title>
+<meta name="description" content="${rows.length} top-scoring LA salons + barbers ranked by SEO + quality + press signals${hood ? ' in ' + safe(hood) : ''}.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased;line-height:1.45}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.lede{font-size:16px;color:#3a3a3a;max-width:60ch}.filter-row{padding:24px 32px;max-width:1240px;margin:0 auto;display:flex;gap:8px;flex-wrap:wrap;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;align-items:center}.filter-row form{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.filter-row select,.filter-row input{padding:8px 12px;border:1px solid var(--rule);background:#fff;font-family:inherit;font-size:11px;font-weight:700;text-transform:uppercase}.filter-row button{background:#1a1a1a;color:#facc15;border:none;padding:8px 14px;font-family:inherit;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;cursor:pointer}.filter-row .clear{color:var(--mute);text-decoration:underline}.list{max-width:1240px;margin:0 auto;padding:24px 32px}.row{display:grid;grid-template-columns:60px 80px 1fr 240px 110px;gap:16px;align-items:center;padding:14px;border-bottom:1px solid var(--rule);transition:background 200ms ease}.row:hover{background:#fff}.rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute);font-weight:500;letter-spacing:-.02em;text-align:center}.score-pill{display:flex;flex-direction:column;align-items:center;gap:0}.score-pill .v{font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;font-weight:500;letter-spacing:-.02em;line-height:1}.score-pill .grade{font-family:Space Mono,monospace;font-size:10px;font-weight:700;letter-spacing:.16em;color:#000;padding:1px 8px;border:1px solid #000;margin-top:4px}.shop-info{min-width:0}.shop-info .name{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;line-height:1.15;letter-spacing:-.005em}.shop-info .meta{font-family:Space Mono,monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin-top:4px}.shop-info .pubs{font-family:Space Mono,monospace;font-size:10px;color:var(--accent);margin-top:4px;letter-spacing:.04em;line-height:1.5}.signals{display:flex;flex-wrap:wrap;gap:4px;font-family:Space Mono,monospace;font-size:9px;letter-spacing:.04em;text-transform:uppercase}.signals span{padding:2px 6px;border:1px solid var(--rule);background:#fff;font-weight:600}.signals span.on{background:var(--accent);color:#fff;border-color:var(--accent)}.signals span.lic{background:var(--gold);color:#000;border-color:#000}.actions{display:flex;flex-direction:column;gap:4px;align-items:flex-end}.actions a{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);text-align:right}.empty{padding:96px 32px;text-align:center;color:var(--mute);font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px}@media(max-width:880px){.row{grid-template-columns:auto auto 1fr;gap:8px}.signals,.actions{grid-column:1/-1}}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}.pager{padding:24px;text-align:center;display:flex;gap:12px;justify-content:center;font-family:Space Mono,monospace;font-size:11px}.pager a{padding:8px 16px;border:1px solid var(--rule);background:#fff;color:var(--ink);font-weight:700;letter-spacing:.06em;text-transform:uppercase}.pager a:hover{border-color:var(--accent);color:var(--accent)}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ leaderboard${hood ? ' / ' + safe(hood) : ''}${cat ? ' / ' + safe(cat) : ''}</span></div>
+  <nav class="topnav">
+    <a href="/best">★ Best</a>
+    <a href="/timeline">Live Feed</a>
+    <a href="/stats">Stats</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Ranked by 14-signal SEO + quality score · 0–100</div>
+  <h1>The <em>leaderboard.</em></h1>
+  <p class="lede">Every shop scored on the same 14 weighted signals as <a href="/biz/moraga-bernie-93010/analyze" style="color:var(--accent);text-decoration:underline">/analyze</a>. Higher = more press, more socials, more presence. Click any shop to see the breakdown + suggestions to climb the ranking.</p>
+</section>
+<section class="filter-row">
+  <form method="get">
+    <label style="color:var(--mute)">Hood:</label>
+    <input type="text" name="hood" value="${safe(hood || '')}" placeholder="e.g. Beverly Hills" autocomplete="off">
+    <label style="color:var(--mute);margin-left:8px">Cat:</label>
+    <select name="cat">
+      <option value="">all</option>
+      ${['barbershop','salon','spa','nail','restaurant','retail','cafe','bar','health','office'].map(c => `<option value="${c}" ${cat === c ? 'selected' : ''}>${c}</option>`).join('')}
+    </select>
+    <button type="submit">Filter</button>
+  </form>
+  ${hood || cat ? '<a class="clear" href="/grades">Clear filter</a>' : ''}
+  <span style="margin-left:auto;color:var(--mute)">${rows.length.toLocaleString()} of ${totalQ.n.toLocaleString()} shops${hood || cat ? ' · filtered' : ''}</span>
+</section>
+${rows.length === 0 ? `<div class="empty">No matches. Try clearing filters.</div>` : `
+<section class="list">
+  ${rows.map((r, i) => {
+    const grade = labelFor(r.score);
+    const color = colorFor(r.score);
+    const signals = [
+      { on: !!(r.website && r.website.trim()), label: '🌐 Web' },
+      { on: r.article_count >= 1,              label: `📰 ${r.article_count}` },
+      { on: !!r.has_ig,                        label: '📷 IG' },
+      { on: !!r.has_yelp,                      label: '⭐ Yelp' },
+      { on: !!r.licensed,                      label: '◆ Lic',     cls: 'lic' },
+      { on: !!r.has_dba,                       label: 'DBA' },
+      { on: !!r.has_fb,                        label: '👤 FB' },
+      { on: r.latitude != null,                label: '📍 Geo' },
+    ];
+    return `<a class="row" href="/biz/${safe(r.slug)}/analyze">
+      <span class="rank">${(from + i + 1).toLocaleString()}</span>
+      <div class="score-pill">
+        <span class="v" style="color:${color}">${r.score}</span>
+        <span class="grade" style="background:${color}">${grade}</span>
+      </div>
+      <div class="shop-info">
+        <div class="name">${safe(r.name)}</div>
+        <div class="meta">${safe(r.category || 'business')}${r.neighborhood ? ' · ' + safe(r.neighborhood) : ''}${r.address ? ' · ' + safe(r.address) : ''}</div>
+        ${r.publishers && r.publishers.length ? `<div class="pubs">📰 ${r.publishers.slice(0, 3).map(p => safe(p)).join(' · ')}${r.publishers.length > 3 ? ' +' + (r.publishers.length - 3) : ''}</div>` : ''}
+      </div>
+      <div class="signals">
+        ${signals.filter(s => s.on).map(s => `<span class="on ${s.cls || ''}">${s.label}</span>`).join('')}
+      </div>
+      <div class="actions">
+        <span style="color:var(--accent);font-weight:600">analyze →</span>
+        ${r.website ? `<span style="font-size:9px;color:var(--mute)">${safe(r.website.replace(/^https?:\/\/(www\.)?/, '').slice(0, 26))}</span>` : ''}
+      </div>
+    </a>`;
+  }).join('')}
+</section>
+<div class="pager">
+  ${from > 0 ? `<a href="?${hood ? 'hood=' + encodeURIComponent(hood) + '&' : ''}${cat ? 'cat=' + encodeURIComponent(cat) + '&' : ''}from=${Math.max(0, from - 200)}&to=${from}">← Previous 200</a>` : ''}
+  ${rows.length === limit ? `<a href="?${hood ? 'hood=' + encodeURIComponent(hood) + '&' : ''}${cat ? 'cat=' + encodeURIComponent(cat) + '&' : ''}from=${to}&to=${to + 200}">Next 200 →</a>` : ''}
+</div>
+`}
+<footer class="footer">
+  <a href="/" style="color:var(--accent)">← Home</a>
+  · ${rows.length.toLocaleString()} shops scored · live data
+</footer>
+</body></html>`);
+});
+
+// =====================================================================
+// /biz/:slug/analyze — heuristic SEO + quality + leader-comparison report
+// =====================================================================
+// Score out of 100 across 14 weighted signals. Compares each shop to top
+// leaders in same neighborhood + category. Suggestions ranked by impact.
+
+const SCORE_RUBRIC = [
+  { key:'has_website',  weight:15, check:b => !!(b.website && b.website.trim()),                    label:'Website',         tip:"Get a Squarespace or Wix site (~$15/mo). Even a 1-page site beats a Yelp-only listing for trust + Google ranking." },
+  { key:'has_press',    weight:14, check:(_, ctx) => ctx.articleCount >= 1,                         label:'Press mention',   tip:"Pitch one local writer (LA Times, Eater LA, LAmag, Voyage LA, Hoodline). One feature compounds — it drives all your other SEO." },
+  { key:'has_ig',       weight:10, check:(_, ctx) => ctx.socialPlatforms.has('instagram'),          label:'Instagram',       tip:"Claim an IG handle today. Post 3 cuts/looks per week. Costs nothing, doubles discovery for under-30 customers." },
+  { key:'has_yelp',     weight:9,  check:(_, ctx) => ctx.socialPlatforms.has('yelp'),               label:'Yelp page',       tip:"Claim your Yelp listing (free). Add 5 photos + business hours. Most LA salon discovery still happens here." },
+  { key:'licensed',     weight:8,  check:b => b.source_data_json?.source === 'dca_bbcb',            label:'State-licensed',  tip:"Make sure your BBCB establishment license is current — public verify badge on your profile only shows when registered with the state board." },
+  { key:'has_address',  weight:6,  check:b => !!(b.address && b.address.trim()),                    label:'Physical address',tip:"Address must be on the profile, on Google Maps, on Yelp, on IG bio. Customers verify before they book." },
+  { key:'has_phone',    weight:5,  check:b => !!(b.phone && String(b.phone).trim()),                label:'Phone',           tip:"Add a tappable phone number. Mobile users won't fight a contact form for a haircut booking." },
+  { key:'geocoded',     weight:5,  check:b => b.latitude != null && b.longitude != null,            label:'Map pin',         tip:"Address must geocode cleanly. Double-check the suite/unit number — Apple Maps + Google Maps need an exact match." },
+  { key:'has_dba',      weight:5,  check:b => !!(b.source_data_json?.dba_name),                     label:'Public brand',    tip:"If license reads as 'LASTNAME FIRSTNAME', your public brand should match across Yelp + IG + Google Maps." },
+  { key:'has_fb',       weight:5,  check:(_, ctx) => ctx.socialPlatforms.has('facebook'),           label:'Facebook page',   tip:"Older customers search FB first. Free, 10-min setup, shows up in Google rich-card results." },
+  { key:'has_tiktok',   weight:5,  check:(_, ctx) => ctx.socialPlatforms.has('tiktok'),             label:'TikTok',          tip:"For under-30: TikTok > IG. Post a haircut transformation video weekly. Algo will hand you free local discovery." },
+  { key:'has_review',   weight:5,  check:(_, ctx) => ctx.reviewCount >= 1,                          label:'Community link',  tip:"Submit a Reddit/Discord/community link via /biz/{slug}/links — opt-in, free, surfaces you in the local community feed." },
+  { key:'has_email',    weight:4,  check:b => !!(b.email && String(b.email).trim()),                label:'Booking email',   tip:"Add a public email so press + influencers can reach you for collaborations." },
+  { key:'has_about',    weight:4,  check:b => !!(b.about_text && String(b.about_text).trim()),      label:'About paragraph', tip:"Write 2-3 sentences about your story. AI tools (Google AI Overview, ChatGPT) only quote shops with a real 'about' passage." },
+];
+
+app.get('/biz/:slug/analyze', async (req, res) => {
+  const biz = await loadBySlug(req.params.slug);
+  if (!biz) return res.status(404).send('Not found');
+
+  const [socials, articles, reviewSources, pricing, hoodLeaders, hoodMedians] = await Promise.all([
+    many(`SELECT platform, handle, url FROM business_socials WHERE business_id=$1 AND url IS NOT NULL`, [biz.id]),
+    many(`SELECT url, title, publisher FROM business_articles WHERE business_id=$1 ORDER BY published_at DESC NULLS LAST LIMIT 10`, [biz.id]),
+    many(`SELECT source_type, url FROM business_review_sources WHERE business_id=$1 AND verified=true`, [biz.id]),
+    many(`SELECT service_kind, price_low, price_high, notes FROM business_pricing WHERE business_id=$1`, [biz.id]),
+    biz.neighborhood ? many(
+      `SELECT b.slug,
+              COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.category,
+              COUNT(DISTINCT a.id)::int AS article_count,
+              COUNT(DISTINCT s.id)::int AS social_count,
+              ARRAY_AGG(DISTINCT a.publisher) FILTER (WHERE a.publisher IS NOT NULL) AS publishers
+         FROM businesses b
+         LEFT JOIN business_articles a ON a.business_id = b.id
+         LEFT JOIN business_socials s ON s.business_id = b.id AND s.url IS NOT NULL
+        WHERE b.neighborhood = $1 AND b.id <> $2
+          AND (b.category = $3 OR $3 IS NULL)
+        GROUP BY b.id, b.slug, b.name, b.source_data_json, b.category
+       HAVING COUNT(DISTINCT a.id) > 0 OR COUNT(DISTINCT s.id) >= 2
+        ORDER BY article_count DESC, social_count DESC
+        LIMIT 5`,
+      [biz.neighborhood, biz.id, biz.category]
+    ) : Promise.resolve([]),
+    biz.neighborhood ? many(
+      `SELECT p.service_kind,
+              percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_low) AS median_low,
+              MIN(p.price_low) AS min_low, MAX(p.price_high) AS max_high,
+              COUNT(*)::int AS sample_size
+         FROM business_pricing p
+         JOIN businesses b ON b.id = p.business_id
+        WHERE b.neighborhood = $1 AND (b.category = $2 OR $2 IS NULL)
+        GROUP BY p.service_kind`,
+      [biz.neighborhood, biz.category]
+    ) : Promise.resolve([]),
+  ]);
+
+  const ctx = {
+    articleCount:  articles.length,
+    socialPlatforms: new Set(socials.map(s => s.platform)),
+    reviewCount:   reviewSources.length,
+  };
+  let totalScore = 0;
+  const breakdown = SCORE_RUBRIC.map(r => {
+    const passed = r.check(biz, ctx);
+    if (passed) totalScore += r.weight;
+    return { ...r, passed };
+  });
+  const suggestions = breakdown.filter(b => !b.passed).sort((a, b) => b.weight - a.weight);
+  const leaderArticleAvg = hoodLeaders.length ? Math.round(hoodLeaders.reduce((s, l) => s + l.article_count, 0) / hoodLeaders.length) : 0;
+  const leaderSocialAvg  = hoodLeaders.length ? Math.round(hoodLeaders.reduce((s, l) => s + l.social_count,  0) / hoodLeaders.length) : 0;
+  const scoreColor = totalScore >= 80 ? '#22c55e' : totalScore >= 60 ? '#84cc16' : totalScore >= 40 ? '#facc15' : totalScore >= 20 ? '#f97316' : '#dc2626';
+  const scoreLabel = totalScore >= 80 ? 'Excellent' : totalScore >= 60 ? 'Strong' : totalScore >= 40 ? 'Average' : totalScore >= 20 ? 'Thin' : 'Bare';
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+  const dba = biz.source_data_json?.dba_name || biz.name;
+  const fmtPrice = (cents) => cents ? '$' + Math.round(cents / 100) : '?';
+  const SERVICE_LABEL = {
+    mens_haircut:   "Men's Haircut", womens_haircut: "Women's Haircut",
+    color:          "Color (single process)", balayage: "Balayage / Highlights",
+    manicure:       "Manicure", pedicure: "Pedicure",
+    facial:         "Facial", wax: "Wax", brow: "Brow", lash: "Lash extensions",
+    other:          "Other",
+  };
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Analysis · ${safe(dba)} · LA Salon Directory</title>
+<meta name="description" content="${safe(dba)} scored ${totalScore}/100 on 14 SEO + quality signals. Compared against top ${hoodLeaders.length} leaders in ${safe(biz.neighborhood || 'LA')}.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1200px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.score-row{display:grid;grid-template-columns:auto 1fr;gap:32px;align-items:center;margin-top:24px}.score-circle{width:160px;height:160px;border-radius:50%;background:${scoreColor};color:#000;display:flex;align-items:center;justify-content:center;flex-direction:column;border:8px solid #1a1a1a}.score-circle b{font-family:Cormorant Garamond,serif;font-size:64px;font-weight:500;line-height:1;font-style:italic;letter-spacing:-.02em}.score-circle span{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;margin-top:2px}.score-meta strong{font-family:Cormorant Garamond,serif;font-size:36px;font-style:italic;letter-spacing:-.01em;display:block;margin-bottom:6px;color:var(--ink)}.score-meta p{color:#3a3a3a;font-size:15px;max-width:42ch}.section{padding:48px 32px;max-width:1200px;margin:0 auto;border-top:1px solid var(--rule)}.section h2{font-family:Cormorant Garamond,serif;font-size:32px;font-weight:500;margin-bottom:6px}.section .lede{color:var(--mute);font-size:13px;font-family:Space Mono,monospace;letter-spacing:.06em;text-transform:uppercase;margin-bottom:24px}.score-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.signal{background:#fff;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:13px}.signal.pass{background:#f0fdf4}.signal.fail{background:#fafaf6;color:#666}.signal-name{font-family:Cormorant Garamond,serif;font-size:18px;font-weight:500}.signal-state{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;padding:3px 8px;border:2px solid #1a1a1a}.signal.pass .signal-state{background:#22c55e;color:#000}.signal.fail .signal-state{background:#fff;color:#000}.signal-weight{font-family:Space Mono,monospace;font-size:9px;color:var(--mute);letter-spacing:.04em}.suggest-list{display:flex;flex-direction:column;gap:0;background:var(--rule);border:1px solid var(--rule)}.suggest{background:var(--paper);padding:18px 22px;display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center}.suggest:hover{background:#fff}.suggest .priority{font-family:Cormorant Garamond,serif;font-style:italic;font-size:32px;color:var(--accent);font-weight:500;letter-spacing:-.02em;width:60px;text-align:center}.suggest-info .what{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;line-height:1.15;margin-bottom:4px}.suggest-info .how{font-size:13px;color:#444;line-height:1.55}.suggest .pts{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;background:#1a1a1a;color:#facc15;padding:4px 9px;font-weight:700;text-align:center}.leaders-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.leader{background:var(--paper);padding:20px 22px;display:flex;flex-direction:column;gap:6px}.leader:hover{background:#fff}.leader .rank{font-family:Cormorant Garamond,serif;font-style:italic;font-size:42px;color:var(--accent);font-weight:500;line-height:1}.leader .name{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;letter-spacing:-.005em;line-height:1.2}.leader .meta{font-family:Space Mono,monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em}.leader .pubs{font-family:Space Mono,monospace;font-size:10px;color:var(--accent);letter-spacing:.04em;line-height:1.5;margin-top:2px}.gap-table{font-family:Space Mono,monospace;font-size:13px;width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--rule);margin-top:16px}.gap-table td,.gap-table th{padding:12px 14px;border-bottom:1px solid var(--rule);text-align:left}.gap-table th{background:#fafaf6;font-size:10px;text-transform:uppercase;letter-spacing:.16em}.gap-table .you{color:var(--accent);font-weight:700}.gap-table .gap{color:#dc2626;font-weight:700}.empty{padding:32px;font-family:Cormorant Garamond,serif;font-style:italic;color:var(--mute);text-align:center}.price-form{background:var(--ink);color:var(--paper);padding:32px}.price-form h3{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;margin-bottom:8px;color:#facc15}.price-form p{color:#aaa;font-size:13px;max-width:60ch;margin-bottom:16px}.price-form form{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:8px;max-width:720px;align-items:end}.price-form label{display:block;font-family:Space Mono,monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:#666;margin-bottom:4px}.price-form select,.price-form input{padding:10px 12px;background:#fff;color:var(--ink);border:none;font-family:Space Mono,monospace;font-size:13px;font-weight:700;width:100%}.price-form button{background:#facc15;color:#000;border:none;padding:10px 20px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;cursor:pointer}@media(max-width:880px){.score-row{grid-template-columns:1fr;text-align:center}.price-form form{grid-template-columns:1fr}}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/biz/${safe(biz.slug)}">${safe(dba)}</a> <span style="color:var(--mute);font-style:italic">/ analysis</span></div>
+  <nav class="topnav">
+    <a href="/biz/${safe(biz.slug)}">← profile</a>
+    <a href="/biz/${safe(biz.slug)}/links">+ Links</a>
+    <a href="/best">★ The Best</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">SEO + Quality Score · ${safe(biz.neighborhood || biz.city || 'LA')}${biz.category ? ' · ' + safe(biz.category) : ''}</div>
+  <h1>${safe(dba)}<br><em style="font-size:.6em">at a glance.</em></h1>
+  <div class="score-row">
+    <div class="score-circle"><b>${totalScore}</b><span>${scoreLabel}</span></div>
+    <div class="score-meta">
+      <strong>${breakdown.filter(b => b.passed).length} of ${breakdown.length} signals · ${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}</strong>
+      <p>Heuristic score across 14 weighted signals — what's known about your shop. Higher = better discoverability + trust + press surface area. Below: leaders we benchmarked you against, then prioritized actions.</p>
+    </div>
+  </div>
+</section>
+${suggestions.length ? `
+<section class="section">
+  <h2>${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'} · ranked by impact</h2>
+  <div class="lede">Top of the list = biggest score gain</div>
+  <div class="suggest-list">
+    ${suggestions.map((s, i) => `
+      <div class="suggest">
+        <div class="priority">${i + 1}.</div>
+        <div class="suggest-info">
+          <div class="what">${safe(s.label)}</div>
+          <div class="how">${safe(s.tip)}</div>
+        </div>
+        <div class="pts">+${s.weight}</div>
+      </div>
+    `).join('')}
+  </div>
+</section>
+` : '<section class="section"><h2>★ Excellent — every signal hit</h2></section>'}
+<section class="section">
+  <h2>Score breakdown · 14 signals</h2>
+  <div class="lede">Pass/fail per signal · sum = ${totalScore}/100</div>
+  <div class="score-grid">
+    ${breakdown.map(b => `
+      <div class="signal ${b.passed ? 'pass' : 'fail'}">
+        <div>
+          <div class="signal-name">${safe(b.label)}</div>
+          <div class="signal-weight">${b.weight} pt${b.weight === 1 ? '' : 's'}</div>
+        </div>
+        <span class="signal-state">${b.passed ? '✓ HIT' : '— miss'}</span>
+      </div>
+    `).join('')}
+  </div>
+</section>
+<section class="section">
+  <h2>Top leaders in ${safe(biz.neighborhood || 'your hood')}</h2>
+  <div class="lede">Same neighborhood${biz.category ? ' · same category (' + safe(biz.category) + ')' : ''} · ranked by press coverage</div>
+  ${hoodLeaders.length ? `
+  <div class="leaders-grid">
+    ${hoodLeaders.map((l, i) => `
+      <a class="leader" href="/biz/${safe(l.slug)}">
+        <div class="rank">№ ${i + 1}</div>
+        <div class="name">${safe(l.name)}</div>
+        <div class="meta">${safe(l.category || 'business')}</div>
+        <div class="meta" style="color:var(--accent)">${l.article_count} article${l.article_count === 1 ? '' : 's'} · ${l.social_count} social${l.social_count === 1 ? '' : 's'}</div>
+        ${l.publishers && l.publishers.length ? `<div class="pubs">${l.publishers.slice(0, 4).map(p => safe(p)).join(' · ')}${l.publishers.length > 4 ? ' +' + (l.publishers.length - 4) : ''}</div>` : ''}
+      </a>
+    `).join('')}
+  </div>
+  <table class="gap-table">
+    <thead><tr><th>Metric</th><th>You</th><th>Hood leader avg</th><th>Gap</th></tr></thead>
+    <tbody>
+      <tr><td>Press articles</td><td class="you">${ctx.articleCount}</td><td>${leaderArticleAvg}</td><td class="gap">${leaderArticleAvg - ctx.articleCount > 0 ? '−' + (leaderArticleAvg - ctx.articleCount) : '+' + (ctx.articleCount - leaderArticleAvg)}</td></tr>
+      <tr><td>Social platforms</td><td class="you">${socials.length}</td><td>${leaderSocialAvg}</td><td class="gap">${leaderSocialAvg - socials.length > 0 ? '−' + (leaderSocialAvg - socials.length) : '+' + (socials.length - leaderSocialAvg)}</td></tr>
+      <tr><td>Has website</td><td class="you">${biz.website ? '✓' : '—'}</td><td>—</td><td>—</td></tr>
+    </tbody>
+  </table>
+  ` : '<div class="empty">No leaders identified in this neighborhood yet.</div>'}
+</section>
+<section class="section">
+  <h2>Pricing · ${pricing.length ? pricing.length + ' service' + (pricing.length === 1 ? '' : 's') + ' on file' : 'no entries yet'}</h2>
+  <div class="lede">Owner-submitted prices · used to compute hood/category medians${pricing.length ? '' : ' · be the first to enter one'}</div>
+  ${pricing.length ? `
+  <table class="gap-table">
+    <thead><tr><th>Service</th><th>Your price</th><th>Hood median</th><th>Sample size</th></tr></thead>
+    <tbody>
+      ${pricing.map(p => {
+        const median = hoodMedians.find(m => m.service_kind === p.service_kind);
+        const yourPrice = p.price_high ? `${fmtPrice(p.price_low)}–${fmtPrice(p.price_high)}` : fmtPrice(p.price_low);
+        return `<tr>
+          <td>${safe(SERVICE_LABEL[p.service_kind] || p.service_kind)}</td>
+          <td class="you">${yourPrice}</td>
+          <td>${median ? fmtPrice(median.median_low) : '—'}</td>
+          <td>${median ? median.sample_size : '0'}</td>
+        </tr>`;
+      }).join('')}
+    </tbody>
+  </table>
+  ` : ''}
+</section>
+<section class="price-form">
+  <h3>Add or update a price</h3>
+  <p>Submit one representative service price. Used to compute neighborhood medians and feed the leader-comparison table above. Free, public, owner-curated. Keeps the directory honest about price.</p>
+  <form id="price-form">
+    <div>
+      <label>Service kind</label>
+      <select name="service_kind" required>
+        <option value="">—</option>
+        ${Object.entries(SERVICE_LABEL).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}
+      </select>
+    </div>
+    <div>
+      <label>Low ($)</label>
+      <input name="price_low" type="number" min="0" step="1" placeholder="35" required>
+    </div>
+    <div>
+      <label>High ($, optional)</label>
+      <input name="price_high" type="number" min="0" step="1" placeholder="55">
+    </div>
+    <button type="submit">Save</button>
+  </form>
+  <div id="price-out" style="font-family:Space Mono,monospace;font-size:13px;color:#facc15;margin-top:14px;min-height:18px"></div>
+</section>
+<footer class="footer">
+  <a href="/biz/${safe(biz.slug)}" style="color:var(--accent)">← back to profile</a> · <a href="/best" style="color:var(--accent)">★ /best</a>
+</footer>
+<script>
+document.getElementById('price-form').addEventListener('submit', async (e) => {
+  e.preventDefault();
+  const fd = new FormData(e.target);
+  const body = Object.fromEntries(fd.entries());
+  body.slug = ${JSON.stringify(biz.slug)};
+  const out = document.getElementById('price-out');
+  out.textContent = 'submitting…';
+  try {
+    const r = await fetch('/api/biz/price', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
+    const j = await r.json();
+    if (j.ok) { out.textContent = '✓ saved · refreshing'; setTimeout(() => location.reload(), 800); }
+    else { out.textContent = '✗ ' + (j.error || 'error'); out.style.color = '#dc2626'; }
+  } catch (err) { out.textContent = '✗ network error'; out.style.color = '#dc2626'; }
+});
+</script>
+</body></html>`);
+});
+
+// POST /api/biz/price — owner-submits a price; upserts into business_pricing.
+const ALLOWED_SERVICE_KINDS = new Set(['mens_haircut','womens_haircut','color','balayage','manicure','pedicure','facial','wax','brow','lash','other']);
+app.post('/api/biz/price', async (req, res) => {
+  try {
+    const { slug, service_kind, price_low, price_high, notes, submitted_by } = req.body || {};
+    if (!slug || !service_kind) return res.status(400).json({ error: 'slug + service_kind required' });
+    const kind = String(service_kind).toLowerCase();
+    if (!ALLOWED_SERVICE_KINDS.has(kind)) return res.status(400).json({ error: 'unknown service_kind' });
+    const lowDollars = Number(price_low);
+    const highDollars = price_high ? Number(price_high) : null;
+    if (!Number.isFinite(lowDollars) || lowDollars <= 0 || lowDollars > 5000) return res.status(400).json({ error: 'price_low out of range ($1-$5000)' });
+    if (highDollars != null && (!Number.isFinite(highDollars) || highDollars < lowDollars || highDollars > 10000)) return res.status(400).json({ error: 'price_high out of range or below low' });
+    const biz = await loadBySlug(slug);
+    if (!biz) return res.status(404).json({ error: 'unknown business' });
+    const lowCents = Math.round(lowDollars * 100);
+    const highCents = highDollars != null ? Math.round(highDollars * 100) : null;
+    await query(
+      `INSERT INTO business_pricing (business_id, service_kind, price_low, price_high, notes, submitted_by)
+       VALUES ($1, $2, $3, $4, $5, $6)
+       ON CONFLICT (business_id, service_kind) DO UPDATE SET
+         price_low    = EXCLUDED.price_low,
+         price_high   = EXCLUDED.price_high,
+         notes        = COALESCE(EXCLUDED.notes, business_pricing.notes),
+         submitted_by = COALESCE(EXCLUDED.submitted_by, business_pricing.submitted_by),
+         updated_at   = now()`,
+      [biz.id, kind, lowCents, highCents, notes ? String(notes).slice(0, 240) : null, submitted_by ? String(submitted_by).slice(0, 120) : null]
+    );
+    res.json({ ok: true });
+  } catch (e) {
+    fail(res, 'biz/price', e);
+  }
+});
+
+// /addresses — phone-book index of every physical address in the catalog.
+// Group by neighborhood, sort within group by street name + house number.
+// Each row deep-links to Apple Maps + Google Maps + DCA verify (if licensed).
+//   /addresses                  — all neighborhoods
+//   /addresses?hood=Silver+Lake — single neighborhood
+//   /addresses?city=Beverly+Hills — single city
+app.get('/addresses', async (req, res) => {
+  const hood = req.query.hood ? String(req.query.hood).slice(0, 80) : null;
+  const cityFilter = req.query.city ? String(req.query.city).slice(0, 80) : null;
+  const params = [];
+  let where = `address IS NOT NULL AND address <> ''`;
+  if (hood) { params.push(hood); where += ` AND neighborhood = $${params.length}`; }
+  if (cityFilter) { params.push(cityFilter); where += ` AND LOWER(city) = LOWER($${params.length})`; }
+  const rows = await many(
+    `SELECT slug,
+            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
+            category, address, city, zip, neighborhood,
+            latitude, longitude,
+            (source_data_json->>'source' = 'dca_bbcb') AS licensed,
+            source_data_json->>'license_number' AS license_number,
+            COALESCE(NULLIF(REGEXP_REPLACE(SPLIT_PART(address, ' ', 1), '\\D', '', 'g'), '')::bigint, 0) AS house_num,
+            REGEXP_REPLACE(address, '^\\d+\\s*', '') AS street_part
+       FROM businesses
+      WHERE ${where}
+      ORDER BY neighborhood NULLS LAST, street_part, house_num, name
+      LIMIT 30000`,
+    params
+  );
+  const byHood = new Map();
+  for (const r of rows) {
+    const k = r.neighborhood || r.city || 'Other';
+    if (!byHood.has(k)) byHood.set(k, []);
+    byHood.get(k).push(r);
+  }
+  const ordered = [...byHood.entries()].sort((a, b) => b[1].length - a[1].length);
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+  const slugify = (s) => String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
+  const totals = await one(
+    `SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE latitude IS NOT NULL)::int AS geo
+       FROM businesses WHERE address IS NOT NULL AND address <> ''`
+  );
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Every Address · LA Salon Directory${hood ? ' · ' + safe(hood) : ''}${cityFilter ? ' · ' + safe(cityFilter) : ''}</title>
+<meta name="description" content="${rows.length.toLocaleString()} physical addresses${hood ? ' in ' + safe(hood) : ''} — every salon, barber, and shop with a known street address. Map deep-links per row.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased;line-height:1.4}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.topnav a:hover{color:var(--ink)}.hero{padding:64px 32px 32px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:16px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(40px,6vw,72px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-stats{display:flex;gap:32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap;margin-top:24px}.hero-stats b{display:block;font-family:Cormorant Garamond,serif;font-style:italic;font-size:28px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}.hood-toc{padding:32px 32px 8px;max-width:1240px;margin:0 auto;display:flex;flex-wrap:wrap;gap:6px}.hood-toc a{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;padding:5px 10px;border:1px solid var(--rule);transition:all 150ms ease}.hood-toc a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.hood-toc a b{color:var(--ink);font-family:Cormorant Garamond,serif;font-style:italic;font-size:14px;margin-right:6px}.hood-section{padding:48px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule);scroll-margin-top:80px}.hood-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:10px;border-bottom:1px solid var(--rule)}.hood-head h2{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(28px,4vw,42px);letter-spacing:-.015em;line-height:1}.hood-head .meta{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.addr-list{column-count:2;column-gap:36px;column-rule:1px solid var(--rule)}@media(max-width:880px){.addr-list{column-count:1}}.addr{break-inside:avoid;display:grid;grid-template-columns:1fr auto;gap:8px;align-items:baseline;padding:8px 0;border-bottom:1px dotted var(--rule);font-size:13px}.addr:hover{background:#fff;padding-left:6px}.addr-info{min-width:0}.addr-info .name{font-family:Cormorant Garamond,serif;font-size:17px;font-weight:500;line-height:1.15;letter-spacing:-.005em;display:inline}.addr-info .street{font-family:Space Mono,monospace;font-size:11px;color:#3a3a3a;display:block;margin-top:2px;letter-spacing:.02em}.addr-info .meta{font-family:Space Mono,monospace;font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin-top:1px}.addr-actions{display:flex;flex-direction:column;gap:2px;align-items:flex-end;font-family:Space Mono,monospace;font-size:9px}.addr-actions a{padding:1px 6px;border:1px solid var(--rule);background:#fff;color:var(--ink);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap;font-weight:600}.addr-actions a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}.addr-actions a.lic{background:var(--gold);color:var(--ink);border-color:var(--gold)}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center;border-top:1px solid var(--rule)}</style></head>
+<body>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ all addresses${hood ? ' / ' + safe(hood) : ''}${cityFilter ? ' / ' + safe(cityFilter) : ''}</span></div>
+  <nav class="topnav">
+    <a href="/best">★ Best</a>
+    <a href="/timeline">Live Feed</a>
+    <a href="/near">Near Me</a>
+    <a href="/neighborhoods">Hoods</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Phone-book index · ${rows.length.toLocaleString()} addresses${hood ? ' · ' + safe(hood) : ''}</div>
+  <h1>Every <em>physical</em> address.</h1>
+  <p style="font-size:16px;color:#3a3a3a;max-width:60ch;margin-bottom:8px">Every salon, barber, and shop with a known street address — sorted by neighborhood, then by street, then house number. Each row deep-links to Apple Maps and Google Maps for directions.</p>
+  <div class="hero-stats">
+    <div>${hood || cityFilter ? 'Filtered' : 'Total'} addresses<b>${rows.length.toLocaleString()}</b></div>
+    <div>Of catalog<b>${totals.total.toLocaleString()}</b></div>
+    <div>Geocoded<b>${totals.geo.toLocaleString()}</b></div>
+    <div>Neighborhoods<b>${ordered.length}</b></div>
+  </div>
+</section>
+${ordered.length === 0 ? `
+<div class="empty">No addresses match this filter.</div>
+` : `
+<nav class="hood-toc">
+  ${ordered.slice(0, 80).map(([h, list]) => `<a href="#${safe(slugify(h))}"><b>${list.length}</b>${safe(h)}</a>`).join('')}
+  ${ordered.length > 80 ? `<span style="font-family:Space Mono,monospace;font-size:11px;color:var(--mute);align-self:center;padding:5px 10px">+${ordered.length - 80} more below</span>` : ''}
+</nav>
+${ordered.map(([h, list]) => `
+  <section class="hood-section" id="${safe(slugify(h))}">
+    <div class="hood-head">
+      <h2>${safe(h)}</h2>
+      <span class="meta">${list.length} address${list.length === 1 ? '' : 'es'}${hood !== h ? ' · <a href="/addresses?hood=' + encodeURIComponent(h) + '" style="color:var(--accent);text-decoration:underline">just this hood →</a>' : ''}</span>
+    </div>
+    <div class="addr-list">
+      ${list.map(r => {
+        const apple  = r.latitude ? `https://maps.apple.com/?daddr=${r.latitude},${r.longitude}` : `https://maps.apple.com/?q=${encodeURIComponent(r.address + ', ' + (r.city || 'Los Angeles') + ', CA')}`;
+        const google = r.latitude ? `https://www.google.com/maps/dir/?api=1&destination=${r.latitude},${r.longitude}` : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.address + ', ' + (r.city || 'Los Angeles') + ', CA')}`;
+        const lic    = r.licensed && r.license_number ? `https://search.dca.ca.gov/results?lic=${encodeURIComponent(r.license_number)}` : '';
+        return `<div class="addr">
+          <div class="addr-info">
+            <a class="name" href="/biz/${safe(r.slug)}">${safe(r.name)}</a>
+            <span class="street">${safe(r.address)}${r.city ? ', ' + safe(r.city) : ''}${r.zip ? ' ' + safe((r.zip || '').slice(0,5)) : ''}</span>
+            <span class="meta">${safe(r.category || 'business')}${r.licensed ? ' · ◆ licensed' : ''}${r.latitude ? ' · 📍' : ''}</span>
+          </div>
+          <div class="addr-actions">
+            <a href="${safe(apple)}" target="_blank" rel="noopener" title="Apple Maps">A↗</a>
+            <a href="${safe(google)}" target="_blank" rel="noopener" title="Google Maps">G↗</a>
+            ${lic ? `<a class="lic" href="${safe(lic)}" target="_blank" rel="noopener" title="Verify license">◆</a>` : ''}
+          </div>
+        </div>`;
+      }).join('')}
+    </div>
+  </section>
+`).join('')}
+`}
+<footer class="footer">
+  <a href="/" style="color:var(--accent)">← Home</a>
+  · ${rows.length.toLocaleString()} addresses · ${ordered.length} neighborhoods · live data
+</footer>
+</body></html>`);
+});
+
+// /random — surface a random shop from the catalog. 302-redirects to the
+// profile, so deep-link sharing makes sense. Uses TABLESAMPLE for cheap
+// O(1) random picks at scale (vs ORDER BY random() which is O(N)).
+//
+// /random         — any shop with a name (the firehose)
+// /random/best    — only fully-enriched shops (data-complete cohort)
+// /random/licensed — only state-licensed BBCB shops
+// /random/<hood>  — random shop in that neighborhood
+app.get('/random', async (req, res) => {
+  try {
+    const r = await one(
+      `SELECT slug FROM businesses TABLESAMPLE SYSTEM(1) WHERE name IS NOT NULL LIMIT 1`
+    );
+    if (!r?.slug) {
+      // TABLESAMPLE may return nothing on small samples; fall back to ordered.
+      const fb = await one(`SELECT slug FROM businesses ORDER BY random() LIMIT 1`);
+      // Reviewer #7: don't redirect to /biz/ with empty slug when DB empty.
+      if (!fb?.slug) return res.status(503).json({ error: 'no businesses available' });
+      return res.redirect(302, `/biz/${encodeURIComponent(fb.slug)}`);
+    }
+    res.redirect(302, `/biz/${encodeURIComponent(r.slug)}`);
+  } catch (e) {
+    fail(res, '/random', e);
+  }
+});
+
+app.get('/random/best', async (_req, res) => {
+  // Pick from the data-complete cohort (website + ≥1 social + ≥1 article).
+  const r = await one(
+    `SELECT b.slug FROM businesses b
+       JOIN business_socials s ON s.business_id = b.id
+       JOIN business_articles a ON a.business_id = b.id
+      WHERE b.website IS NOT NULL AND b.website <> ''
+      GROUP BY b.id, b.slug
+     HAVING COUNT(DISTINCT s.id) >= 1 AND COUNT(DISTINCT a.id) >= 1
+      ORDER BY random() LIMIT 1`
+  );
+  if (!r?.slug) return res.redirect(302, '/best');
+  res.redirect(302, `/biz/${r.slug}`);
+});
+
+app.get('/random/licensed', async (_req, res) => {
+  const r = await one(
+    `SELECT slug FROM businesses
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+      ORDER BY random() LIMIT 1`
+  );
+  res.redirect(302, `/biz/${r?.slug || ''}`);
+});
+
+app.get('/random/:hood', async (req, res) => {
+  const hood = String(req.params.hood || '').replace(/-/g, ' ');
+  const r = await one(
+    `SELECT slug FROM businesses
+      WHERE LOWER(neighborhood) = LOWER($1)
+      ORDER BY random() LIMIT 1`,
+    [hood]
+  );
+  if (!r?.slug) return res.redirect(302, '/');
+  res.redirect(302, `/biz/${r.slug}`);
+});
+
+// /stats — public growth dashboard. Hourly histograms of enrichment finds +
+// geocode hits over the last 24h, plus aggregate totals. Auto-refresh 60s.
+app.get('/stats', async (_req, res) => {
+  const totals = await one(
+    `SELECT
+        (SELECT COUNT(*) FROM businesses)::int AS biz_total,
+        (SELECT COUNT(*) FROM businesses WHERE source_data_json->>'source'='dca_bbcb')::int AS biz_licensed,
+        (SELECT COUNT(*) FROM businesses WHERE latitude IS NOT NULL)::int AS biz_geo,
+        (SELECT COUNT(*) FROM businesses WHERE website IS NOT NULL AND website <> '')::int AS biz_websites,
+        (SELECT COUNT(*) FROM businesses WHERE source_data_json ? 'dba_name')::int AS biz_dba,
+        (SELECT COUNT(DISTINCT neighborhood) FROM businesses WHERE neighborhood IS NOT NULL)::int AS hood_count,
+        (SELECT COUNT(*) FROM enrichment_jobs)::int AS jobs_total,
+        (SELECT COUNT(*) FROM enrichment_jobs WHERE status='found')::int AS jobs_found,
+        (SELECT COUNT(*) FROM business_articles)::int AS articles_total,
+        (SELECT COUNT(*) FROM business_socials WHERE url IS NOT NULL)::int AS socials_total,
+        (SELECT COUNT(*) FROM business_review_sources WHERE verified=true)::int AS reviews_total`
+  );
+  // Hourly histogram for the last 24h.
+  const histogram = await many(
+    `WITH hours AS (
+       SELECT generate_series(
+         date_trunc('hour', now() - interval '24 hours'),
+         date_trunc('hour', now()),
+         '1 hour'::interval
+       ) AS h
+     )
+     SELECT to_char(h, 'HH24:MI') AS bucket,
+            EXTRACT(epoch FROM h)::int AS ts,
+            (SELECT COUNT(*) FROM enrichment_jobs WHERE attempted_at >= h AND attempted_at < h + interval '1 hour' AND status='found')::int AS jobs_found,
+            (SELECT COUNT(*) FROM business_articles WHERE created_at >= h AND created_at < h + interval '1 hour')::int AS articles,
+            (SELECT COUNT(*) FROM business_socials WHERE scraped_at >= h AND scraped_at < h + interval '1 hour')::int AS socials
+       FROM hours
+      ORDER BY h`
+  );
+  const platform = await many(
+    `SELECT platform, COUNT(*)::int AS n
+       FROM business_socials WHERE url IS NOT NULL
+      GROUP BY platform ORDER BY n DESC LIMIT 10`
+  );
+  const publishers = await many(
+    `SELECT publisher, COUNT(*)::int AS n
+       FROM business_articles WHERE publisher IS NOT NULL
+      GROUP BY publisher ORDER BY n DESC LIMIT 10`
+  );
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+  // Find the max value across all hourly metrics for chart scaling.
+  const maxBar = Math.max(1, ...histogram.flatMap(h => [h.jobs_found, h.articles, h.socials]));
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta http-equiv="refresh" content="60">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Stats · LA Salon Directory</title>
+<meta name="description" content="Live growth dashboard: 27,740 LA salons indexed, ${totals.biz_geo.toLocaleString()} geocoded, ${totals.articles_total} press articles surfaced, ${totals.socials_total} socials linked. Updates every 60s.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.refresh{position:fixed;top:14px;right:18px;font-family:Space Mono,monospace;font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;background:rgba(255,255,255,.85);padding:4px 8px;border:1px solid var(--rule);z-index:100}.hero{padding:80px 32px 40px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}.hero-eyebrow{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.section{max-width:1240px;margin:0 auto;padding:48px 32px;border-bottom:1px solid var(--rule)}.section h2{font-family:Cormorant Garamond,serif;font-size:32px;font-weight:500;margin-bottom:18px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}.card{background:var(--paper);padding:20px 22px;display:flex;flex-direction:column;gap:6px;transition:background 200ms ease}.card:hover{background:#fff}.card-label{font-family:Space Mono,monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}.card-val{font-family:Cormorant Garamond,serif;font-style:italic;font-size:42px;line-height:1;letter-spacing:-.02em}.card-val em{color:var(--accent)}.chart{margin-top:24px}.chart-row{display:flex;align-items:flex-end;gap:2px;height:120px;font-family:Space Mono,monospace;border-bottom:1px solid var(--rule);padding-bottom:4px}.bar{flex:1;background:var(--accent);min-height:2px;display:flex;align-items:flex-end;justify-content:center;color:#fff;font-size:9px;padding:2px;border-top:2px solid var(--accent)}.bar.articles{background:var(--gold);border-top-color:var(--gold);color:#1a1a1a}.bar.socials{background:#1a1a1a;border-top-color:#1a1a1a}.legend{display:flex;gap:18px;font-family:Space Mono,monospace;font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin-top:14px}.legend span{display:inline-flex;align-items:center;gap:6px}.legend i{width:14px;height:14px;display:inline-block;border:1px solid #1a1a1a}.legend i.found{background:var(--accent)}.legend i.articles{background:var(--gold)}.legend i.socials{background:#1a1a1a}.x-axis{display:flex;justify-content:space-between;font-family:Space Mono,monospace;font-size:9px;color:var(--mute);margin-top:6px;letter-spacing:.04em}.list-grid{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:18px}@media (max-width:880px){.list-grid{grid-template-columns:1fr}}.list h3{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:10px}.list-row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1px dotted var(--rule);font-size:13px}.list-row:last-child{border-bottom:none}.list-row .name{font-family:Cormorant Garamond,serif;font-size:16px}.list-row .n{font-family:Space Mono,monospace;font-weight:700;color:var(--accent);font-size:12px}.footer{padding:48px 32px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}</style></head>
+<body>
+<div class="refresh">↻ refresh 60s</div>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ stats</span></div>
+  <nav class="topnav">
+    <a href="/best">★ Best</a>
+    <a href="/timeline">Live Feed</a>
+    <a href="/press">Press</a>
+    <a href="/feed.xml">RSS</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Live Growth Dashboard · Auto-refresh 60s</div>
+  <h1>Where the <em>directory</em> is.</h1>
+</section>
+<section class="section">
+  <h2>The Catalog</h2>
+  <div class="cards">
+    <div class="card"><span class="card-label">Total shops</span><span class="card-val">${totals.biz_total.toLocaleString()}</span></div>
+    <div class="card"><span class="card-label">State-licensed</span><span class="card-val"><em>${totals.biz_licensed.toLocaleString()}</em></span></div>
+    <div class="card"><span class="card-label">Geocoded</span><span class="card-val">${totals.biz_geo.toLocaleString()}</span></div>
+    <div class="card"><span class="card-label">w/ Website</span><span class="card-val">${totals.biz_websites.toLocaleString()}</span></div>
+    <div class="card"><span class="card-label">DBA recorded</span><span class="card-val"><em>${totals.biz_dba.toLocaleString()}</em></span></div>
+    <div class="card"><span class="card-label">Neighborhoods</span><span class="card-val">${totals.hood_count.toLocaleString()}</span></div>
+  </div>
+</section>
+<section class="section">
+  <h2>The Enrichment Fleet</h2>
+  <div class="cards">
+    <div class="card"><span class="card-label">Jobs total</span><span class="card-val">${totals.jobs_total.toLocaleString()}</span></div>
+    <div class="card"><span class="card-label">Found</span><span class="card-val"><em>${totals.jobs_found.toLocaleString()}</em></span></div>
+    <div class="card"><span class="card-label">Hit rate</span><span class="card-val">${totals.jobs_total > 0 ? Math.round(totals.jobs_found / totals.jobs_total * 100) : 0}%</span></div>
+    <div class="card"><span class="card-label">Articles</span><span class="card-val"><em>${totals.articles_total}</em></span></div>
+    <div class="card"><span class="card-label">Socials</span><span class="card-val">${totals.socials_total}</span></div>
+    <div class="card"><span class="card-label">Reviews opt-in</span><span class="card-val">${totals.reviews_total}</span></div>
+  </div>
+</section>
+<section class="section">
+  <h2>Last 24 hours</h2>
+  <div class="legend">
+    <span><i class="found"></i> jobs found</span>
+    <span><i class="articles"></i> articles</span>
+    <span><i class="socials"></i> socials</span>
+  </div>
+  <div class="chart">
+    <div class="chart-row" title="hourly: jobs found / articles / socials">
+      ${histogram.map(h => `
+        <div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;gap:1px;height:120px">
+          <div class="bar socials" style="height:${(h.socials / maxBar * 100).toFixed(1)}%" title="${safe(h.bucket)} · ${h.socials} socials">${h.socials > 0 ? h.socials : ''}</div>
+          <div class="bar articles" style="height:${(h.articles / maxBar * 100).toFixed(1)}%" title="${safe(h.bucket)} · ${h.articles} articles">${h.articles > 0 ? h.articles : ''}</div>
+          <div class="bar" style="height:${(h.jobs_found / maxBar * 100).toFixed(1)}%" title="${safe(h.bucket)} · ${h.jobs_found} jobs found">${h.jobs_found > 0 ? h.jobs_found : ''}</div>
+        </div>
+      `).join('')}
+    </div>
+    <div class="x-axis">
+      ${histogram.filter((_, i) => i % 3 === 0).map(h => `<span>${safe(h.bucket)}</span>`).join('')}
+    </div>
+  </div>
+</section>
+<section class="section">
+  <div class="list-grid">
+    <div class="list">
+      <h3>★ Top Platforms</h3>
+      ${platform.map(p => `<div class="list-row"><span class="name">${safe(p.platform)}</span><span class="n">${p.n}</span></div>`).join('')}
+    </div>
+    <div class="list">
+      <h3>★ Top Publishers</h3>
+      ${publishers.map(p => `<div class="list-row"><span class="name">${safe(p.publisher)}</span><span class="n">${p.n}</span></div>`).join('')}
+    </div>
+  </div>
+</section>
+<footer class="footer">
+  <a href="/" style="color:var(--accent)">← Home</a> · live data · refresh 60s
+</footer>
+</body></html>`);
+});
+
+// /timeline — public activity stream of the most-recent enrichments. Reads
+// from enrichment_jobs + business_articles + business_socials, merges chronologically.
+// Reverse-cron feed — newest first. Auto-refresh 30s so the stream feels alive.
+app.get('/timeline', async (_req, res) => {
+  const limit = 100;
+  // Pull from each source, merge by timestamp.
+  const [jobs, articles, socials] = await Promise.all([
+    many(
+      `SELECT j.kind, j.status, j.found_url, j.found_handle, j.attempted_at AS ts,
+              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.neighborhood, b.category
+         FROM enrichment_jobs j
+         JOIN businesses b ON b.id = j.business_id
+        WHERE j.status = 'found' AND j.attempted_at IS NOT NULL
+        ORDER BY j.attempted_at DESC LIMIT $1`,
+      [limit]
+    ),
+    many(
+      `SELECT a.title, a.publisher, a.url, a.created_at AS ts,
+              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.neighborhood
+         FROM business_articles a
+         JOIN businesses b ON b.id = a.business_id
+        ORDER BY a.created_at DESC LIMIT $1`,
+      [limit]
+    ),
+    many(
+      `SELECT s.platform, s.url, s.handle, s.scraped_at AS ts,
+              b.slug, COALESCE(NULLIF(b.source_data_json->>'dba_name',''), b.name) AS name,
+              b.neighborhood
+         FROM business_socials s
+         JOIN businesses b ON b.id = s.business_id
+        WHERE s.url IS NOT NULL
+        ORDER BY s.scraped_at DESC LIMIT $1`,
+      [limit]
+    ),
+  ]);
+
+  // Tag each event with a type and merge.
+  const events = [
+    ...jobs.map(j => ({ ...j, _type: 'enrich', when: j.ts })),
+    ...articles.map(a => ({ ...a, _type: 'article', when: a.ts })),
+    ...socials.map(s => ({ ...s, _type: 'social', when: s.ts })),
+  ].filter(e => e.when).sort((a, b) => new Date(b.when) - new Date(a.when)).slice(0, 200);
+
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+  const fmtAgo = (d) => {
+    const ms = Date.now() - new Date(d).getTime();
+    if (ms < 60_000) return 'just now';
+    if (ms < 3600_000) return Math.floor(ms / 60_000) + 'm ago';
+    if (ms < 86400_000) return Math.floor(ms / 3600_000) + 'h ago';
+    return Math.floor(ms / 86400_000) + 'd ago';
+  };
+  const eventLine = (e) => {
+    if (e._type === 'article') {
+      return `<div class="ev ev-article">
+        <span class="ev-time">${fmtAgo(e.when)}</span>
+        <span class="ev-tag tag-article">📰 article</span>
+        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
+        <span class="ev-where">${safe(e.neighborhood || '')}</span>
+        <a class="ev-link" href="${safe(e.url)}" target="_blank" rel="noopener" title="${safe(e.title || '')}">${safe(e.publisher || 'press')} · ${safe((e.title || '').slice(0, 80))}↗</a>
+      </div>`;
+    }
+    if (e._type === 'social') {
+      return `<div class="ev ev-social">
+        <span class="ev-time">${fmtAgo(e.when)}</span>
+        <span class="ev-tag tag-social">📷 ${safe(e.platform)}</span>
+        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
+        <span class="ev-where">${safe(e.neighborhood || '')}</span>
+        <a class="ev-link" href="${safe(e.url)}" target="_blank" rel="noopener">${e.handle ? '@' + safe(e.handle) : safe(e.url.replace(/^https?:\/\//, '').slice(0, 40))}↗</a>
+      </div>`;
+    }
+    // enrich (catch-all from enrichment_jobs)
+    if (e.kind === 'website') {
+      return `<div class="ev ev-website">
+        <span class="ev-time">${fmtAgo(e.when)}</span>
+        <span class="ev-tag tag-website">🌐 website</span>
+        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
+        <span class="ev-where">${safe(e.neighborhood || '')}</span>
+        <a class="ev-link" href="${safe(e.found_url || '#')}" target="_blank" rel="noopener">${safe((e.found_url || '').replace(/^https?:\/\//, '').slice(0, 40))}↗</a>
+      </div>`;
+    }
+    if (e.kind === 'dba_name') {
+      return `<div class="ev ev-dba">
+        <span class="ev-time">${fmtAgo(e.when)}</span>
+        <span class="ev-tag tag-dba">★ DBA</span>
+        <a class="ev-shop" href="/biz/${safe(e.slug)}">${safe(e.name)}</a>
+        <span class="ev-where">${safe(e.neighborhood || '')}</span>
+        <span class="ev-link" style="color:#000">→ ${safe(e.found_handle || '?')}</span>
+      </div>`;
+    }
+    return ''; // skip enrich events for kinds covered by article/social
+  };
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta http-equiv="refresh" content="30">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Timeline · LA Salon Directory</title>
+<meta name="description" content="Live activity stream of every enrichment the directory finds — websites, Instagram handles, press articles, DBA names — newest first.">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.topbar{padding:18px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:60;flex-wrap:wrap;gap:12px}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.topnav{display:flex;gap:18px;font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}.hero{padding:80px 32px 32px;border-bottom:1px solid var(--rule);max-width:1080px;margin:0 auto}.hero-eyebrow{font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}h1{font-family:Cormorant Garamond,serif;font-weight:500;font-size:clamp(48px,7vw,84px);line-height:.96;letter-spacing:-.02em;margin-bottom:14px}h1 em{font-style:italic;color:var(--accent)}.hero-lede{font-size:16px;color:#3a3a3a;max-width:60ch}.refresh-tag{position:fixed;top:14px;right:18px;font-family:monospace;font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;background:rgba(255,255,255,.85);padding:4px 8px;border:1px solid var(--rule);z-index:100}.feed{max-width:1080px;margin:0 auto;padding:32px;display:flex;flex-direction:column;gap:0}.ev{display:grid;grid-template-columns:80px 110px minmax(180px,1fr) 140px minmax(140px,2fr);gap:14px;align-items:baseline;padding:10px 14px;border-bottom:1px dotted var(--rule);font-size:13px;line-height:1.4;transition:padding 150ms ease}.ev:hover{background:#fff;padding-left:20px}.ev-time{font-family:monospace;font-size:10px;color:var(--mute);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap}.ev-tag{font-family:monospace;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:2px 8px;text-align:center;white-space:nowrap}.tag-article{background:var(--gold);color:#000}.tag-social{background:var(--accent);color:#fff}.tag-website{background:#000;color:#facc15}.tag-dba{background:#000;color:#fff}.ev-shop{font-family:Cormorant Garamond,serif;font-size:18px;font-weight:500;color:var(--ink);letter-spacing:-.005em;line-height:1.2;text-decoration:none}.ev-shop:hover{color:var(--accent)}.ev-where{font-family:monospace;font-size:10px;color:var(--mute);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ev-link{font-family:monospace;font-size:11px;color:var(--accent);letter-spacing:.04em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ev-link:hover{color:var(--ink);text-decoration:underline}.empty{text-align:center;padding:96px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}.footer{padding:48px 32px;border-top:1px solid var(--rule);font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);text-align:center}@media (max-width:880px){.ev{grid-template-columns:1fr;gap:4px;padding:16px 14px}.ev-time,.ev-where{font-size:9px}.ev-tag{justify-self:start}}</style></head>
+<body>
+<div class="refresh-tag">↻ live · 30s</div>
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ timeline</span></div>
+  <nav class="topnav">
+    <a href="/best">★ Best</a>
+    <a href="/press">Press</a>
+    <a href="/proprietors">Proprietors</a>
+    <a href="/near">Near</a>
+    <a href="/">Home</a>
+  </nav>
+</header>
+<section class="hero">
+  <div class="hero-eyebrow">Live · Enrichment Activity Stream</div>
+  <h1>What the agents <em>just found.</em></h1>
+  <p class="hero-lede">Every enrichment the directory pulls in — a website discovered, an Instagram handle confirmed, a press article surfaced, a DBA brand uncovered — streams here newest first. The fleet runs continuously.</p>
+</section>
+<div class="feed">
+  ${events.length === 0 ? '<div class="empty">No activity yet. Check back in a moment.</div>' : events.map(eventLine).filter(Boolean).join('')}
+</div>
+<footer class="footer">
+  <span>${events.length} recent events · ${jobs.length + articles.length + socials.length} total stream sources · auto-refresh 30s</span>
+</footer>
+</body></html>`);
+});
+
+// /admin/data-status — tier × kind matrix showing enrichment coverage.
+// One row per tier (sole_prop, corridor, geocoded, licensed, press_eligible),
+// one column per kind (website, instagram, articles, yelp, dba_name, etc.).
+// Each cell shows X / Y (= attempted-with-find / attempted total).
+app.get('/admin/data-status', async (req, res) => {
+  if (!requireAdmin(req, res)) return;
+  const tiers = [
+    ['sole_prop',      `b.source_data_json->>'shop_personality' = 'sole_prop'`],
+    ['corridor',       `b.source_data_json->>'corridor' IS NOT NULL`],
+    ['geocoded',       `b.latitude IS NOT NULL`],
+    ['licensed',       `b.source_data_json->>'source' = 'dca_bbcb'`],
+    ['press_eligible', `b.neighborhood = ANY(ARRAY['Hollywood','East Hollywood','Hollywood Hills','Silver Lake','Echo Park','Highland Park','Koreatown','Mid-Wilshire','Downtown LA','Venice','Mar Vista','Beverly Hills','West Hollywood','Westwood','Brentwood','Pacific Palisades','Santa Monica','Culver City','Los Feliz','Atwater Village','Eagle Rock','Pasadena','South Pasadena','Bel-Air','Boyle Heights','Lincoln Heights','Studio City','Sherman Oaks','Beverly Grove'])`],
+  ];
+  const kinds = ['website', 'instagram', 'articles', 'yelp', 'facebook', 'tiktok', 'dba_name'];
+
+  // Build the matrix — one query per tier × kind cell. Quick on small sets.
+  const matrix = {};
+  const totals = {};
+  for (const [tierName, tierWhere] of tiers) {
+    matrix[tierName] = {};
+    const totalRow = await one(`SELECT COUNT(*)::int AS n FROM businesses b WHERE ${tierWhere}`);
+    totals[tierName] = totalRow?.n || 0;
+    for (const kind of kinds) {
+      const r = await one(
+        `SELECT
+            COUNT(*) FILTER (WHERE j.status='found')::int AS hit,
+            COUNT(*) FILTER (WHERE j.status='not_found')::int AS miss,
+            COUNT(*)::int AS tried
+           FROM businesses b LEFT JOIN enrichment_jobs j ON j.business_id = b.id AND j.kind = $1
+          WHERE ${tierWhere} AND j.id IS NOT NULL`,
+        [kind]
+      );
+      matrix[tierName][kind] = r || { hit: 0, miss: 0, tried: 0 };
+    }
+  }
+
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+  const cellColor = (hit, tried, total) => {
+    if (tried === 0) return '#fafaf6';
+    const triedPct = tried / total;
+    const hitPct = hit / tried;
+    if (triedPct < 0.05) return '#fef3c7';                    // barely touched
+    if (hitPct >= 0.5)   return '#22c55e';                    // strong yield
+    if (hitPct >= 0.2)   return '#84cc16';                    // moderate
+    if (hitPct > 0)      return '#facc15';                    // weak
+    return '#f3f4f6';                                          // tried but 0 hits
+  };
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html><head><meta charset="utf-8">
+<meta http-equiv="refresh" content="60">
+<title>Data Status · Admin · LA Salon Directory</title>
+<meta name="robots" content="noindex">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;padding:32px;line-height:1.4}h1{font-family:Cormorant Garamond,serif;font-size:48px;font-weight:500;margin-bottom:6px}h1 em{color:var(--accent);font-style:italic}.crumb{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}p{color:var(--mute);font-size:14px;margin-bottom:32px}table{width:100%;border-collapse:collapse;background:#fff;border:2px solid var(--ink);font-family:Space Mono,monospace}th,td{padding:12px;text-align:center;border:1px solid var(--rule);font-size:12px;vertical-align:middle}th{background:var(--ink);color:var(--paper);font-size:10px;letter-spacing:.18em;text-transform:uppercase;font-weight:700}tr:first-child th{background:var(--ink)}td.tier{background:#fafaf6;text-align:left;font-weight:700;text-transform:uppercase;letter-spacing:.06em;font-size:11px}td.tier b{display:block;font-family:Cormorant Garamond,serif;font-size:18px;font-weight:500;letter-spacing:0;text-transform:none;margin-top:2px}.cell{padding:10px;font-size:11px;font-weight:700;line-height:1.3}.cell .hit{font-family:Cormorant Garamond,serif;font-size:24px;font-style:italic;font-weight:500;letter-spacing:-.01em;display:block;line-height:1}.cell .frac{display:block;color:#222;font-size:10px;margin-top:2px}.cell .pct{display:block;font-size:9px;color:#444;margin-top:2px}a{color:var(--accent);text-decoration:underline}.legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px;font-family:Space Mono,monospace;font-size:11px}.legend span{padding:6px 10px;border:1px solid #000}</style></head>
+<body>
+<div class="crumb">★ Admin · Live Data Coverage Matrix · refresh 60s</div>
+<h1>Data <em>status.</em></h1>
+<p>Each cell = (found / attempted) for that (tier, kind). Greener = higher hit rate among shops we've tried. Yellow = under 5% attempted (most untouched). Recompute every refresh. <a href="/admin/enrichment">Live enrichment feed →</a></p>
+<table>
+<thead><tr><th style="text-align:left">Tier</th>${kinds.map(k => `<th>${safe(k)}</th>`).join('')}</tr></thead>
+<tbody>
+${tiers.map(([tier, _]) => `<tr>
+  <td class="tier">${safe(tier)}<b>${(totals[tier] || 0).toLocaleString()} shops</b></td>
+  ${kinds.map(k => {
+    const c = matrix[tier][k];
+    const color = cellColor(c.hit, c.tried, totals[tier] || 1);
+    const triedPct = totals[tier] ? (c.tried / totals[tier] * 100).toFixed(1) : '0';
+    const hitPct   = c.tried   ? (c.hit / c.tried * 100).toFixed(0) : '–';
+    return `<td class="cell" style="background:${color}">
+      <span class="hit">${c.hit}</span>
+      <span class="frac">/ ${c.tried} tried</span>
+      <span class="pct">${triedPct}% touched · ${hitPct}% yield</span>
+    </td>`;
+  }).join('')}
+</tr>`).join('')}
+</tbody>
+</table>
+<div class="legend">
+  <span style="background:#22c55e">≥50% yield (green)</span>
+  <span style="background:#84cc16">≥20% yield</span>
+  <span style="background:#facc15">>0% yield</span>
+  <span style="background:#f3f4f6">tried 0 found</span>
+  <span style="background:#fef3c7">< 5% touched</span>
+  <span style="background:#fafaf6">untouched</span>
+</div>
+</body></html>`);
+});
+
+// /admin/enrichment — live status of the Exa enrichment fleet. Shows
+// per-kind hit/miss/pending counts, the freshest 25 found websites, top
+// 12 article publishers, and the most recent 25 articles. Auto-refresh.
+app.get('/admin/enrichment', async (req, res) => {
+  if (!requireAdmin(req, res)) return;
+  const byKind = await many(
+    `SELECT kind,
+            COUNT(*) FILTER (WHERE status='found')      AS found,
+            COUNT(*) FILTER (WHERE status='not_found')  AS missed,
+            COUNT(*) FILTER (WHERE status='pending')    AS pending,
+            COUNT(*) FILTER (WHERE status='error')      AS errored,
+            COUNT(*)                                    AS total
+       FROM enrichment_jobs
+      GROUP BY kind
+      ORDER BY total DESC`
+  );
+  const recentWebsites = await many(
+    `SELECT b.slug, b.name, b.neighborhood, j.found_url, j.attempted_at
+       FROM enrichment_jobs j JOIN businesses b ON b.id = j.business_id
+      WHERE j.kind='website' AND j.status='found' AND j.found_url IS NOT NULL
+      ORDER BY j.attempted_at DESC LIMIT 25`
+  );
+  const topPublishers = await many(
+    `SELECT publisher, COUNT(*)::int AS count
+       FROM business_articles
+      WHERE publisher IS NOT NULL
+      GROUP BY publisher ORDER BY count DESC LIMIT 12`
+  );
+  const recentArticles = await many(
+    `SELECT b.slug, b.name, a.title, a.publisher, a.url, a.created_at
+       FROM business_articles a JOIN businesses b ON b.id = a.business_id
+      ORDER BY a.created_at DESC LIMIT 25`
+  );
+  const recentSocials = await many(
+    `SELECT b.slug, b.name, s.platform, s.url, s.handle
+       FROM business_socials s JOIN businesses b ON b.id = s.business_id
+      WHERE s.url IS NOT NULL
+      ORDER BY s.scraped_at DESC LIMIT 25`
+  );
+  const totals = await one(
+    `SELECT COUNT(*)::int AS total_biz,
+            COUNT(*) FILTER (WHERE website IS NOT NULL AND website <> '')::int AS with_website
+       FROM businesses`
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta http-equiv="refresh" content="30">
+<title>Enrichment · Admin · LA Salon Directory</title>
+<meta name="robots" content="noindex">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a;--err:#c2410c}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.45;-webkit-font-smoothing:antialiased;padding:32px}h1{font-family:Cormorant Garamond,serif;font-size:42px;font-weight:500;margin-bottom:6px}h1 em{color:var(--accent);font-style:italic}.crumb{font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule);margin-bottom:36px}.card{background:#fff;padding:18px 20px}.card h3{font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:6px}.card .v{font-family:Cormorant Garamond,serif;font-size:42px;line-height:1;font-weight:500;letter-spacing:-.02em}.card .v em{color:var(--accent);font-style:italic;font-size:24px;margin-left:6px}h2{font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;margin:32px 0 12px}table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--rule);font-size:13px}th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--rule);font-family:monospace}th{background:rgba(0,0,0,.03);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);font-weight:500}td.name{font-family:Cormorant Garamond,serif;font-size:16px}td a{color:var(--accent)}.tag{font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;background:rgba(0,0,0,.04);padding:2px 6px}.tag.found{background:var(--accent);color:#fff}.tag.miss{background:var(--err);color:#fff}.refresh{position:fixed;top:12px;right:14px;font-family:monospace;font-size:9px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase}</style></head>
+<body>
+<div class="refresh">Auto-refresh · 30s</div>
+<div class="crumb">Admin · Exa Enrichment Fleet</div>
+<h1>Enrichment <em>flow.</em></h1>
+<p style="color:var(--mute);margin-bottom:32px">${totals.total_biz.toLocaleString()} businesses total · ${totals.with_website.toLocaleString()} with website (${(totals.with_website/totals.total_biz*100).toFixed(1)}%)</p>
+
+<h2>Per-kind progress</h2>
+<table>
+  <thead><tr><th>Kind</th><th>Found</th><th>Not found</th><th>Pending</th><th>Errored</th><th>Total tried</th></tr></thead>
+  <tbody>
+  ${byKind.length === 0 ? '<tr><td colspan="6" style="text-align:center;padding:32px;color:var(--mute)">No jobs yet — agents starting up.</td></tr>' :
+    byKind.map(r => `<tr><td class="name">${esc(r.kind)}</td><td><span class="tag found">${r.found || 0}</span></td><td><span class="tag miss">${r.missed || 0}</span></td><td>${r.pending || 0}</td><td>${r.errored || 0}</td><td><b>${r.total}</b></td></tr>`).join('')
+  }
+  </tbody>
+</table>
+
+<h2>Recently-found websites · ${recentWebsites.length}</h2>
+<table>
+  <thead><tr><th>Shop</th><th>Neighborhood</th><th>Website</th><th>When</th></tr></thead>
+  <tbody>
+  ${recentWebsites.length === 0 ? '<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--mute)">no website hits yet</td></tr>' :
+    recentWebsites.map(r => `<tr><td class="name"><a href="/biz/${esc(r.slug)}">${esc(r.name)}</a></td><td>${esc(r.neighborhood || '')}</td><td><a href="${esc(r.found_url)}" target="_blank" rel="noopener">${esc(r.found_url)}</a></td><td>${new Date(r.attempted_at).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</td></tr>`).join('')
+  }
+  </tbody>
+</table>
+
+<h2>Top article publishers</h2>
+<div class="grid">
+  ${topPublishers.length === 0 ? '<div class="card" style="grid-column:1/-1;color:var(--mute)">no articles yet</div>' :
+    topPublishers.map(r => `<div class="card"><h3>${esc(r.publisher)}</h3><div class="v">${r.count}<em>articles</em></div></div>`).join('')
+  }
+</div>
+
+<h2>Recent articles · ${recentArticles.length}</h2>
+<table>
+  <thead><tr><th>Shop</th><th>Title</th><th>Publisher</th></tr></thead>
+  <tbody>
+  ${recentArticles.length === 0 ? '<tr><td colspan="3" style="text-align:center;padding:24px;color:var(--mute)">no articles yet</td></tr>' :
+    recentArticles.map(r => `<tr><td class="name"><a href="/biz/${esc(r.slug)}">${esc(r.name)}</a></td><td><a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.title || r.url)}</a></td><td>${esc(r.publisher || '?')}</td></tr>`).join('')
+  }
+  </tbody>
+</table>
+
+<h2>Recent socials · ${recentSocials.length}</h2>
+<table>
+  <thead><tr><th>Shop</th><th>Platform</th><th>Handle / URL</th></tr></thead>
+  <tbody>
+  ${recentSocials.length === 0 ? '<tr><td colspan="3" style="text-align:center;padding:24px;color:var(--mute)">no socials yet</td></tr>' :
+    recentSocials.map(r => `<tr><td class="name"><a href="/biz/${esc(r.slug)}">${esc(r.name)}</a></td><td><span class="tag">${esc(r.platform)}</span></td><td><a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.handle || r.url)}</a></td></tr>`).join('')
+  }
+  </tbody>
+</table>
+</body></html>`);
+});
+
+// POST /api/admin/enrich — bulk-persist Exa-agent findings. ADMIN-token
+// gated (otherwise an attacker could inject arbitrary URLs into
+// businesses.website + business_socials).
+//
+// Body shape (one batch from one exa-agent):
+//   {
+//     findings: [
+//       { slug, kind:'website',  url, source?:'exa', raw_json? }
+//       { slug, kind:'instagram', url, handle?, follower_count?, source?, raw_json? }
+//       { slug, kind:'articles', items:[{url,title,publisher?,published_at?,excerpt?}] }
+//       { slug, kind:'website',  status:'not_found' }    // negative result
+//     ]
+//   }
+const KIND_TO_PLATFORM = {
+  instagram:'instagram', tiktok:'tiktok', facebook:'facebook', x:'x', twitter:'x',
+  bluesky:'bluesky', pinterest:'pinterest', yelp:'yelp', threads:'threads', youtube:'youtube',
+};
+app.post('/api/admin/enrich', async (req, res) => {
+  if (!requireAdminJson(req, res)) return;
+  try {
+    const findings = Array.isArray(req.body?.findings) ? req.body.findings : [];
+    if (!findings.length) return res.status(400).json({ error: 'findings array required' });
+
+    let persisted = { website:0, social:0, article:0, job:0, skipped:0 };
+    for (const f of findings) {
+      const slug = String(f.slug || '').slice(0, 200);
+      const kind = String(f.kind || '').toLowerCase();
+      if (!slug || !kind) { persisted.skipped++; continue; }
+      const biz = await loadBySlug(slug);
+      if (!biz) { persisted.skipped++; continue; }
+
+      // Mark a job entry regardless of outcome (so we know we tried).
+      // Operator-precedence fix per reviewer: previous expression
+      // `f.status || (f.url || (f.items?.length) ? 'found' : 'not_found')`
+      // bound the `?:` to (f.url || items.length), incorrectly evaluating
+      // `'not_found'` for the articles-only case. Use explicit parens.
+      const hasFinding = !!(f.url || (f.items && f.items.length > 0) || f.handle);
+      const status = f.status || (hasFinding ? 'found' : 'not_found');
+      const url    = String(f.url || '').slice(0, 600) || null;
+      const handle = f.handle ? String(f.handle).slice(0, 120) : null;
+      await query(
+        `INSERT INTO enrichment_jobs (business_id, kind, status, found_url, found_handle, hit_count, raw_json, attempted_at, attempts, source)
+         VALUES ($1, $2, $3, $4, $5, $6, $7, now(), 1, $8)
+         ON CONFLICT (business_id, kind) DO UPDATE SET
+           status = EXCLUDED.status,
+           found_url = COALESCE(EXCLUDED.found_url, enrichment_jobs.found_url),
+           found_handle = COALESCE(EXCLUDED.found_handle, enrichment_jobs.found_handle),
+           hit_count = COALESCE(EXCLUDED.hit_count, enrichment_jobs.hit_count),
+           raw_json = COALESCE(EXCLUDED.raw_json, enrichment_jobs.raw_json),
+           attempted_at = now(),
+           attempts = enrichment_jobs.attempts + 1,
+           updated_at = now()`,
+        [biz.id, kind, status, url, handle, f.items ? f.items.length : null,
+         f.raw_json ? JSON.stringify(f.raw_json) : null, f.source || 'exa']
+      );
+      persisted.job++;
+
+      // Apply the actual data to the business / socials / articles tables.
+      if (kind === 'dba_name' && handle) {
+        // DBA = "doing business as". BBCB licensee names are often the
+        // owner's name ("WILKINS BERNIE") while the shop publicly trades as
+        // a different brand ("Midway Barbershop"). Persist into JSON so we
+        // never overwrite the canonical licensee name.
+        await query(
+          `UPDATE businesses
+              SET source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{dba_name}', to_jsonb($1::text)),
+                  updated_at = now()
+            WHERE id = $2`,
+          [handle, biz.id]
+        );
+      } else if (kind === 'website' && url && /^https?:\/\//i.test(url)) {
+        // Only fill if the field is currently empty — never overwrite a
+        // human-curated URL with an Exa guess.
+        await query(
+          `UPDATE businesses
+              SET website = COALESCE(NULLIF(website, ''), $1),
+                  source_data_json = jsonb_set(COALESCE(source_data_json, '{}'::jsonb), '{website_source}', '"exa"'),
+                  updated_at = now()
+            WHERE id = $2`,
+          [url, biz.id]
+        );
+        persisted.website++;
+      } else if (KIND_TO_PLATFORM[kind] && (url || handle)) {
+        const platform = KIND_TO_PLATFORM[kind];
+        await query(
+          `INSERT INTO business_socials (business_id, platform, handle, url, raw_json, scraped_at)
+           VALUES ($1, $2, $3, $4, $5, now())
+           ON CONFLICT (business_id, platform) DO UPDATE SET
+             handle = COALESCE(EXCLUDED.handle, business_socials.handle),
+             url = COALESCE(EXCLUDED.url, business_socials.url),
+             raw_json = COALESCE(EXCLUDED.raw_json, business_socials.raw_json),
+             scraped_at = now()`,
+          [biz.id, platform, handle, url, f.raw_json ? JSON.stringify(f.raw_json) : null]
+        );
+        persisted.social++;
+      } else if (kind === 'articles' && Array.isArray(f.items) && f.items.length) {
+        for (const it of f.items.slice(0, 30)) {
+          const aurl = String(it.url || '').slice(0, 600);
+          if (!/^https?:\/\//i.test(aurl)) continue;
+          await query(
+            `INSERT INTO business_articles (business_id, url, title, publisher, published_at, excerpt, source, raw_json)
+             VALUES ($1, $2, $3, $4, $5, $6, 'exa', $7)
+             ON CONFLICT (business_id, url) DO UPDATE SET
+               title = COALESCE(EXCLUDED.title, business_articles.title),
+               publisher = COALESCE(EXCLUDED.publisher, business_articles.publisher),
+               published_at = COALESCE(EXCLUDED.published_at, business_articles.published_at),
+               excerpt = COALESCE(EXCLUDED.excerpt, business_articles.excerpt)`,
+            [biz.id, aurl, it.title?.slice(0, 240) || null, it.publisher?.slice(0, 120) || null,
+             it.published_at || null, it.excerpt?.slice(0, 600) || null,
+             JSON.stringify(it)]
+          );
+          persisted.article++;
+        }
+      }
+    }
+    res.json({ ok: true, persisted });
+  } catch (e) {
+    fail(res, 'admin/enrich', e);
+  }
+});
+
+// GET /api/admin/enrich-batch?limit=50&kind=website — pull a batch of shops
+// that need enrichment for a given kind. Used by exa-agents as their work-
+// queue source. Skips shops we've already tried for that kind (status≠pending).
+app.get('/api/admin/enrich-batch', async (req, res) => {
+  if (!requireAdminJson(req, res)) return;
+  const kind = String(req.query.kind || 'website').toLowerCase();
+  const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10)));
+  const tier = String(req.query.tier || 'sole_prop').toLowerCase();
+  // Tier picker — start with the highest-value cohort. press_eligible
+  // narrows to hoods where editorial coverage actually concentrates (per
+  // Wave-2 B finding: suburban shops have ~0% press hit rate).
+  const PRESS_HOODS = "ARRAY['Hollywood','East Hollywood','Hollywood Hills','Silver Lake','Echo Park','Highland Park','Koreatown','Mid-Wilshire','Downtown LA','Venice','Mar Vista','Beverly Hills','West Hollywood','Westwood','Brentwood','Pacific Palisades','Santa Monica','Culver City','Los Feliz','Atwater Village','Eagle Rock','Pasadena','South Pasadena','Bel-Air','Boyle Heights','Lincoln Heights','Studio City','Sherman Oaks','Beverly Grove']";
+  const tierFilter = {
+    sole_prop:      `AND b.source_data_json->>'shop_personality' = 'sole_prop'`,
+    corridor:       `AND b.source_data_json->>'corridor' IS NOT NULL`,
+    geocoded:       `AND b.latitude IS NOT NULL`,
+    licensed:       `AND b.source_data_json->>'source' = 'dca_bbcb'`,
+    press_eligible: `AND b.neighborhood = ANY(${PRESS_HOODS})`,
+    all:            ``,
+  }[tier] ?? '';
+  const rows = await many(
+    `SELECT b.id, b.slug, b.name, b.address, b.city, b.neighborhood, b.zip
+       FROM businesses b
+       LEFT JOIN enrichment_jobs j
+         ON j.business_id = b.id AND j.kind = $1
+      WHERE j.id IS NULL
+        ${tierFilter}
+      ORDER BY b.id
+      LIMIT $2`,
+    [kind, limit]
+  );
+  res.json({ ok: true, kind, tier, count: rows.length, batch: rows });
+});
+
+// POST /api/biz/review-source — opt-in review-source registration. Owners
+// or fans submit a Reddit thread, Discord invite, Yelp page, etc. Goes into
+// a moderation queue (verified=false) until admin flips the flag.
+//
+// Body: { slug, source_type, url, label?, submitted_by? }
+const ALLOWED_SOURCE_TYPES = new Set([
+  'reddit','discord','yelp','google','youtube','tiktok','instagram','facebook',
+  'nextdoor','bluesky','x','pinterest','threads','substack','website','other',
+]);
+app.post('/api/biz/review-source', async (req, res) => {
+  try {
+    const { slug, source_type, url, label, submitted_by } = req.body || {};
+    if (!slug || typeof slug !== 'string') return res.status(400).json({ error: 'slug required' });
+    if (!ALLOWED_SOURCE_TYPES.has(String(source_type || '').toLowerCase())) {
+      return res.status(400).json({ error: 'unknown source_type', allowed: [...ALLOWED_SOURCE_TYPES] });
+    }
+    // URL must be http(s) and bounded — keeps form-spam from injecting javascript: URIs.
+    const cleanUrl = String(url || '').trim().slice(0, 600);
+    if (!/^https?:\/\//i.test(cleanUrl)) return res.status(400).json({ error: 'url must be http(s)' });
+    const biz = await loadBySlug(slug);
+    if (!biz) return res.status(404).json({ error: 'unknown business' });
+    const r = await query(
+      `INSERT INTO business_review_sources (business_id, source_type, url, label, submitted_by)
+       VALUES ($1, $2, $3, $4, $5)
+       ON CONFLICT (business_id, url) DO UPDATE SET
+         label = COALESCE(EXCLUDED.label, business_review_sources.label),
+         submitted_by = COALESCE(business_review_sources.submitted_by, EXCLUDED.submitted_by),
+         updated_at = now()
+       RETURNING id, verified`,
+      [biz.id, String(source_type).toLowerCase(), cleanUrl, label ? String(label).slice(0, 80) : null, submitted_by ? String(submitted_by).slice(0, 120) : null]
+    );
+    res.json({ ok: true, id: r.rows[0].id, verified: r.rows[0].verified, queued: !r.rows[0].verified });
+  } catch (e) {
+    fail(res, 'biz/review-source', e);
+  }
+});
+
+// GET /api/biz/:slug/panel — combined payload for the drive-map side panel.
+// Returns the slim shape the corridor-map UI needs: socials, articles (top 5),
+// verified review-sources. One round trip, no fan-out from the client.
+app.get('/api/biz/:slug/panel', async (req, res) => {
+  const biz = await loadBySlug(req.params.slug);
+  if (!biz) return res.status(404).json({ error: 'unknown business' });
+  const [socials, articles, reviewSources] = await Promise.all([
+    many(`SELECT platform, handle, url FROM business_socials WHERE business_id=$1 AND url IS NOT NULL ORDER BY platform`, [biz.id]),
+    many(`SELECT url, title, publisher, published_at FROM business_articles WHERE business_id=$1 ORDER BY published_at DESC NULLS LAST, created_at DESC LIMIT 5`, [biz.id]),
+    many(`SELECT source_type, url, label FROM business_review_sources WHERE business_id=$1 AND verified=true ORDER BY source_type`, [biz.id]),
+  ]);
+  res.json({ ok: true, slug: biz.slug, name: biz.name, website: biz.website || null, socials, articles, review_sources: reviewSources });
+});
+
+// GET /api/biz/:slug/review-sources — return verified sources for client use.
+app.get('/api/biz/:slug/review-sources', async (req, res) => {
+  const biz = await loadBySlug(req.params.slug);
+  if (!biz) return res.status(404).json({ error: 'unknown business' });
+  const rows = await many(
+    `SELECT source_type, url, label
+       FROM business_review_sources
+      WHERE business_id = $1 AND verified = TRUE
+      ORDER BY source_type, created_at`,
+    [biz.id]
+  );
+  res.json({ ok: true, sources: rows });
+});
+
+// /biz/:slug/links — public-facing opt-in form. Anyone (owner, fan, journalist)
+// can submit a Reddit/Discord/Yelp/etc. URL. The submission goes into the
+// moderation queue (verified=false) until an admin approves it.
+app.get('/biz/:slug/links', async (req, res) => {
+  const biz = await loadBySlug(req.params.slug);
+  if (!biz) return res.status(404).send('Unknown business');
+  const existing = await many(
+    `SELECT source_type, url, label, verified, created_at
+       FROM business_review_sources
+      WHERE business_id = $1
+      ORDER BY verified DESC, created_at DESC`,
+    [biz.id]
+  );
+  const verifiedRows = existing.filter(r => r.verified);
+  const queuedRows   = existing.filter(r => !r.verified);
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>Add a link · ${esc(biz.name)} · LA Salon Directory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;line-height:1.5;-webkit-font-smoothing:antialiased}main{max-width:640px;margin:0 auto;padding:64px 28px}h1{font-family:Cormorant Garamond,serif;font-size:42px;font-weight:500;line-height:1.05;letter-spacing:-.015em;margin-bottom:8px}h1 em{color:var(--accent);font-style:italic}.crumb{font-family:monospace;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}.lede{color:#3a3a3a;font-size:16px;margin-bottom:36px;max-width:60ch}form{display:grid;gap:14px;margin-bottom:48px}label{display:block;font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:4px}input,select{width:100%;padding:11px 14px;border:1px solid var(--rule);background:#fff;font-size:15px;font-family:inherit}button{padding:14px 24px;border:none;background:var(--accent);color:var(--paper);font-family:monospace;font-size:11px;letter-spacing:.22em;text-transform:uppercase;cursor:pointer;margin-top:6px}button:hover{background:var(--ink)}h2{font-family:Cormorant Garamond,serif;font-size:24px;font-weight:500;margin:32px 0 12px}.list{list-style:none}.list li{padding:11px 14px;border:1px solid var(--rule);background:#fff;margin-bottom:6px;font-family:monospace;font-size:12px;display:flex;justify-content:space-between;align-items:center;gap:12px}.list li.q{background:#fff8e6;border-color:#e8d896}.list li a{color:var(--accent);overflow:hidden;text-overflow:ellipsis}.tag{font-size:9px;letter-spacing:.18em;color:var(--mute);text-transform:uppercase;background:rgba(0,0,0,.04);padding:2px 6px}.tag.q{background:var(--gold);color:var(--ink)}.back{font-family:monospace;font-size:11px;letter-spacing:.16em;color:var(--mute);text-transform:uppercase;margin-top:48px;display:inline-block}.back a{color:var(--accent)}#out{font-family:monospace;font-size:13px;color:var(--accent);min-height:18px;margin-top:8px}</style></head>
+<body><main>
+  <div class="crumb">${esc(biz.neighborhood || biz.city || '')} · Public submission form</div>
+  <h1>Add a <em>link</em>.<br>${esc(biz.name)}.</h1>
+  <p class="lede">Got a Reddit thread, Discord server, Yelp page, YouTube review, or another community discussion about this business? Drop the link. We moderate, then surface verified sources on the public profile and the drive-mode map.</p>
+  <form id="f">
+    <input type="hidden" name="slug" value="${esc(biz.slug)}">
+    <div><label>Source type</label>
+      <select name="source_type" required>
+        <option value="">— pick one —</option>
+        ${[...ALLOWED_SOURCE_TYPES].map(t => `<option value="${t}">${t}</option>`).join('')}
+      </select>
+    </div>
+    <div><label>URL</label><input name="url" type="url" placeholder="https://reddit.com/r/LosAngeles/..." required></div>
+    <div><label>Label (optional)</label><input name="label" placeholder="r/LosAngeles · Best fade in K-Town?" maxlength="80"></div>
+    <div><label>Submitted by (optional, your name or email)</label><input name="submitted_by" placeholder="optional" maxlength="120"></div>
+    <button type="submit">Submit for moderation</button>
+    <div id="out" aria-live="polite"></div>
+  </form>
+  ${verifiedRows.length ? `<h2>Verified sources</h2><ul class="list">${verifiedRows.map(r => `<li><span><span class="tag">${esc(r.source_type)}</span> <a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.label || r.url)}</a></span></li>`).join('')}</ul>` : ''}
+  ${queuedRows.length ? `<h2>In moderation queue</h2><ul class="list">${queuedRows.map(r => `<li class="q"><span><span class="tag q">${esc(r.source_type)}</span> <a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.label || r.url)}</a></span><span class="tag">queued</span></li>`).join('')}</ul>` : ''}
+  <a class="back" href="/biz/${esc(biz.slug)}">← back to profile</a>
+</main>
+<script>
+document.getElementById('f').addEventListener('submit', async (e) => {
+  e.preventDefault();
+  const fd = new FormData(e.target);
+  const body = Object.fromEntries(fd.entries());
+  const out = document.getElementById('out');
+  out.textContent = 'submitting…';
+  try {
+    const r = await fetch('/api/biz/review-source', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
+    const j = await r.json();
+    if (j.ok) {
+      out.textContent = j.queued ? '✓ submitted — pending moderation' : '✓ verified, live';
+      setTimeout(() => location.reload(), 1200);
+    } else {
+      out.textContent = '✗ ' + (j.error || 'error');
+      out.style.color = '#c2410c';
+    }
+  } catch (err) {
+    out.textContent = '✗ network error';
+    out.style.color = '#c2410c';
+  }
+});
+</script></body></html>`);
+});
+
+// /proprietors — every sole-prop barber/salon flagged by the owner-name
+// heuristic, grouped by neighborhood. Unique-angle SEO play: nobody else
+// publishes a chairs-where-the-license-is-a-person directory.
+app.get('/proprietors', async (_req, res) => {
+  const businesses = await many(
+    `SELECT slug, name, category, neighborhood, city, address
+       FROM businesses
+      WHERE source_data_json->>'shop_personality' = 'sole_prop'
+      ORDER BY neighborhood NULLS LAST, name`
+  );
+  const hoodCounts = await many(
+    `SELECT neighborhood, COUNT(*)::int AS count
+       FROM businesses
+      WHERE source_data_json->>'shop_personality' = 'sole_prop'
+        AND neighborhood IS NOT NULL AND neighborhood <> ''
+      GROUP BY neighborhood
+      ORDER BY count DESC`
+  );
+  const { renderProprietors } = await import('../render/proprietors.js');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderProprietors({ businesses, hoodCounts }));
+});
+
+// /search?q=... — fast LIKE-based search across name + neighborhood + city.
+// Editorial JSON results designed to be embeddable on any future landing
+// page; the endpoint also doubles as the Google SearchAction target.
+app.get('/search', async (req, res) => {
+  const q = String(req.query.q || '').trim().slice(0, 80);
+  if (!q || q.length < 2) {
+    res.set('content-type', 'text/html; charset=utf-8');
+    return res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Search · LA Salon Directory</title><style>body{font-family:Inter,system-ui;margin:0;padding:80px 32px;background:#f5f5f5;color:#1a1a1a}form{max-width:560px;margin:0 auto;display:flex;gap:12px}input{flex:1;padding:14px 18px;font-size:18px;border:1px solid #ccc;background:#fff}button{padding:14px 28px;border:none;background:#5a6e3a;color:#f5f5f5;font-size:14px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer}h1{text-align:center;font-family:Cormorant Garamond,serif;font-weight:500;font-size:48px;margin-bottom:48px}.back{text-align:center;margin-top:32px;font-size:13px;color:#888}.back a{color:#5a6e3a}</style></head><body><h1>Search the directory</h1><form method="get" action="/search"><input name="q" placeholder="Try: silver lake barber, koreatown nails, sherman oaks salon" autofocus><button>Search</button></form><div class="back"><a href="/">← Home</a></div></body></html>`);
+  }
+  // ILIKE on indexed-or-not is fine at our scale (27k rows, sub-50ms).
+  const pattern = `%${q.replace(/[%_]/g, ch => '\\' + ch)}%`;
+  const rows = await many(
+    `SELECT slug, name, category, neighborhood, city, address,
+            (source_data_json->>'source' = 'dca_bbcb') AS licensed
+       FROM businesses
+      WHERE name ILIKE $1
+         OR neighborhood ILIKE $1
+         OR city ILIKE $1
+      ORDER BY (source_data_json->>'source' = 'dca_bbcb') DESC NULLS LAST,
+               LENGTH(name) ASC,
+               name ASC
+      LIMIT 100`,
+    [pattern]
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>${esc(q)} · Search · LA Salon Directory</title>
+<meta name="robots" content="noindex, follow">
+<style>:root{--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:#5a6e3a;--gold:#b8945a}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}.bar{padding:18px 32px;border-bottom:1px solid var(--rule);display:flex;gap:24px;align-items:center;background:#fff;position:sticky;top:0;z-index:5}.bar form{flex:1;display:flex;gap:8px}.bar input{flex:1;padding:10px 14px;border:1px solid var(--rule);font-size:15px}.bar button{padding:10px 20px;border:none;background:var(--accent);color:#fff;font-size:11px;letter-spacing:.18em;text-transform:uppercase}.brand{font-family:Cormorant Garamond,serif;font-size:20px;font-weight:500}.brand em{color:var(--accent);font-style:italic}.results{max-width:1100px;margin:0 auto;padding:48px 32px}.r-head{font-family:monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:24px;padding-bottom:12px;border-bottom:1px solid var(--rule)}.row{display:grid;grid-template-columns:1fr 200px 120px;gap:24px;align-items:baseline;padding:18px 0;border-bottom:1px dotted var(--rule);transition:padding 150ms}.row:hover{padding-left:8px;background:#fff}.r-name{font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500}.r-name em{font-style:italic;color:var(--mute);font-size:12px;font-family:monospace;letter-spacing:.06em;margin-left:8px;text-transform:uppercase}.r-where{font-family:monospace;font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.04em}.r-cat{font-family:monospace;font-size:10px;color:var(--accent);letter-spacing:.18em;text-transform:uppercase;text-align:right}.r-cat .lic{background:var(--gold);color:var(--ink);padding:2px 6px;font-weight:700}.empty{text-align:center;padding:48px;font-family:Cormorant Garamond,serif;font-style:italic;font-size:24px;color:var(--mute)}@media (max-width:880px){.row{grid-template-columns:1fr;gap:4px}}</style></head>
+<body><nav class="bar"><a class="brand" href="/">LA <em>Salon</em> Directory</a><form method="get" action="/search"><input name="q" value="${esc(q)}" autofocus><button>Search</button></form></nav>
+<section class="results">
+<div class="r-head">${rows.length} result${rows.length===1?'':'s'} for "${esc(q)}"</div>
+${rows.length === 0 ? `<div class="empty">No matches. Try a neighborhood ("silver lake"), category ("nails"), or shop name.</div>` :
+  rows.map(r => `<a class="row" href="/biz/${esc(r.slug)}">
+    <span class="r-name">${esc(r.name)}<em>${esc(r.category || '')}</em></span>
+    <span class="r-where">${esc(r.neighborhood || r.city || '')}${r.address ? ' · ' + esc(r.address) : ''}</span>
+    <span class="r-cat">${r.licensed ? '<span class="lic">◆ Licensed</span>' : ''}</span>
+  </a>`).join('')
+}
+</section></body></html>`);
+});
+
+// robots.txt — point Google at the sitemap. Block /admin and /biz/*/edit
+// so the editor URLs don't end up in search results.
+app.get('/robots.txt', (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  res.set('content-type', 'text/plain; charset=utf-8');
+  res.send(`User-agent: *
+Disallow: /admin
+Disallow: /api/
+Disallow: /biz/*/edit
+Disallow: /biz/*/interview
+Allow: /
+
+Sitemap: ${proto}://${host}/sitemap.xml
+`);
+});
+
+// sitemap.xml — chunked into a top-level sitemap-index that points at three
+// per-section sitemaps (shops, neighborhoods, corridors+categories+landings).
+// Splitting keeps each <urlset> well under Google's 50k-URL/50MB limit and
+// lets us refresh shops independently when the catalog grows.
+app.get('/sitemap.xml', async (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  const base = `${proto}://${host}`;
+  const lastMod = new Date().toISOString().slice(0, 10);
+  res.set('content-type', 'application/xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+<sitemap><loc>${base}/sitemap-pages.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
+<sitemap><loc>${base}/sitemap-neighborhoods.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
+<sitemap><loc>${base}/sitemap-shops.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
+<sitemap><loc>${base}/sitemap-best.xml</loc><lastmod>${lastMod}</lastmod></sitemap>
+</sitemapindex>`);
+});
+
+// "Best" sitemap = every /best/:hood and /best/:hood/:cat permutation. These are
+// the SEO bigfoot URLs — verbatim Google query patterns ("best hair salons in X").
+app.get('/sitemap-best.xml', async (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  const base = `${proto}://${host}`;
+  const lastMod = new Date().toISOString().slice(0, 10);
+  const grid = await many(
+    `SELECT neighborhood,
+            COUNT(*) FILTER (WHERE category = 'salon')      AS salons,
+            COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops,
+            COUNT(*) FILTER (WHERE category = 'spa')        AS spas,
+            COUNT(*) FILTER (WHERE category = 'nail')       AS nails
+       FROM businesses
+      WHERE neighborhood IS NOT NULL AND neighborhood <> ''
+      GROUP BY neighborhood
+      HAVING COUNT(*) >= 5
+      ORDER BY neighborhood`
+  );
+  const slug = s => String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
+  const urls = ['/best'];
+  for (const g of grid) {
+    const s = slug(g.neighborhood);
+    urls.push(`/best/${s}`);
+    if (Number(g.salons)      > 0) urls.push(`/best/${s}/salons`);
+    if (Number(g.barbershops) > 0) urls.push(`/best/${s}/barbershops`);
+    if (Number(g.spas)        > 0) urls.push(`/best/${s}/spas`);
+    if (Number(g.nails)       > 0) urls.push(`/best/${s}/nail-salons`);
+  }
+  res.set('content-type', 'application/xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.map(u => `<url><loc>${base}${u}</loc><lastmod>${lastMod}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>`).join('\n')}
+</urlset>`);
+});
+
+// "Pages" sitemap = static-ish landing pages (corridors, categories, /fresh,
+// /map, /neighborhoods, /).
+app.get('/sitemap-pages.xml', async (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  const base = `${proto}://${host}`;
+  const lastMod = new Date().toISOString().slice(0, 10);
+  const cats = await many(
+    `SELECT DISTINCT category FROM businesses WHERE category IS NOT NULL AND category <> '' ORDER BY 1`
+  );
+  const corridors = ['ventura-blvd','sunset-blvd','wilshire-blvd','hollywood-blvd'];
+  const urls = [
+    '/', '/neighborhoods', '/map', '/fresh', '/proprietors', '/search',
+    ...corridors.map(c => `/corridor/${c}`),
+    ...cats.map(c => `/c/${c.category}`),
+  ];
+  res.set('content-type', 'application/xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.map(u => `<url><loc>${base}${u}</loc><lastmod>${lastMod}</lastmod><changefreq>weekly</changefreq></url>`).join('\n')}
+</urlset>`);
+});
+
+// "Neighborhoods" sitemap = one URL per /n/<slug> page.
+app.get('/sitemap-neighborhoods.xml', async (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  const base = `${proto}://${host}`;
+  const lastMod = new Date().toISOString().slice(0, 10);
+  const rows = await many(
+    `SELECT DISTINCT neighborhood FROM businesses
+      WHERE neighborhood IS NOT NULL AND neighborhood <> ''
+      ORDER BY 1`
+  );
+  const urls = rows.map(r => `/n/${slugify(r.neighborhood)}`);
+  res.set('content-type', 'application/xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.map(u => `<url><loc>${base}${u}</loc><lastmod>${lastMod}</lastmod><changefreq>weekly</changefreq></url>`).join('\n')}
+</urlset>`);
+});
+
+// "Shops" sitemap = one URL per /biz/<slug>. Capped at 50k URLs per Google's
+// limit; if the catalog grows past that we'll need to chunk into shops-1,
+// shops-2, etc.
+app.get('/sitemap-shops.xml', async (req, res) => {
+  const host = req.get('host') || `127.0.0.1:${PORT}`;
+  const proto = req.get('x-forwarded-proto') || 'http';
+  const base = `${proto}://${host}`;
+  const rows = await many(
+    `SELECT slug, GREATEST(updated_at, created_at) AS lastmod
+       FROM businesses
+      ORDER BY lastmod DESC
+      LIMIT 50000`
+  );
+  res.set('content-type', 'application/xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${rows.map(r => `<url><loc>${base}/biz/${r.slug}</loc><lastmod>${new Date(r.lastmod).toISOString().slice(0,10)}</lastmod></url>`).join('\n')}
+</urlset>`);
+});
+
+// /fresh — newest BBCB-licensed shops first. The "what's new this week" feed.
+// We sort by issue_date as a date (parsed from MM/DD/YYYY in JS), so the page
+// is fully driven off source_data_json without a separate column.
+app.get('/fresh', async (_req, res) => {
+  const rows = await many(
+    `SELECT slug,name,category,neighborhood,city,address,zip,source_data_json
+       FROM businesses
+      WHERE source_data_json->>'source' = 'dca_bbcb'
+        AND source_data_json->>'issue_date' ~ '^[0-9]{1,2}[-/][0-9]{1,2}[-/][0-9]{4}$'`
+  );
+  // Parse issue_date once, sort desc, slice 100. JS Date works fine for
+  // MM/DD/YYYY strings; invalid dates push to the bottom via NaN-as-0 fallback.
+  const parseIssue = (s) => {
+    // BBCB stores either "MM-DD-YYYY" or "MM/DD/YYYY" depending on export pass.
+    const m = String(s || '').match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
+    if (!m) return 0;
+    const [, mm, dd, yyyy] = m;
+    return new Date(parseInt(yyyy,10), parseInt(mm,10)-1, parseInt(dd,10)).getTime();
+  };
+  const sorted = rows
+    .map(r => ({ ...r, _issued: parseIssue(r.source_data_json?.issue_date) }))
+    .filter(r => r._issued > 0 && r._issued <= Date.now())
+    .sort((a, b) => b._issued - a._issued)
+    .slice(0, 100);
+  const { renderFresh } = await import('../render/fresh.js');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderFresh({ businesses: sorted }));
+});
+
+// /c/:category — category landing page (barbershop, salon, spa, …). Shows top
+// neighborhoods for the category, featured shops (state-licensed first), and
+// an alphabetical roll. SEO play for "best barbers in LA" / "nail salons LA".
+app.get('/c/:category', async (req, res) => {
+  const category = String(req.params.category || '').toLowerCase().replace(/[^\w]/g, '');
+  if (!category) return res.status(404).send('Unknown category');
+  const businesses = await many(
+    `SELECT slug,
+            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
+            category,address,city,state,zip,neighborhood,latitude,longitude,phone,website,hero_image_url,source_data_json
+       FROM businesses
+      WHERE category = $1
+      ORDER BY (source_data_json->>'source' = 'dca_bbcb') DESC NULLS LAST,
+               neighborhood NULLS LAST,
+               name`,
+    [category]
+  );
+  if (!businesses.length) return res.status(404).send('Unknown or empty category');
+  const hoodCounts = await many(
+    `SELECT neighborhood, COUNT(*)::int AS count
+       FROM businesses
+      WHERE category = $1 AND neighborhood IS NOT NULL AND neighborhood <> ''
+      GROUP BY neighborhood
+      ORDER BY count DESC
+      LIMIT 50`,
+    [category]
+  );
+  const totalLicensed = businesses.filter(b => b.source_data_json?.source === 'dca_bbcb').length;
+  const { renderCategory } = await import('../render/category.js');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderCategory({ category, businesses, hoodCounts, totalLicensed }));
+});
+
+// /neighborhoods — full A-Z index of every neighborhood with at least one shop.
+// One DB hit; aggregate at SQL layer for speed (160 rows, not 27k).
+app.get('/neighborhoods', async (_req, res) => {
+  const rows = await many(
+    `SELECT neighborhood,
+            COUNT(*)::int AS count,
+            COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb')::int AS licensed_count
+       FROM businesses
+      WHERE neighborhood IS NOT NULL AND neighborhood <> ''
+      GROUP BY neighborhood
+      ORDER BY count DESC`
+  );
+  const totals = await one(
+    `SELECT COUNT(*)::int AS total_shops,
+            COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb')::int AS total_licensed
+       FROM businesses
+      WHERE neighborhood IS NOT NULL AND neighborhood <> ''`
+  );
+  const { renderNeighborhoods } = await import('../render/neighborhoods.js');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderNeighborhoods({
+    neighborhoods: rows,
+    totalShops: totals?.total_shops || 0,
+    totalLicensed: totals?.total_licensed || 0,
+  }));
 });
 
 // Original mockup-grid (admin/builder UI) lives at /builder now.
@@ -154,6 +2894,79 @@ app.get('/builder', async (_req, res) => {
 });
 
 // Neighborhood detail page — list the shops in one LA neighborhood, editorially.
+// Corridor page — long linear stretch of road treated as the spine of a
+// curated section. Currently only ventura-blvd-20mi is wired up.
+// /corridor/:slug/map — drive-mode Leaflet map of one corridor with live
+// geolocation, nearest-businesses panel, and click-to-side-panel detail.
+// Reuses the same SQL as the editorial corridor page so any new corridor
+// gets a /corridor/:slug/map for free.
+app.get('/corridor/:slug/map', async (req, res) => {
+  const slug = String(req.params.slug || '').toLowerCase();
+  const tagMap = {
+    'ventura-blvd': 'ventura-blvd-20mi', 'ventura-blvd-20mi': 'ventura-blvd-20mi',
+    'sunset-blvd': 'sunset-blvd-22mi', 'sunset-blvd-22mi': 'sunset-blvd-22mi',
+    'wilshire-blvd': 'wilshire-blvd-16mi', 'wilshire-blvd-16mi': 'wilshire-blvd-16mi',
+    'hollywood-blvd': 'hollywood-blvd-5mi', 'hollywood-blvd-5mi': 'hollywood-blvd-5mi',
+  };
+  const corridorTag = tagMap[slug];
+  if (!corridorTag) return res.status(404).send('Unknown corridor');
+  const businesses = await many(
+    `SELECT slug,
+            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
+            category,address,city,state,zip,neighborhood,
+            latitude,longitude,phone,website,hero_image_url,source_data_json
+       FROM businesses
+      WHERE source_data_json->>'corridor' = $1
+      ORDER BY longitude NULLS LAST, name`,
+    [corridorTag]
+  );
+  // Pull the same CORRIDOR_META used by /corridor/:slug so we can inherit
+  // accent + title without duplicating it server-side.
+  const corridorMod = await import('../render/corridor.js');
+  // CORRIDOR_META is module-internal; re-derive a tiny shim. The render
+  // module also exports renderCorridor, so we just read what we need from
+  // a scoped peek via a known-shape default fallback.
+  const META = {
+    'ventura-blvd-20mi':  { title:'Ventura Boulevard',   accent:'#5a6e3a' },
+    'sunset-blvd-22mi':   { title:'Sunset Boulevard',    accent:'#c2410c' },
+    'wilshire-blvd-16mi': { title:'Wilshire Boulevard',  accent:'#6b3a8e' },
+    'hollywood-blvd-5mi': { title:'Hollywood Boulevard', accent:'#9b1c1c' },
+  };
+  const { renderCorridorMap } = await import('../render/corridor-map.js');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderCorridorMap({
+    corridorTag,
+    meta: META[corridorTag] || { title: 'Corridor', accent: '#5a6e3a' },
+    businesses,
+  }));
+});
+
+app.get('/corridor/:slug', async (req, res) => {
+  const slug = String(req.params.slug || '').toLowerCase();
+  const tagMap = {
+    'ventura-blvd': 'ventura-blvd-20mi', 'ventura-blvd-20mi': 'ventura-blvd-20mi',
+    'sunset-blvd': 'sunset-blvd-22mi', 'sunset-blvd-22mi': 'sunset-blvd-22mi',
+    'wilshire-blvd': 'wilshire-blvd-16mi', 'wilshire-blvd-16mi': 'wilshire-blvd-16mi',
+    'hollywood-blvd': 'hollywood-blvd-5mi', 'hollywood-blvd-5mi': 'hollywood-blvd-5mi',
+  };
+  const corridorTag = tagMap[slug];
+  if (!corridorTag) return res.status(404).send('Unknown corridor');
+
+  const { renderCorridor } = await import('../render/corridor.js');
+  const businesses = await many(
+    `SELECT slug,
+            COALESCE(NULLIF(source_data_json->>'dba_name',''), name) AS name,
+            category,address,city,state,zip,neighborhood,
+            latitude,longitude,phone,website,hero_image_url,source_data_json
+       FROM businesses
+      WHERE source_data_json->>'corridor' = $1
+      ORDER BY longitude NULLS LAST, name`,
+    [corridorTag]
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderCorridor({ corridorTag, businesses }));
+});
+
 app.get('/n/:slug', async (req, res) => {
   const slug = String(req.params.slug || '').toLowerCase();
   // First, resolve slug → distinct neighborhood name(s). Pull only the unique
@@ -215,8 +3028,35 @@ app.get('/biz/:slug', async (req, res) => {
      ORDER BY ranking_score DESC NULLS LAST, updated_at DESC LIMIT 4`,
     [b.neighborhood, b.id]
   ) : [];
+  // Pull Exa-enriched + community-submitted accessories: socials, articles,
+  // and verified review-source links. profile.js renders these inline.
+  const socials = await many(
+    `SELECT platform, handle, url FROM business_socials
+      WHERE business_id=$1 AND url IS NOT NULL
+      ORDER BY platform`,
+    [b.id]
+  );
+  const articles = await many(
+    `SELECT url, title, publisher, published_at, excerpt
+       FROM business_articles
+      WHERE business_id=$1
+      ORDER BY published_at DESC NULLS LAST, created_at DESC
+      LIMIT 8`,
+    [b.id]
+  );
+  const reviewSources = await many(
+    `SELECT source_type, url, label
+       FROM business_review_sources
+      WHERE business_id=$1 AND verified=true
+      ORDER BY source_type`,
+    [b.id]
+  );
+  // Block clickjacking — foreign sites embedding the public profile in an
+  // iframe could overlay invisible UI on top of the lead form.
   res.set('content-type', 'text/html; charset=utf-8');
-  res.send(renderProfile({ business: b, interview, otherShopsInHood }));
+  res.set('x-frame-options', 'SAMEORIGIN');
+  res.set('content-security-policy', "frame-ancestors 'self'");
+  res.send(renderProfile({ business: b, interview, otherShopsInHood, socials, articles, reviewSources }));
 });
 
 // Interview admin form — fill in the Q&A.
@@ -278,6 +3118,28 @@ app.post('/api/biz/photo/upload', async (req, res) => {
   }
 });
 
+// AI-draft an interview from existing context (about_text, vibe note, scraped meta).
+// Hits local qwen3:14b on Mac Studio 1. Returns {qa: {...}} for the client to
+// pre-fill the form — does NOT auto-save. Owner edits each [VERIFY] tag before saving.
+app.post('/api/biz/interview/draft', async (req, res) => {
+  try {
+    const { slug } = req.body || {};
+    if (!slug) return res.status(400).json({ error: 'slug required' });
+    const b = await loadBySlug(slug);
+    if (!b) return res.status(404).json({ error: 'business not found' });
+    log.info('interview/draft start', { slug });
+    const result = await draftInterview(b);
+    log.info('interview/draft ok', {
+      slug, fields: Object.keys(result.qa).length, model: result.model, host: result.host,
+      eval_count: result.eval_count, ms: result.total_duration_ms,
+    });
+    res.json({ ok: true, slug, ...result });
+  } catch (e) {
+    log.err('interview/draft failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
 // Save (newest-wins; we keep a rolling history capped at INTERVIEW_HISTORY_MAX rows).
 // Auth: gated by the same ADMIN_TOKEN cookie as /admin so anonymous visitors
 // can't overwrite arbitrary shop interviews.
@@ -374,7 +3236,11 @@ app.get('/biz/:slug/template/:id', async (req, res) => {
   if (!b) return res.status(404).send('Not found');
   const id = parseInt(req.params.id, 10);
   if (!(id >= 1 && id <= 5)) return res.status(400).send('Bad template id');
+  // Same anti-clickjacking headers as /biz/:slug — only /preview is meant for
+  // foreign embedding (the grid iframes).
   res.set('content-type', 'text/html; charset=utf-8');
+  res.set('x-frame-options', 'SAMEORIGIN');
+  res.set('content-security-policy', "frame-ancestors 'self'");
   res.send(renderTemplate(id, b));
 });
 
@@ -520,6 +3386,1067 @@ app.post('/api/biz/socials', async (req, res) => {
   }
 });
 
+// --- website-analysis -------------------------------------------------------
+// Brutalist entry page (URL + Instagram) → result page with the user's site
+// embedded + 3 mockup-themed rebuild cards. Backed by website_analyses +
+// website_analysis_mockups tables (migration 002).
+
+app.get('/website-analysis', (_req, res) => {
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderWAEntry({}));
+});
+
+// Builds Index — sortable + collapsible table of every ~/Projects directory.
+// Description text comes from MEMORY.md, dates from `git log -1 --format=%cI`,
+// fallback mtime when not a git repo. Cached in-memory for 30s so a refresh
+// doesn't shell out 100+ git commands per page-view.
+// Q4 dream team: filesystem dir name (`wholivedthere`) doesn't always match the
+// analysis row's slug (`wholivedthere-com` for URL-driven entries). Often BOTH
+// rows exist (build-driven + URL-driven). Collect every match and prefer the
+// one that actually has audit data so the /builds card picks up live signals.
+function lookupAna(b, anaBySlug) {
+  const candidates = [];
+  if (anaBySlug.has(b.slug)) candidates.push(anaBySlug.get(b.slug));
+  if (anaBySlug.has(`${b.slug}-com`)) candidates.push(anaBySlug.get(`${b.slug}-com`));
+  if (b.url) {
+    try {
+      const host = new URL(b.url).hostname.toLowerCase().replace(/\./g, '-').replace(/^www-/, '');
+      if (anaBySlug.has(host) && !candidates.find(c => c.slug === host)) candidates.push(anaBySlug.get(host));
+    } catch {}
+  }
+  if (!candidates.length) return null;
+  // Score each: +2 has audit_report, +2 has live_audit, +1 has copy_pack, +1 vertical specified.
+  const score = (c) => (c.summary_json?.audit_report ? 2 : 0) + (c.summary_json?.live_audit ? 2 : 0)
+                     + (c.summary_json?.copy_pack ? 1 : 0) + (c.vertical && c.vertical !== 'generic' ? 1 : 0);
+  return candidates.reduce((best, c) => score(c) > score(best) ? c : best);
+}
+
+let _buildsCache = { at: 0, rows: null };
+async function getBuildsRows() {
+  const now = Date.now();
+  if (!_buildsCache.rows || (now - _buildsCache.at) > 30_000) {
+    const ana = await many('SELECT slug, vertical, summary_json, meta_json, ig_handle FROM website_analyses');
+    const set = new Set(ana.map(r => r.slug));
+    const baseRows = listBuilds({ analysisSlugs: set });
+    const anaBySlug = new Map(ana.map(r => [r.slug, r]));
+    const grades = new Map();
+    for (const b of baseRows) {
+      const a = lookupAna(b, anaBySlug) || null;
+      const g = computeGrade({ analysis: a, build: b });
+      const ar = a?.summary_json?.audit_report;
+      const deepBlob = ar
+        ? [...(ar.quick_wins || []), ...(ar.suggested_headlines || []), ...(ar.vertical_tips || [])].join(' ').toLowerCase()
+        : '';
+      grades.set(b.slug, { ...g, vertical: a?.vertical || null, deep_search: deepBlob, audit_score: ar?.score ?? null });
+    }
+    _buildsCache = { at: now, rows: listBuilds({ analysisSlugs: set, grades }) };
+  }
+  return _buildsCache.rows;
+}
+
+// Filter helpers — each takes the full row list + returns a slice.
+const BUILD_VIEWS = {
+  all:        { title: 'All Builds',          fn: rows => rows },
+  active:     { title: 'Active · pm2 online', fn: rows => rows.filter(r => r.pm2?.status === 'online') },
+  today:      { title: 'Touched today',       fn: rows => rows.filter(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 86_400_000) },
+  week:       { title: 'Touched this week',   fn: rows => rows.filter(r => r.last_at && (Date.now() - Date.parse(r.last_at)) < 7 * 86_400_000) },
+  stale:      { title: 'Stale · 30d+',        fn: rows => rows.filter(r => !r.last_at || (Date.now() - Date.parse(r.last_at)) > 30 * 86_400_000) },
+  graded:     { title: 'Graded only',         fn: rows => rows.filter(r => r.grade) },
+  ungraded:   { title: 'Ungraded',            fn: rows => rows.filter(r => !r.grade) },
+  broken:     { title: 'Broken pm2',          fn: rows => rows.filter(r => r.pm2 && r.pm2.status !== 'online') },
+  url:        { title: 'Has public URL',      fn: rows => rows.filter(r => r.url) },
+  timeline:   { title: 'Timeline · newest first', fn: rows => [...rows].sort((a, b) => (b.last_at ? Date.parse(b.last_at) : 0) - (a.last_at ? Date.parse(a.last_at) : 0)) },
+};
+
+app.get('/builds', async (req, res) => {
+  const rows = await getBuildsRows();
+  const view = String(req.query.view || 'all').toLowerCase();
+  const viewDef = BUILD_VIEWS[view] || BUILD_VIEWS.all;
+  const grade = String(req.query.grade || '').toUpperCase();
+  const cat = String(req.query.cat || '').toLowerCase();
+  let filtered = viewDef.fn(rows);
+  if (grade && /^[A-F]$/.test(grade)) filtered = filtered.filter(r => r.grade?.letter === grade);
+  if (cat) filtered = filtered.filter(r => r.category === cat);
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderBuildsIndex({
+    builds: filtered,
+    allBuilds: rows,
+    activeView: view,
+    activeGrade: grade,
+    activeCat: cat,
+  }));
+});
+
+app.get('/builds/:view', async (req, res, next) => {
+  // Convenience redirects for /builds/active, /builds/today, etc.
+  // Fall through (next()) to other matching routes for unknown segments — e.g.
+  // /builds/pm2-logs, /builds/build/:slug — so they aren't shadowed by this
+  // generic single-segment matcher.
+  const view = String(req.params.view || '').toLowerCase();
+  if (BUILD_VIEWS[view]) return res.redirect(302, `/builds?view=${encodeURIComponent(view)}`);
+  if (view === 'by-grade') {
+    const g = String(req.query.g || 'A').toUpperCase();
+    return res.redirect(302, `/builds?view=graded&grade=${encodeURIComponent(g)}`);
+  }
+  return next();
+});
+
+app.get('/builds/by-grade/:letter', (req, res) => {
+  const g = String(req.params.letter || 'A').toUpperCase();
+  res.redirect(302, `/builds?view=graded&grade=${encodeURIComponent(g)}`);
+});
+
+// /builds/build/:slug — per-project deep-dive page. Two-segment path so it
+// doesn't collide with /builds/:view (one-segment).
+// Shows: pm2 health, last 20 git commits, package.json deps, README excerpt,
+// audit grade, recent file mtimes. All cheap shell-outs are 4s-capped.
+app.get('/builds/build/:slug', async (req, res) => {
+  const slug = String(req.params.slug || '').toLowerCase();
+  if (!/^[\w.-]+$/.test(slug)) return res.status(400).send('invalid slug');
+  const rows = await getBuildsRows();
+  const build = rows.find(r => (r.slug || r.name).toLowerCase() === slug || r.name.toLowerCase() === slug);
+  if (!build) return res.status(404).send('Unknown build');
+
+  const { execFile } = await import('node:child_process');
+  const fsMod = await import('node:fs');
+  const pathMod = await import('node:path');
+
+  const sh = (cmd, args) => new Promise(resolve => {
+    execFile(cmd, args, { timeout: 4000, maxBuffer: 1024 * 256 }, (err, stdout, stderr) => {
+      resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
+    });
+  });
+  const [gitLog, gitStatus, gitBranch] = await Promise.all([
+    build.has_git ? sh('git', ['-C', build.path, 'log', '-20', '--format=%cI %h %s']) : Promise.resolve({ stdout: '' }),
+    build.has_git ? sh('git', ['-C', build.path, 'status', '--porcelain']) : Promise.resolve({ stdout: '' }),
+    build.has_git ? sh('git', ['-C', build.path, 'rev-parse', '--abbrev-ref', 'HEAD']) : Promise.resolve({ stdout: '' }),
+  ]);
+
+  let pkg = null;
+  try { pkg = JSON.parse(fsMod.readFileSync(pathMod.join(build.path, 'package.json'), 'utf8')); } catch {}
+
+  let readme = null;
+  for (const name of ['README.md', 'README.txt', 'README']) {
+    try {
+      const txt = fsMod.readFileSync(pathMod.join(build.path, name), 'utf8');
+      readme = { file: name, body: txt.split('\n').slice(0, 200).join('\n') };
+      break;
+    } catch {}
+  }
+
+  let recentFiles = [];
+  try {
+    recentFiles = fsMod.readdirSync(build.path)
+      .filter(e => !e.startsWith('.') && !['node_modules','dist','build','.next','coverage','.cache'].includes(e))
+      .map(name => {
+        try {
+          const st = fsMod.statSync(pathMod.join(build.path, name));
+          return { name, mtime: st.mtimeMs, size: st.size, dir: st.isDirectory() };
+        } catch { return null; }
+      })
+      .filter(Boolean)
+      .sort((a, b) => b.mtime - a.mtime)
+      .slice(0, 30);
+  } catch {}
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  const safe = (s) => String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+  const fmtSize = (n) => n < 1024 ? `${n}B` : n < 1048576 ? `${(n/1024).toFixed(1)}K` : `${(n/1048576).toFixed(1)}M`;
+  const fmtTime = (ms) => {
+    const days = Math.floor((Date.now() - ms) / 86400000);
+    if (days === 0) return 'today';
+    if (days === 1) return '1d ago';
+    if (days < 30) return `${days}d ago`;
+    if (days < 365) return `${Math.floor(days/30)}mo ago`;
+    return `${Math.floor(days/365)}y ago`;
+  };
+  let urlObj = null;
+  try { urlObj = new URL(build.url || ''); } catch {}
+  const ghSearch = `https://github.com/search?q=${encodeURIComponent(build.name)}+stevestudio2&type=repositories`;
+
+  const commits = gitLog.stdout.split('\n').filter(Boolean).map(line => {
+    const m = line.match(/^([\dT:.+\-Z]+)\s+([a-f0-9]+)\s+(.*)$/);
+    return m ? { iso: m[1], hash: m[2], subject: m[3] } : null;
+  }).filter(Boolean);
+  const dirty = gitStatus.stdout.split('\n').filter(Boolean);
+  const branch = gitBranch.stdout.trim() || '(no git)';
+  const deps    = pkg?.dependencies    ? Object.entries(pkg.dependencies)    : [];
+  const devDeps = pkg?.devDependencies ? Object.entries(pkg.devDependencies) : [];
+
+  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>${safe(build.name)} · Build · agentabrams</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:'Space Mono',monospace;background:#fff;color:#000;line-height:1.4}
+.hd{background:#000;color:#fff;border-bottom:4px solid #000;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
+.lg{font-family:'Archivo Black',sans-serif;font-size:24px;letter-spacing:-.04em;text-transform:uppercase;text-decoration:none;color:#fff}
+.lg::after{content:'.';color:#facc15}
+.hd-nav{display:flex;gap:6px;font-size:11px;font-weight:700;text-transform:uppercase}
+.hd-nav a{padding:6px 12px;border:2px solid #fff;color:#fff;text-decoration:none}
+.hd-nav a:hover{background:#facc15;color:#000;border-color:#facc15}
+main{max-width:1240px;margin:0 auto;padding:32px 24px}
+.crumb{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#666;margin-bottom:12px}
+.crumb a{color:#000;text-decoration:underline}
+h1{font-family:'Archivo Black',sans-serif;font-size:64px;line-height:.9;text-transform:uppercase;letter-spacing:-.04em;margin-bottom:8px;word-break:break-word}
+h1 mark{background:#facc15;padding:0 8px}
+.subtitle{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#666;margin-bottom:18px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
+.subtitle .grade{display:inline-block;padding:2px 10px;border:2px solid #000;font-family:'Archivo Black',sans-serif;font-size:14px;color:#000}
+.btnrow{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:32px}
+.bbtn{display:inline-block;background:#fff;color:#000;border:2px solid #000;padding:7px 12px;font-family:'Space Mono',monospace;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;text-decoration:none;cursor:pointer}
+.bbtn:hover{background:#facc15}
+.bbtn-primary{background:#000;color:#facc15}
+.bbtn-primary:hover{background:#facc15;color:#000}
+.bbtn-port{background:#facc15;color:#000}
+.bbtn-mock{background:#22c55e;color:#000}
+.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px}
+.panel{border:4px solid #000;background:#fff}
+.panel-h{background:#000;color:#facc15;font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:.04em;padding:10px 14px;display:flex;justify-content:space-between;align-items:center}
+.panel-b{padding:14px;font-size:12px;line-height:1.5}
+.panel-b ul{list-style:none}
+.panel-b li{padding:4px 0;border-bottom:1px dotted #ccc;display:flex;justify-content:space-between;align-items:baseline;gap:8px}
+.panel-b li:last-child{border-bottom:none}
+.panel-b .meta{font-size:10px;color:#666;font-weight:700;text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}
+.commit{font-size:11px;padding:6px 0;border-bottom:1px dotted #ccc}
+.commit-hash{font-family:'Space Mono',monospace;font-weight:700;background:#facc15;padding:1px 5px;font-size:10px;letter-spacing:.04em;margin-right:6px}
+.commit-iso{font-size:10px;color:#666;font-weight:700;letter-spacing:.04em}
+.dirty-line{font-family:'Space Mono',monospace;font-size:11px;color:#dc2626;padding:2px 0}
+pre.readme{background:#fafaf6;border:none;padding:14px;font-family:'Space Mono',monospace;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:520px;overflow-y:auto}
+.dep-tag{display:inline-block;background:#fafaf6;border:1px solid #000;padding:2px 6px;font-size:10px;font-weight:700;letter-spacing:.02em;margin:1px}
+.dep-tag .v{color:#666}
+.empty{color:#aaa;font-style:italic;padding:8px 0}
+.pm2-online{background:#22c55e;color:#000;padding:2px 8px;font-weight:700;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
+.pm2-broken{background:#dc2626;color:#fff;padding:2px 8px;font-weight:700;font-size:11px;letter-spacing:.04em;text-transform:uppercase}
+.kv-row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1px dotted #ccc;font-size:11px;gap:8px}
+.kv-row strong{font-weight:700;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase}
+.kv-row .v{font-family:'Space Mono',monospace;color:#444;text-align:right;word-break:break-all}
+</style></head><body>
+<header class="hd">
+  <a href="/builds" class="lg">★ Builds</a>
+  <nav class="hd-nav">
+    <a href="/builds">All</a>
+    <a href="/builds?view=active">Active</a>
+    <a href="/builds?view=today">Today</a>
+    <a href="/builds?view=graded">Graded</a>
+    <a href="/builds?view=timeline">Timeline</a>
+    <a href="/">Salon Dir →</a>
+  </nav>
+</header>
+<main>
+  <div class="crumb"><a href="/builds">All Builds</a> · <a href="/builds?cat=${encodeURIComponent(build.category)}">${safe(build.category)}</a></div>
+  <h1>★ <mark>${safe(build.name)}</mark></h1>
+  <div class="subtitle">
+    ${build.grade ? `<span class="grade" style="background:${safe(build.grade.color)}">${safe(build.grade.letter)}</span>` : ''}
+    <span>${safe(build.category)}</span>
+    ${build.last_at ? `<span>· last touched ${fmtTime(Date.parse(build.last_at))}</span>` : ''}
+    <span>· ~/Projects/${safe(build.name)}</span>
+    ${build.has_git ? `<span>· git ✓ ${safe(branch)}</span>` : ''}
+    ${build.pm2 ? (build.pm2.status === 'online' ? `<span class="pm2-online">★ pm2 ${safe(build.pm2.name)} :${build.pm2.port || '?'}</span>` : `<span class="pm2-broken">★ pm2 ${safe(build.pm2.name)} ${safe(build.pm2.status)}</span>`) : ''}
+  </div>
+  ${build.description ? `<p style="font-size:14px;line-height:1.55;color:#333;margin-bottom:18px;max-width:78ch">${safe(build.description)}</p>` : ''}
+
+  <div class="btnrow">
+    ${urlObj ? `<a class="bbtn bbtn-primary" href="${safe(build.url)}" target="_blank" rel="noopener">★ ${safe(urlObj.host)} ↗</a>` : ''}
+    ${build.port ? `<a class="bbtn bbtn-port" href="http://127.0.0.1:${build.port}" target="_blank" rel="noopener">:${build.port}</a>` : ''}
+    ${build.analysis_slug ? `<a class="bbtn bbtn-mock" href="/wa/${safe(build.analysis_slug)}" target="_blank" rel="noopener">★ Mockups</a>` : ''}
+    <a class="bbtn" href="vscode://file${safe(build.path)}">VS Code</a>
+    <a class="bbtn" href="file://${safe(build.path)}">Finder</a>
+    <button class="bbtn" type="button" onclick="navigator.clipboard?.writeText('${safe(build.path)}');this.textContent='✓ Copied'">Copy Path</button>
+    <a class="bbtn" href="${safe(ghSearch)}" target="_blank" rel="noopener">GitHub Search</a>
+    ${build.pm2?.name ? `<a class="bbtn" href="/builds/pm2-logs?app=${encodeURIComponent(build.pm2.name)}" target="_blank" rel="noopener">pm2 Logs</a>` : ''}
+  </div>
+
+  <div class="grid">
+    <div class="panel">
+      <div class="panel-h"><span>★ Quick Facts</span><span style="color:#fff;font-size:10px">${build.has_git ? safe(branch) : ''}</span></div>
+      <div class="panel-b">
+        <div class="kv-row"><strong>Name</strong><span class="v">${safe(build.name)}</span></div>
+        <div class="kv-row"><strong>Path</strong><span class="v">${safe(build.path)}</span></div>
+        <div class="kv-row"><strong>Category</strong><span class="v">${safe(build.category)}</span></div>
+        ${build.url ? `<div class="kv-row"><strong>URL</strong><span class="v"><a href="${safe(build.url)}" target="_blank">${safe(build.url)}</a></span></div>` : ''}
+        ${build.port ? `<div class="kv-row"><strong>Port</strong><span class="v">${build.port}</span></div>` : ''}
+        ${build.last_at ? `<div class="kv-row"><strong>Last Worked</strong><span class="v">${safe(build.last_at.slice(0,10))} · ${fmtTime(Date.parse(build.last_at))}</span></div>` : ''}
+        ${build.has_git ? `<div class="kv-row"><strong>Git Branch</strong><span class="v">${safe(branch)}</span></div>` : ''}
+        ${build.has_git && dirty.length ? `<div class="kv-row"><strong>Uncommitted</strong><span class="v">${dirty.length} files</span></div>` : ''}
+        ${build.grade ? `<div class="kv-row"><strong>Grade</strong><span class="v"><span style="background:${safe(build.grade.color)};color:#000;border:1px solid #000;padding:0 6px;font-family:'Archivo Black',sans-serif">${safe(build.grade.letter)}</span> · score ${build.grade.score}/100</span></div>` : ''}
+        ${pkg?.version ? `<div class="kv-row"><strong>Version</strong><span class="v">${safe(pkg.version)}</span></div>` : ''}
+        ${pkg?.scripts ? `<div class="kv-row"><strong>Scripts</strong><span class="v">${Object.keys(pkg.scripts).slice(0,8).map(s => `<code style="background:#facc15;padding:0 4px;margin:1px;display:inline-block">${safe(s)}</code>`).join(' ')}</span></div>` : ''}
+      </div>
+    </div>
+
+    ${commits.length ? `<div class="panel">
+      <div class="panel-h"><span>★ Recent Commits</span><span style="color:#fff;font-size:10px">${commits.length}</span></div>
+      <div class="panel-b">
+        ${commits.map(c => `<div class="commit">
+          <span class="commit-hash">${safe(c.hash)}</span>
+          <span class="commit-iso">${safe(c.iso.slice(0,10))} · ${fmtTime(Date.parse(c.iso))}</span>
+          <div style="margin-top:2px">${safe(c.subject)}</div>
+        </div>`).join('')}
+      </div>
+    </div>` : ''}
+
+    ${dirty.length ? `<div class="panel">
+      <div class="panel-h"><span>★ Uncommitted</span><span style="color:#fff;font-size:10px">${dirty.length}</span></div>
+      <div class="panel-b">
+        ${dirty.slice(0, 60).map(line => `<div class="dirty-line">${safe(line)}</div>`).join('')}
+        ${dirty.length > 60 ? `<div class="empty" style="margin-top:6px">… +${dirty.length - 60} more</div>` : ''}
+      </div>
+    </div>` : ''}
+
+    ${recentFiles.length ? `<div class="panel">
+      <div class="panel-h"><span>★ Recent Files</span><span style="color:#fff;font-size:10px">${recentFiles.length}</span></div>
+      <div class="panel-b"><ul>
+        ${recentFiles.map(f => `<li><span>${f.dir ? '📁 ' : '📄 '}<strong>${safe(f.name)}</strong></span><span class="meta">${fmtSize(f.size)} · ${fmtTime(f.mtime)}</span></li>`).join('')}
+      </ul></div>
+    </div>` : ''}
+
+    ${deps.length ? `<div class="panel">
+      <div class="panel-h"><span>★ Dependencies</span><span style="color:#fff;font-size:10px">${deps.length}</span></div>
+      <div class="panel-b">
+        ${deps.map(([k, v]) => `<span class="dep-tag">${safe(k)} <span class="v">${safe(v)}</span></span>`).join('')}
+      </div>
+    </div>` : ''}
+
+    ${devDeps.length ? `<div class="panel">
+      <div class="panel-h"><span>★ Dev Dependencies</span><span style="color:#fff;font-size:10px">${devDeps.length}</span></div>
+      <div class="panel-b">
+        ${devDeps.map(([k, v]) => `<span class="dep-tag">${safe(k)} <span class="v">${safe(v)}</span></span>`).join('')}
+      </div>
+    </div>` : ''}
+  </div>
+
+  ${readme ? `<div class="panel" style="margin-top:24px">
+    <div class="panel-h"><span>★ ${safe(readme.file)}</span><span style="color:#fff;font-size:10px">first 200 lines</span></div>
+    <div class="panel-b" style="padding:0"><pre class="readme">${safe(readme.body)}</pre></div>
+  </div>` : ''}
+</main>
+</body></html>`);
+});
+
+// /builds/pm2-logs?app=<name> — tails `pm2 logs <name> --lines 200 --raw`
+// once and returns the captured output. Read-only, no streaming yet.
+app.get('/builds/pm2-logs', async (req, res) => {
+  const name = String(req.query.app || '').trim();
+  if (!name || !/^[\w@.\-]+$/.test(name)) return res.status(400).send('invalid app name');
+  const { execFile } = await import('node:child_process');
+  execFile('pm2', ['logs', name, '--lines', '200', '--nostream', '--raw'], { timeout: 8000 }, (err, stdout, stderr) => {
+    res.set('content-type', 'text/html; charset=utf-8');
+    const safe = (s) => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+    res.send(`<!doctype html><html><head><meta charset="utf-8"><title>pm2 logs · ${safe(name)}</title>
+<style>body{font-family:'SF Mono',Menlo,monospace;background:#000;color:#22c55e;padding:24px;line-height:1.5;font-size:12px}h1{color:#facc15;font-family:'Archivo Black',sans-serif;font-size:24px;margin-bottom:18px}pre{white-space:pre-wrap;word-break:break-word;background:#0a0a0a;border:2px solid #facc15;padding:14px;max-height:80vh;overflow-y:auto}.err{color:#dc2626}.bar{background:#facc15;color:#000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:11px;text-transform:uppercase;letter-spacing:.04em;display:inline-block;margin-bottom:14px}.bar a{color:#000;text-decoration:underline;margin-left:14px}</style>
+</head><body>
+<h1>★ pm2 logs · ${safe(name)}</h1>
+<div class="bar">★ last 200 lines · refresh page to retail <a href="/builds">← back to builds</a></div>
+${err && !stdout ? `<div class="err">★ pm2 logs ${safe(name)} failed: ${safe(err.message)}</div>` : ''}
+<pre>${safe(stdout || '(empty)')}</pre>
+${stderr ? `<h1 style="color:#dc2626;margin-top:24px">stderr</h1><pre class="err">${safe(stderr)}</pre>` : ''}
+</body></html>`);
+  });
+});
+
+function normalizeUrl(raw) {
+  let u = String(raw || '').trim();
+  if (!u) return null;
+  if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
+  try {
+    const p = new URL(u);
+    if (p.protocol !== 'http:' && p.protocol !== 'https:') return null;
+    return p.toString();
+  } catch { return null; }
+}
+
+function normalizeIg(raw) {
+  if (!raw) return null;
+  const h = String(raw).trim().replace(/^@/, '').replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/\/$/, '');
+  if (!/^[A-Za-z0-9._]{1,30}$/.test(h)) return null;
+  return h;
+}
+
+app.post('/api/website-analysis', async (req, res) => {
+  const url = normalizeUrl(req.body?.url);
+  const igHandle = normalizeIg(req.body?.ig_handle);
+  if (!url) {
+    res.set('content-type', 'text/html; charset=utf-8');
+    return res.status(400).send(renderWAEntry({ error: 'Enter a valid website URL (e.g. yourbusiness.com)' }));
+  }
+
+  let meta;
+  try {
+    meta = await scrapeWebsiteMeta(url);
+  } catch (e) {
+    log.err('website-analysis scrape failed', { url, error: String(e?.message || e) });
+    res.set('content-type', 'text/html; charset=utf-8');
+    return res.status(400).send(renderWAEntry({ error: `Could not reach ${url}. Check the URL and try again.` }));
+  }
+
+  const vertical = detectVertical({ title: meta.title || '', description: meta.description || '', html: '' });
+  const themes = pickThemesFor(vertical);
+  const ctx = {
+    title: meta.title || meta.site_name || new URL(url).hostname,
+    tagline: meta.description || '',
+    palette: meta.palette || [],
+    hero_image: meta.hero_image || null,
+    vertical,
+    ig_handle: igHandle,
+    url,
+  };
+
+  const baseSlug = slugify(new URL(url).hostname.replace(/^www\./, '')) || 'site';
+  let slug = baseSlug;
+  let i = 0;
+  while (await one('SELECT 1 FROM website_analyses WHERE slug=$1', [slug])) {
+    i += 1; slug = `${baseSlug}-${i}`;
+  }
+
+  const summary = {
+    grade: 'pending',
+    top_3_issues: [],
+    vertical,
+    title: ctx.title,
+    tagline: ctx.tagline,
+  };
+
+  const metaBlob = {
+    iframe_blocked: !!meta.iframe_blocked,
+    palette: ctx.palette.slice(0, 16),
+    hero_image: ctx.hero_image,
+    title: ctx.title,
+    tagline: ctx.tagline,
+  };
+
+  const inserted = await one(
+    `INSERT INTO website_analyses (slug, url, ig_handle, vertical, status, summary_json, meta_json)
+     VALUES ($1,$2,$3,$4,'ready',$5::jsonb,$6::jsonb) RETURNING id, slug`,
+    [slug, url, igHandle, vertical, JSON.stringify(summary), JSON.stringify(metaBlob)]
+  );
+
+  for (let n = 0; n < themes.length; n += 1) {
+    const theme = themes[n];
+    const mockupId = `${String(n + 1).padStart(2, '0')}-${theme}`;
+    const html = renderTheme(theme, ctx);
+    await query(
+      `INSERT INTO website_analysis_mockups (analysis_id, mockup_id, title, theme, preview_html)
+       VALUES ($1,$2,$3,$4,$5)`,
+      [inserted.id, mockupId, theme.charAt(0).toUpperCase() + theme.slice(1).replace(/-/g, ' '), theme, html]
+    );
+  }
+
+  res.redirect(`/wa/${inserted.slug}`);
+});
+
+// /wall — public brutalist gallery of every audited+mockuped build (compliance
+// blocklist applied). Pulls one preview mockup_id per row so cards can render
+// a thumbnail iframe. Cached 60s.
+let _wallCache = { at: 0, key: null, html: null };
+app.get('/wall', async (req, res) => {
+  const PER_PAGE = 24;
+  const page = Math.max(1, parseInt(req.query.p || '1', 10) || 1);
+  const vertical = String(req.query.v || 'all').toLowerCase().slice(0, 32);
+  const cacheKey = `${page}:${vertical}`;
+  const now = Date.now();
+  if (_wallCache.key === cacheKey && (now - _wallCache.at) < 60_000 && _wallCache.html) {
+    res.set('content-type', 'text/html; charset=utf-8');
+    return res.send(_wallCache.html);
+  }
+  const baseFilter = `(meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'`;
+  const vFilter = vertical !== 'all' ? `AND vertical = $1` : '';
+  const params = vertical !== 'all' ? [vertical] : [];
+  const totalRow = await one(`SELECT COUNT(*)::int AS c FROM website_analyses WHERE ${baseFilter} ${vFilter}`, params);
+  const rows = await many(`
+    SELECT a.id, a.slug, a.vertical, a.summary_json, a.meta_json,
+           (SELECT mockup_id FROM website_analysis_mockups m
+              WHERE m.analysis_id = a.id
+              ORDER BY CASE WHEN m.theme = 'brutalist' THEN 0
+                            WHEN m.theme = 'editorial' THEN 1
+                            ELSE 2 END, m.mockup_id LIMIT 1) AS preview_mockup_id
+    FROM website_analyses a
+    WHERE ${baseFilter} ${vFilter}
+    ORDER BY (summary_json ? 'live_audit') DESC NULLS LAST,
+             (summary_json ? 'roast') DESC NULLS LAST,
+             (summary_json->'live_audit'->'grade'->>'score')::int DESC NULLS LAST,
+             a.id DESC
+    OFFSET $${params.length + 1} LIMIT $${params.length + 2}
+  `, [...params, (page - 1) * PER_PAGE, PER_PAGE]);
+  // Verticals for filter chips — whatever shows up in the corpus.
+  const verticalsRows = await many(`
+    SELECT vertical, COUNT(*)::int c FROM website_analyses
+    WHERE ${baseFilter} AND vertical IS NOT NULL AND vertical <> ''
+    GROUP BY vertical ORDER BY c DESC LIMIT 12`);
+  const verticals = verticalsRows.filter(r => r.c >= 3).map(r => r.vertical);
+  // Marquee — most recent 8 roasts.
+  const marqueeRows = await many(`
+    SELECT summary_json->'roast'->>'text' AS text
+    FROM website_analyses WHERE summary_json ? 'roast' AND ${baseFilter}
+    ORDER BY (summary_json->'roast'->>'generated_at') DESC NULLS LAST LIMIT 8`);
+  // Aggregate stats banner — single roundtrip.
+  const statsRow = await one(`
+    SELECT
+      COUNT(*)::int AS total,
+      COUNT(*) FILTER (WHERE summary_json ? 'live_audit')::int AS graded,
+      COUNT(*) FILTER (WHERE summary_json ? 'roast')::int AS roasted,
+      COALESCE(ROUND(AVG((summary_json->'live_audit'->'grade'->>'score')::numeric)
+        FILTER (WHERE summary_json ? 'live_audit')), 0)::int AS avg_score
+    FROM website_analyses
+    WHERE ${baseFilter}`);
+  const mockupCountRow = await one(`
+    SELECT COUNT(*)::int AS c
+    FROM website_analysis_mockups m
+    JOIN website_analyses a ON a.id = m.analysis_id
+    WHERE (a.meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'`);
+  const avgScore = statsRow.avg_score;
+  const avgGrade = avgScore >= 80 ? 'A' : avgScore >= 65 ? 'B' : avgScore >= 45 ? 'C' : avgScore >= 25 ? 'D' : 'F';
+  const stats = {
+    total: statsRow.total,
+    graded: statsRow.graded,
+    roasted: statsRow.roasted,
+    mockups: mockupCountRow.c,
+    avgGrade: `${avgGrade} ${avgScore}`,
+  };
+  const html = renderWall({
+    rows, page, perPage: PER_PAGE, totalCount: totalRow.c,
+    vertical, verticals, roastsForMarquee: marqueeRows.filter(r => r.text),
+    stats,
+  });
+  _wallCache = { at: now, key: cacheKey, html };
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(html);
+});
+
+// /grades — public top-12 rails. Pure SQL, brutalist render. 60s cache.
+// (Originally mounted as /leaderboard but the LA Salon Directory took that path
+// at line 1256, and Express picks first-match.)
+let _lbCache = { at: 0, html: null };
+app.get('/grades', async (_req, res) => {
+  const now = Date.now();
+  if (_lbCache.html && (now - _lbCache.at) < 60_000) {
+    res.set('content-type', 'text/html; charset=utf-8');
+    return res.send(_lbCache.html);
+  }
+  const baseFilter = `(meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'`;
+  const topGraded = await many(`
+    SELECT slug, vertical,
+           summary_json->'live_audit'->'grade'->>'letter' AS letter,
+           (summary_json->'live_audit'->'grade'->>'score')::int AS score,
+           summary_json->'copy_pack'->>'headline' AS headline
+    FROM website_analyses WHERE summary_json ? 'live_audit' AND ${baseFilter}
+    ORDER BY (summary_json->'live_audit'->'grade'->>'score')::int DESC LIMIT 12`);
+  const recentRoasts = await many(`
+    SELECT slug, vertical,
+           summary_json->'roast'->>'text' AS roast,
+           summary_json->'copy_pack'->>'headline' AS headline,
+           summary_json->'roast'->>'generated_at' AS at
+    FROM website_analyses WHERE summary_json ? 'roast' AND ${baseFilter}
+    ORDER BY (summary_json->'roast'->>'generated_at') DESC LIMIT 12`);
+  const verticals = await many(`
+    SELECT vertical, COUNT(*)::int n,
+           ROUND(AVG((summary_json->'live_audit'->'grade'->>'score')::numeric))::int AS avg_score
+    FROM website_analyses
+    WHERE summary_json ? 'live_audit' AND ${baseFilter} AND vertical IS NOT NULL
+    GROUP BY vertical ORDER BY avg_score DESC NULLS LAST`);
+
+  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+  const gradeColor = (s) => s>=80?'#22c55e':s>=65?'#84cc16':s>=45?'#facc15':s>=25?'#f97316':'#dc2626';
+  const html = `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>★ THE LEADERBOARD · TOP-GRADED + MOST-ROASTED</title>
+<meta name="description" content="Top-12 rails — best-graded sites, freshest qwen3 roasts, vertical averages.">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Mono:wght@400;700&display=swap">
+<style>
+  :root{--bg:#fafaf6;--ink:#111;--paper:#fff;--accent:#facc15;--muted:#666}
+  [data-theme="dark"]{--bg:#0a0a08;--ink:#fafaf6;--paper:#1a1a18;--accent:#facc15;--muted:#999}
+  *{box-sizing:border-box} body{margin:0;font-family:'Space Mono',monospace;background:var(--bg);color:var(--ink);transition:background .15s,color .15s}
+  body .row,body .roast-row,body .vert-row{background:var(--paper);color:var(--ink)}
+  .theme-toggle{background:var(--paper);color:var(--ink);border:3px solid var(--ink);padding:8px 12px;font-family:'Archivo Black',sans-serif;font-size:12px;cursor:pointer;text-transform:uppercase;letter-spacing:.04em}
+  .theme-toggle:hover{background:var(--accent);color:#000}
+  body .rail h2{color:var(--ink)}
+  a{color:inherit}
+  h1,h2,h3{font-family:'Archivo Black',sans-serif;letter-spacing:-.02em;text-transform:uppercase;margin:0}
+  .container{max-width:1320px;margin:0 auto;padding:0 24px}
+  .nav{display:flex;justify-content:space-between;align-items:center;padding:24px;border-bottom:4px solid #000}
+  .nav a{text-decoration:none;font-family:'Archivo Black',sans-serif;text-transform:uppercase;letter-spacing:.04em}
+  .hero{padding:64px 24px;background:#000;color:#facc15;border-bottom:4px solid #facc15}
+  .hero h1{font-size:clamp(48px,8vw,96px);line-height:.9;color:#fafaf6}
+  .hero h1 mark{background:#facc15;color:#000;padding:0 .15em}
+  .rail{padding:48px 0;border-bottom:4px solid #000}
+  .rail h2{font-size:36px;color:#000;margin-bottom:18px}
+  .rail .sub{font-size:12px;font-weight:700;color:#666;letter-spacing:.06em;text-transform:uppercase;margin-bottom:24px}
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
+  .row{display:flex;align-items:center;gap:14px;border:3px solid #000;background:#fff;padding:12px 16px;text-decoration:none;color:#000;transition:transform .08s}
+  .row:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 0 #000}
+  .rank{font-family:'Archivo Black',sans-serif;font-size:24px;color:#facc15;-webkit-text-stroke:1.5px #000;width:36px;text-align:center}
+  .badge{font-family:'Archivo Black',sans-serif;font-size:18px;width:42px;height:42px;display:flex;align-items:center;justify-content:center;border:3px solid #000;flex-shrink:0}
+  .info{flex:1;min-width:0}
+  .name{font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:-.01em;line-height:1.1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .meta{font-size:11px;color:#666;font-weight:700;letter-spacing:.04em;text-transform:uppercase;margin-top:3px}
+  .roast-row{display:block;border:3px solid #000;background:#fff;padding:14px 18px;text-decoration:none;color:#000;transition:transform .08s}
+  .roast-row:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 0 #000}
+  .roast-row .name{margin-bottom:6px}
+  .roast-row .text{font-size:13px;line-height:1.45;font-style:italic;color:#222;border-left:3px solid #facc15;padding-left:10px}
+  .vert-row{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border:3px solid #000;background:#fff}
+  .vert-row .v{font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;letter-spacing:-.01em}
+  .vert-row .s{font-family:'Space Mono',monospace;font-size:13px;font-weight:700;color:#666}
+</style>
+</head><body>
+<script>(function(){try{var t=localStorage.getItem('lsa-theme');if(t==='dark')document.documentElement.setAttribute('data-theme','dark')}catch(e){}})()</script>
+<nav class="nav container">
+  <a href="/" style="font-size:18px">★ LIVE SITE AUDIT</a>
+  <div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center">
+    <a href="/wall" style="font-size:13px">★ THE WALL</a>
+    <button class="theme-toggle" id="theme-toggle" title="dark/light">★ <span id="theme-icon">☾</span></button>
+    <a href="/website-analysis" style="font-size:13px;background:#facc15;border:3px solid #000;padding:8px 14px">★ AUDIT YOUR SITE</a>
+  </div>
+</nav>
+<script>(function(){const tt=document.getElementById('theme-toggle'),ic=document.getElementById('theme-icon');function r(){const d=document.documentElement.getAttribute('data-theme')==='dark';if(ic)ic.textContent=d?'☀':'☾'}r();tt?.addEventListener('click',()=>{const d=document.documentElement.getAttribute('data-theme')==='dark';if(d){document.documentElement.removeAttribute('data-theme');localStorage.setItem('lsa-theme','light')}else{document.documentElement.setAttribute('data-theme','dark');localStorage.setItem('lsa-theme','dark')}r()})})()</script>
+<header class="hero"><div class="container"><h1>THE<br><mark>LEADERBOARD.</mark></h1><p style="margin-top:18px;font-size:16px;font-weight:700;color:#fafaf6;text-transform:uppercase;letter-spacing:.04em;max-width:60ch">Best-graded sites. Freshest roasts. Vertical averages. Updated every 60 seconds.</p></div></header>
+<section class="rail container"><h2>★ TOP 12 — LIVE-AUDIT GRADE</h2><div class="sub">By cheerio-fetched HTML signals · score / 100</div><div class="grid">${
+  topGraded.map((r, i) => `<a class="row" href="/wa/${esc(r.slug)}" target="_blank" rel="noopener"><div class="rank">${i + 1}</div><div class="badge" style="background:${gradeColor(r.score)};">${esc(r.letter)}</div><div class="info"><div class="name">${esc((r.headline || r.slug).slice(0, 48))}</div><div class="meta">${esc(r.vertical || 'project')} · ${r.score}/100</div></div></a>`).join('')
+}</div></section>
+<section class="rail container"><h2>★ FRESHEST ROASTS</h2><div class="sub">Latest qwen3:14b stand-up takes · running locally on Mac1</div><div class="grid">${
+  recentRoasts.map(r => `<a class="roast-row" href="/wa/${esc(r.slug)}" target="_blank" rel="noopener"><div class="name">${esc((r.headline || r.slug).slice(0, 48))}</div><div class="text">${esc((r.roast || '').slice(0, 200))}</div></a>`).join('')
+}</div></section>
+${verticals.length ? `<section class="rail container"><h2>★ AVG GRADE BY VERTICAL</h2><div class="sub">Where small businesses actually score</div><div class="grid">${
+  verticals.map(v => `<div class="vert-row"><div class="v">${esc(v.vertical || '?')}</div><div class="s"><span style="background:${gradeColor(v.avg_score)};padding:4px 8px;border:2px solid #000;color:#000">${v.avg_score}/100</span> · ${v.n}</div></div>`).join('')
+}</div></section>` : ''}
+<footer style="border-top:4px solid #000;background:#000;color:#fafaf6;padding:48px 24px"><div class="container" style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:18px"><div style="font-family:'Archivo Black',sans-serif;font-size:24px;color:#facc15">★ LIVE SITE AUDIT</div><div style="display:flex;gap:18px;flex-wrap:wrap"><a href="/wall" style="color:#fafaf6;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ THE WALL</a><a href="/grades" style="color:#facc15;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ LEADERBOARD</a><a href="/website-analysis" style="color:#fafaf6;text-decoration:none;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;letter-spacing:.04em">★ AUDIT YOUR SITE</a></div></div></footer>
+</body></html>`;
+  _lbCache = { at: now, html };
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(html);
+});
+
+// /roasts.rss — public RSS feed of latest qwen3 roasts (compliance-filtered).
+app.get('/roasts.rss', async (_req, res) => {
+  const rows = await many(`
+    SELECT slug, summary_json->'roast'->>'text' AS text,
+           summary_json->'roast'->>'generated_at' AS generated_at,
+           summary_json->'copy_pack'->>'headline' AS headline
+    FROM website_analyses
+    WHERE summary_json ? 'roast'
+      AND (meta_json->>'compliance_sensitive') IS DISTINCT FROM 'true'
+    ORDER BY (summary_json->'roast'->>'generated_at') DESC NULLS LAST
+    LIMIT 50`);
+  const xmlEsc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;' }[c]));
+  const items = rows.filter(r => r.text).map(r => `
+    <item>
+      <title>${xmlEsc(`★ Roast: ${r.headline || r.slug}`)}</title>
+      <link>https://livesiteaudit.agentabrams.com/wa/${xmlEsc(r.slug)}</link>
+      <guid isPermaLink="false">roast-${xmlEsc(r.slug)}-${xmlEsc(r.generated_at || '')}</guid>
+      <pubDate>${new Date(r.generated_at || Date.now()).toUTCString()}</pubDate>
+      <description>${xmlEsc(r.text)}</description>
+    </item>`).join('');
+  const xml = `<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0"><channel>
+  <title>Live Site Audit · Roasts</title>
+  <link>https://livesiteaudit.agentabrams.com/wall</link>
+  <description>Daily savage-but-affectionate website roasts, generated by qwen3:14b running locally on Mac1.</description>
+  <language>en-us</language>
+  <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
+</channel></rss>`;
+  res.set('content-type', 'application/rss+xml; charset=utf-8');
+  res.send(xml);
+});
+
+// /api/roast-me — POST {url} → qwen3 roasts the user's site on the spot.
+// Public path. Rate-limited per IP (5/min) so qwen3 isn't drowned.
+const _roastRate = new Map();   // ip → [ts, ts, …]
+app.post('/api/roast-me', express.json({ limit: '4kb' }), async (req, res) => {
+  const ip = (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString().split(',')[0].trim();
+  const now = Date.now();
+  const recent = (_roastRate.get(ip) || []).filter(t => now - t < 60_000);
+  if (recent.length >= 5) return res.status(429).json({ error: 'whoa, slow down — try again in a minute' });
+  recent.push(now);
+  _roastRate.set(ip, recent);
+
+  const url = String(req.body?.url || '').trim().slice(0, 240);
+  if (!/^https?:\/\/[^\s]+$/i.test(url)) return res.status(400).json({ error: 'need a real http(s) URL' });
+  let host;
+  try { host = new URL(url).hostname; } catch { return res.status(400).json({ error: 'unparseable URL' }); }
+
+  const SYS = `You are a stand-up comic roasting bad website design. SAVAGE BUT AFFECTIONATE. One paragraph, 2-4 sentences, max 60 words. Specific to the site, never generic. /no_think — output the roast directly with no preamble.`;
+  const prompt = `/no_think\nROAST TARGET: ${host}\nFULL URL: ${url}\n\nWrite the roast. Just the paragraph, no quotes, no preamble.`;
+
+  // Heartbeat the response immediately so nginx (and any reverse proxy with a
+  // ~30s read timeout) sees byte flow well before qwen3 finishes — Mac1 has
+  // OLLAMA_MAX_LOADED_MODELS=1 so a cold load + queue can take 30-60s.
+  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+  const heartbeat = setInterval(() => { try { res.write(' '); } catch {} }, 8_000);
+
+  try {
+    const r = await fetch('http://192.168.1.133:11434/api/generate', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      signal: AbortSignal.timeout(90_000),
+      body: JSON.stringify({
+        model: 'qwen3:14b', system: SYS, prompt, stream: false,
+        options: { temperature: 0.95, num_predict: 250 },
+      }),
+    });
+    if (!r.ok) throw new Error(`ollama ${r.status}`);
+    const data = await r.json();
+    let txt = (data.response || '').trim();
+    txt = txt.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+    txt = txt.replace(/^["']|["']$/g, '');
+    txt = txt.replace(/^(here'?s|here is|roast:?|sure|okay|alright)[^.]*[:.]?\s*/i, '').trim();
+    if (txt.length < 20) throw new Error('empty roast');
+    clearInterval(heartbeat);
+    res.end(JSON.stringify({ roast: txt, host }));
+  } catch (e) {
+    clearInterval(heartbeat);
+    res.end(JSON.stringify({ error: 'qwen3 hung up on us — try again in a sec' }));
+  }
+});
+
+app.get('/wa/:slug', async (req, res) => {
+  const analysis = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
+  if (!analysis) return res.status(404).send('not found');
+  const mockups = await many(
+    'SELECT mockup_id, title, theme FROM website_analysis_mockups WHERE analysis_id=$1 ORDER BY mockup_id',
+    [analysis.id]
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderWAResult({ analysis, mockups }));
+});
+
+// /wa/:slug/screenshot.png — Playwright-captured, disk-cached screenshot of the
+// analyzed URL. Used as a visual fallback when the live site blocks iframe embeds.
+// Steve's rule: "we must ALWAYS see the screen — if iframe does not work, screenshot."
+app.get('/wa/:slug/screenshot.png', async (req, res) => {
+  const slug = String(req.params.slug || '').slice(0, 96);
+  if (!/^[a-z0-9-]+$/i.test(slug)) return res.status(400).send('bad slug');
+  const SHOT_DIR = path.resolve('data/wa-screenshots');
+  await fsp.mkdir(SHOT_DIR, { recursive: true });
+  const cached = path.join(SHOT_DIR, `${slug}.png`);
+  // Serve cached if <7 days old
+  try {
+    const st = await fsp.stat(cached);
+    if (Date.now() - st.mtimeMs < 7 * 24 * 60 * 60 * 1000) {
+      res.set('Cache-Control', 'public, max-age=86400');
+      return res.sendFile(cached);
+    }
+  } catch {}
+  const row = await one('SELECT url FROM website_analyses WHERE slug=$1', [slug]);
+  if (!row) return res.status(404).send('not found');
+  let browser;
+  try {
+    const { chromium } = await import('playwright-core');
+    // chromium executablePath: prefer system Chrome/Edge if available
+    const candidates = [
+      '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+      '/Applications/Chromium.app/Contents/MacOS/Chromium',
+      '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
+      '/usr/bin/chromium',
+      '/usr/bin/google-chrome',
+    ];
+    let executablePath;
+    for (const p of candidates) { try { await fsp.access(p); executablePath = p; break; } catch {} }
+    browser = await chromium.launch({ headless: true, executablePath });
+    const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
+    const page = await ctx.newPage();
+    await page.goto(row.url, { waitUntil: 'domcontentloaded', timeout: 20000 });
+    await page.waitForTimeout(1500);
+    await page.screenshot({ path: cached, type: 'png', fullPage: false, animations: 'disabled' });
+    await browser.close();
+    res.set('Cache-Control', 'public, max-age=86400');
+    return res.sendFile(cached);
+  } catch (e) {
+    if (browser) try { await browser.close(); } catch {}
+    log.warn('screenshot capture failed', { slug, url: row.url, err: String(e?.message || e) });
+    return res.status(502).send('screenshot capture failed');
+  }
+});
+
+app.get('/wa/:slug/m/:mockup', async (req, res) => {
+  const analysis = await one('SELECT id FROM website_analyses WHERE slug=$1', [req.params.slug]);
+  if (!analysis) return res.status(404).send('not found');
+  const m = await one(
+    'SELECT preview_html FROM website_analysis_mockups WHERE analysis_id=$1 AND mockup_id=$2',
+    [analysis.id, req.params.mockup]
+  );
+  if (!m) return res.status(404).send('not found');
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(m.preview_html);
+});
+
+// "Ship this rebuild" — convert a website-analysis row + chosen mockup into a
+// Deep-dive — synthesize a mini site-audit report from existing analysis row,
+// store under summary_json.audit_report, then redirect back to /wa/:slug.
+// Pure local synthesis: no scraping, no LLM. ~50ms per call.
+import { runDeepDive } from '../lib/deep-dive.js';
+
+// Bulk deep-dive — runs synthesis on every analysis in DB. Idempotent.
+// Skips records already containing a recent (≤7d) audit_report unless ?force=1.
+app.post('/api/builds/run-all-deep-dives', async (req, res) => {
+  const force = req.query.force === '1' || req.body?.force === '1';
+  const all = await many('SELECT id, slug, summary_json, meta_json, ig_handle, vertical FROM website_analyses');
+  let ran = 0, skipped = 0;
+  for (const a of all) {
+    const existing = a.summary_json?.audit_report;
+    if (!force && existing?.generated_at) {
+      const ageDays = (Date.now() - Date.parse(existing.generated_at)) / 86400000;
+      if (ageDays < 7) { skipped += 1; continue; }
+    }
+    const report = runDeepDive(a);
+    const newSummary = { ...(a.summary_json || {}), audit_report: report };
+    await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
+                [JSON.stringify(newSummary), a.id]);
+    ran += 1;
+  }
+  _buildsCache = { at: 0, rows: null };
+  log.info('bulk deep-dive complete', { ran, skipped, total: all.length });
+  res.redirect('/builds');
+});
+
+// CSV export — every build row + grade + audit_score, downloadable.
+app.get('/api/builds/export.csv', async (_req, res) => {
+  const ana = await many('SELECT slug, vertical, summary_json, meta_json, ig_handle FROM website_analyses');
+  const set = new Set(ana.map(r => r.slug));
+  const baseRows = listBuilds({ analysisSlugs: set });
+  const anaBySlug = new Map(ana.map(r => [r.slug, r]));
+  const grades = new Map();
+  for (const b of baseRows) {
+    const a = lookupAna(b, anaBySlug) || null;
+    const g = computeGrade({ analysis: a, build: b });
+    const ar = a?.summary_json?.audit_report;
+    grades.set(b.slug, { ...g, vertical: a?.vertical || null, audit_score: ar?.score ?? null });
+  }
+  const rows = listBuilds({ analysisSlugs: set, grades });
+  const escCsv = (v) => {
+    const s = String(v == null ? '' : v).replace(/"/g, '""');
+    return /[",\n]/.test(s) ? `"${s}"` : s;
+  };
+  const headers = ['name','category','grade','grade_score','audit_score','vertical','last_at','pm2_status','pm2_port','public_url','has_git','description'];
+  const lines = [headers.join(',')];
+  for (const r of rows) {
+    lines.push([
+      r.name, r.category, r.grade?.letter || '', r.grade?.score || '',
+      r.audit_score ?? '', r.grade?.vertical || '', r.last_at || '',
+      r.pm2?.status || '', r.pm2?.port || '', r.url || '',
+      r.has_git ? 'yes' : '', (r.description || '').slice(0, 480),
+    ].map(escCsv).join(','));
+  }
+  res.set('content-type', 'text/csv; charset=utf-8');
+  res.set('content-disposition', `attachment; filename="builds-${new Date().toISOString().slice(0,10)}.csv"`);
+  res.send(lines.join('\n'));
+});
+
+// REBUILD MOCKUPS — runs build-all-analyses.mjs --rebuild, streams output.
+import { spawn as _spawn } from 'node:child_process';
+app.post('/api/builds/rebuild-mockups', (_req, res) => {
+  const child = _spawn('node', ['scripts/build-all-analyses.mjs', '--rebuild'], {
+    cwd: path.resolve(__dirname, '..', '..'),
+    env: process.env,
+  });
+  res.set('content-type', 'text/plain; charset=utf-8');
+  res.set('refresh', '60; url=/builds');
+  res.write('★ REBUILDING MOCKUPS — this page reloads /builds in 60s\n\n');
+  child.stdout.on('data', d => res.write(d));
+  child.stderr.on('data', d => res.write(d));
+  child.on('close', code => {
+    res.write(`\n\n★ DONE (exit ${code}). Reloading /builds...\n`);
+    res.end();
+    _buildsCache = { at: 0, rows: null };
+    log.info('rebuild-mockups complete', { exit: code });
+  });
+});
+
+// FIX VISIBLE — force-rerun deep-dive on a comma-separated list of slugs.
+// Always overrides the 7-day skip. Bounded by 200 slugs to prevent runaway.
+app.post('/api/builds/fix-visible', async (req, res) => {
+  const raw = (req.body?.slugs || req.query?.slugs || '').toString();
+  const slugs = raw.split(',').map(s => s.trim()).filter(Boolean).slice(0, 200);
+  if (!slugs.length) return res.status(400).send('no slugs provided');
+  const all = await many(
+    `SELECT id, slug, summary_json, meta_json, ig_handle, vertical
+       FROM website_analyses
+      WHERE slug = ANY($1::text[])`,
+    [slugs]
+  );
+  let ran = 0;
+  for (const a of all) {
+    const report = runDeepDive(a);
+    const newSummary = { ...(a.summary_json || {}), audit_report: report };
+    await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
+                [JSON.stringify(newSummary), a.id]);
+    ran += 1;
+  }
+  _buildsCache = { at: 0, rows: null };
+  log.info('fix-visible ran', { requested: slugs.length, ran });
+  res.redirect('/builds');
+});
+
+// Lightweight peek fragment for the drawer — small + fast.
+app.get('/api/wa/:slug/peek', async (req, res) => {
+  const a = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
+  if (!a) return res.status(404).send('not found');
+  const ar = a.summary_json?.audit_report;
+  const meta = a.meta_json || {};
+  const sum = a.summary_json || {};
+  const title = sum.title || meta.title || a.slug;
+  const tagline = sum.tagline || meta.tagline || '';
+  const ig = a.ig_handle ? '@' + a.ig_handle : '';
+  const body = `
+<div style="font-family:'Space Mono',monospace">
+  <div style="font-size:11px;font-weight:700;color:#666;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">★ ${esc(a.vertical || 'generic')}</div>
+  <h2 style="font-family:'Archivo Black',sans-serif;font-size:32px;line-height:1;letter-spacing:-.02em;text-transform:uppercase;margin-bottom:14px">${esc(title)}</h2>
+  ${tagline ? `<p style="font-size:14px;line-height:1.5;color:#333;margin-bottom:14px">${esc(tagline)}</p>` : ''}
+  ${ig ? `<div style="font-size:12px;font-weight:700;margin-bottom:14px">★ ${esc(ig)}</div>` : ''}
+  <div style="display:flex;gap:10px;margin:14px 0;align-items:center;flex-wrap:wrap">
+    ${ar ? `<div style="background:${ar.score>=70?'#22c55e':ar.score>=50?'#84cc16':ar.score>=30?'#facc15':'#dc2626'};color:#000;border:3px solid #000;padding:8px 14px;display:flex;gap:10px;align-items:center"><div style="font-family:'Archivo Black',sans-serif;font-size:36px;line-height:1">${ar.score}</div><div style="font-size:10px;font-weight:700">/100<br>★ AUDIT</div></div>` : ''}
+    <button data-star="${esc(a.slug)}" type="button" style="background:#fff;color:#000;border:3px solid #000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;cursor:pointer">★ STAR</button>
+    <a href="/wa/${esc(a.slug)}" target="_blank" style="background:#000;color:#facc15;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;text-decoration:none;border:3px solid #000">★ FULL ↗</a>
+    <form method="post" action="/api/wa/${esc(a.slug)}/deep-dive" style="display:inline;margin:0">
+      <button type="submit" style="background:#facc15;color:#000;border:3px solid #000;padding:8px 14px;font-family:'Archivo Black',sans-serif;font-size:13px;text-transform:uppercase;cursor:pointer">★ DEEP-DIVE</button>
+    </form>
+  </div>
+  ${ar?.quick_wins?.length ? `<div style="background:#dc2626;color:#fff;padding:14px;border:3px solid #000;margin-top:14px">
+    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-bottom:10px">★ ${ar.quick_wins.length} QUICK WINS</div>
+    <ul style="list-style:none;font-size:12px;line-height:1.5">${ar.quick_wins.map(q => `<li style="padding:4px 0;border-bottom:1px solid #fff8">→ ${esc(q)}</li>`).join('')}</ul>
+  </div>` : '<div style="color:#666;font-size:12px;margin-top:14px">★ No deep-dive run yet — click DEEP-DIVE above.</div>'}
+  ${ar?.suggested_headlines?.length ? `<div style="background:#facc15;color:#000;padding:14px;border:3px solid #000;margin-top:14px">
+    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-bottom:10px">★ HEADLINES TO TRY</div>
+    ${ar.suggested_headlines.map((h, i) => `<div style="padding:6px 0;border-bottom:2px solid #000;font-size:13px;font-weight:700"><span style="font-family:'Archivo Black',sans-serif;margin-right:6px">${i+1}.</span>${esc(h)}</div>`).join('')}
+  </div>` : ''}
+  ${ar?.vertical_tips?.length ? `<div style="background:#fff;color:#000;padding:14px;border:3px solid #000;margin-top:14px">
+    <div style="font-family:'Archivo Black',sans-serif;font-size:14px;text-transform:uppercase;margin-bottom:10px">★ VERTICAL TIPS</div>
+    ${ar.vertical_tips.map((t, i) => `<div style="padding:6px 0;border-bottom:2px solid #000;font-size:12px;line-height:1.5"><span style="font-family:'Archivo Black',sans-serif;margin-right:6px">${String.fromCharCode(65+i)}.</span>${esc(t)}</div>`).join('')}
+  </div>` : ''}
+</div>`;
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(body);
+});
+
+app.post('/api/wa/:slug/deep-dive', async (req, res) => {
+  const a = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
+  if (!a) return res.status(404).send('not found');
+  const report = runDeepDive(a);
+  const newSummary = { ...(a.summary_json || {}), audit_report: report };
+  await query('UPDATE website_analyses SET summary_json=$1::jsonb, updated_at=NOW() WHERE id=$2',
+              [JSON.stringify(newSummary), a.id]);
+  log.info('deep-dive ran', { slug: a.slug, score: report.score });
+  res.redirect(`/wa/${a.slug}#audit-report`);
+});
+
+// real businesses row + business_mockups row, then redirect to the smb-builder
+// edit flow so the user can keep iterating on the picked direction.
+app.post('/api/wa/:slug/ship/:mockup', async (req, res) => {
+  const analysis = await one('SELECT * FROM website_analyses WHERE slug=$1', [req.params.slug]);
+  if (!analysis) return res.status(404).send('analysis not found');
+  const m = await one(
+    'SELECT mockup_id, title, theme, preview_html FROM website_analysis_mockups WHERE analysis_id=$1 AND mockup_id=$2',
+    [analysis.id, req.params.mockup]
+  );
+  if (!m) return res.status(404).send('mockup not found');
+
+  // If we already shipped this analysis to a business, route them to that biz.
+  if (analysis.business_id) {
+    const existing = await one('SELECT slug FROM businesses WHERE id=$1', [analysis.business_id]);
+    if (existing) return res.redirect(`/biz/${existing.slug}/edit`);
+  }
+
+  const meta = analysis.meta_json || {};
+  const baseName = meta.title || (new URL(analysis.url)).hostname.replace(/^www\./, '');
+  const bSlug = await uniqueSlug(baseName);
+
+  const created = await one(
+    `INSERT INTO businesses (slug, name, category, website, hero_image_url, color_primary, color_accent, about_text, source_data_json)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)
+     RETURNING id, slug`,
+    [
+      bSlug, baseName, analysis.vertical || 'generic', analysis.url,
+      meta.hero_image || null,
+      (meta.palette && meta.palette[0]) || null,
+      (meta.palette && meta.palette[1]) || null,
+      meta.tagline || null,
+      JSON.stringify({ from_website_analysis: analysis.slug, picked_mockup: m.mockup_id, picked_theme: m.theme }),
+    ]
+  );
+
+  // Record the chosen mockup HTML as template_id 1 (default profile template)
+  // so /biz/:slug/edit lights up immediately.
+  await query(
+    `INSERT INTO business_mockups (business_id, template_id, preview_url, full_html)
+     VALUES ($1, 1, $2, $3)
+     ON CONFLICT (business_id, template_id)
+     DO UPDATE SET preview_url=EXCLUDED.preview_url, full_html=EXCLUDED.full_html, generated_at=NOW()`,
+    [created.id, `/wa/${analysis.slug}/m/${m.mockup_id}`, m.preview_html]
+  );
+
+  await query(
+    'UPDATE website_analyses SET selected_mockup_id=$1, business_id=$2, updated_at=NOW() WHERE id=$3',
+    [m.mockup_id, created.id, analysis.id]
+  );
+
+  log.info('website-analysis shipped', { analysis_slug: analysis.slug, biz_slug: created.slug, theme: m.theme });
+  res.redirect(`/biz/${created.slug}/edit`);
+});
+
+// --- /map + /api/map.json ---------------------------------------------------
+
+// In-memory cache for map data (static-ish dataset, reuse for 60s)
+let _mapCache = { at: 0, data: null };
+
+app.get('/api/map.json', async (_req, res) => {
+  try {
+    const now = Date.now();
+    if (!_mapCache.data || (now - _mapCache.at) > 60_000) {
+      const rows = await many(
+        `SELECT slug, name, category, address, city,
+                latitude::float AS lat, longitude::float AS lng,
+                source_data_json->>'source' AS src,
+                source_data_json->>'license_number' AS lic_num,
+                phone
+         FROM businesses
+         WHERE latitude IS NOT NULL
+         ORDER BY category, name
+         LIMIT 15000`
+      );
+      _mapCache.data = rows.map(r => ({
+        slug:     r.slug     || null,
+        name:     r.name     || '',
+        category: r.category || '',
+        address:  r.address  || '',
+        city:     r.city     || '',
+        lat:      r.lat,
+        lng:      r.lng,
+        phone:    r.phone    || null,
+        lic:      r.src === 'dca_bbcb' ? (r.lic_num || null) : null,
+      }));
+      _mapCache.at = now;
+    }
+    res.set('content-type', 'application/json; charset=utf-8');
+    res.set('cache-control', 'public, max-age=60');
+    res.send(JSON.stringify(_mapCache.data));
+  } catch (e) {
+    fail(res, '/api/map.json', e);
+  }
+});
+
+app.get('/map', async (_req, res) => {
+  try {
+    const stats = await one(
+      `SELECT
+         COUNT(*)                                           AS geocoded,
+         COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb') AS licensed,
+         COUNT(DISTINCT city) FILTER (WHERE city IS NOT NULL AND city <> '') AS cities
+       FROM businesses
+       WHERE latitude IS NOT NULL`
+    );
+    res.set('content-type', 'text/html; charset=utf-8');
+    res.send(renderMap({
+      stats: {
+        geocoded: parseInt(stats.geocoded, 10) || 0,
+        licensed: parseInt(stats.licensed, 10) || 0,
+        cities:   parseInt(stats.cities,   10) || 0,
+      },
+    }));
+  } catch (e) {
+    log.err('/map failed', { error: String(e?.stack || e?.message || e) });
+    res.status(500).send('Internal error');
+  }
+});
+
 // --- admin ------------------------------------------------------------------
 
 app.get('/admin', async (req, res) => {

← 783c9b7 qa click-through: log session-minutes to cost-tracker via sh  ·  back to Small Business Builder  ·  fix: add rel="noopener noreferrer" to target=_blank links mi 771aaa9 →