[object Object]

← back to Lawyer Directory Builder

Counsel & Bar visual loop: 21 iterations of Aston Martin DB12 / Genesis G80 redesign

ade90a7de3324285169a85a9c7074b81079e7071 · 2026-05-03 23:14:07 -0700 · SteveStudio2

Surfaces redesigned with cinematic noir + champagne-metal aesthetic:
- Landing (/) — italic Roman watermark, proof strip with real CA firms, scroll-reveal stagger
- /find-a-lawyer — 3-section configurator with outlined italic Romans (i/ii/iii)
- /find-a-lawyer/thanks — Volume strip + Inquiry № receipt with print stylesheet
- /find-a-lawyer/results — proximity-ranked firm cards + reason-branched empty state
- /signup — Volume strip + outlined italic Romans replacing Step 01-04
- /login + /dashboard — matching Volume strip masthead + italic-metal display
- /data — champagne-tier compliance callout (replaced amber-warning aesthetic)
- /audit + /privacy + /terms — monogram + Volume strip + italic-metal display

Brand tokens: --noir #0a0a0c, --ink #f4f1ea, --metal #b89968 (champagne),
brushed-metal CTA gradient, Cormorant Garamond italic + Inter sans pairing.

Compliance: §6155 directory-not-referral language, Rule 7.x no-fabricated-stats,
trust block conditional on populated vs empty results.

A11y: scroll-reveal honors prefers-reduced-motion, :focus-visible champagne ring,
@media print stylesheet on /thanks for receipt printing.

Iter 21 specifically: editorial empty state on /results with reason enum
(no_anchor / no_firms_in_radius / ok / no_zip) — replaces misleading LA-fallback
'near {ZIP}' claim that displayed downtown LA firms for unknown ZIPs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ade90a7de3324285169a85a9c7074b81079e7071
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Sun May 3 23:14:07 2026 -0700

    Counsel & Bar visual loop: 21 iterations of Aston Martin DB12 / Genesis G80 redesign
    
    Surfaces redesigned with cinematic noir + champagne-metal aesthetic:
    - Landing (/) — italic Roman watermark, proof strip with real CA firms, scroll-reveal stagger
    - /find-a-lawyer — 3-section configurator with outlined italic Romans (i/ii/iii)
    - /find-a-lawyer/thanks — Volume strip + Inquiry № receipt with print stylesheet
    - /find-a-lawyer/results — proximity-ranked firm cards + reason-branched empty state
    - /signup — Volume strip + outlined italic Romans replacing Step 01-04
    - /login + /dashboard — matching Volume strip masthead + italic-metal display
    - /data — champagne-tier compliance callout (replaced amber-warning aesthetic)
    - /audit + /privacy + /terms — monogram + Volume strip + italic-metal display
    
    Brand tokens: --noir #0a0a0c, --ink #f4f1ea, --metal #b89968 (champagne),
    brushed-metal CTA gradient, Cormorant Garamond italic + Inter sans pairing.
    
    Compliance: §6155 directory-not-referral language, Rule 7.x no-fabricated-stats,
    trust block conditional on populated vs empty results.
    
    A11y: scroll-reveal honors prefers-reduced-motion, :focus-visible champagne ring,
    @media print stylesheet on /thanks for receipt printing.
    
    Iter 21 specifically: editorial empty state on /results with reason enum
    (no_anchor / no_firms_in_radius / ok / no_zip) — replaces misleading LA-fallback
    'near {ZIP}' claim that displayed downtown LA firms for unknown ZIPs.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/audit.html           | 222 ++++++++++++
 public/index.html           | 804 ++++++++++++++++++++++++++++----------------
 public/privacy.html         | 171 ++++++++++
 public/terms.html           | 133 ++++++++
 src/lib/match_firms.ts      | 180 ++++++++++
 src/lib/mockup_templates.ts | 297 ++++++++++++++++
 src/server/data_market.ts   | 666 ++++++++++++++++++++++++++++++++++++
 src/server/index.ts         | 237 ++++++++++++-
 src/server/leads.ts         | 761 +++++++++++++++++++++++++++++++++++++++++
 src/server/portal.ts        | 644 +++++++++++++++++++++++++++++++++++
 10 files changed, 3814 insertions(+), 301 deletions(-)

diff --git a/public/audit.html b/public/audit.html
new file mode 100644
index 0000000..a69c606
--- /dev/null
+++ b/public/audit.html
@@ -0,0 +1,222 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Site Audit · Counsel &amp; Bar</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+  :root {
+    --noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;
+    --rule:#2a2724;--rule-faint:#1a1815;
+    --ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;
+    --metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;
+    --good:#34d399;--warn:#d4a04a;--bad:#d87a7a;
+    --serif:"Cormorant Garamond","Cormorant","Georgia",serif;
+    --sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
+  }
+  * { box-sizing: border-box; }
+  body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; font-size: 14px; line-height: 1.5; }
+  ::selection { background: var(--metal); color: var(--noir); }
+  a { color: var(--metal); text-decoration: none; }
+  a:hover { color: var(--metal-glow); }
+  header { padding: 22px 32px; border-bottom: 1px solid var(--rule); display: flex; align-items: center; gap: 18px; flex-wrap: wrap; background: var(--noir-deep); }
+  header h1 { margin: 0; font-family: var(--serif); font-size: 20px; font-weight: 400; letter-spacing: 0.005em; }
+  header h1 em { font-style: italic; color: var(--metal); font-weight: 400; }
+  header .stat { color: var(--ink-mute); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }
+  header .filter { margin-left: auto; display: flex; gap: 12px; align-items: center; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-mute); }
+  header input[type=range] { width: 200px; accent-color: var(--metal); }
+  main { padding: 32px; }
+  .audit-masthead { max-width: 1280px; margin: 24px auto 36px; padding: 36px 0 32px; border-bottom: 1px solid var(--rule); display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: end; }
+  @media (max-width: 720px) { .audit-masthead { grid-template-columns: 1fr; padding: 24px 0; } }
+  .public-vol { display: flex; align-items: center; gap: 18px; margin: 0 0 18px; color: var(--metal); }
+  .public-vol .v-rule { flex: 0 0 56px; height: 1px; background: linear-gradient(90deg, transparent, var(--metal) 50%, transparent); }
+  .public-vol .v-text { font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: .02em; }
+  .public-vol .v-meta { font-size: 10px; letter-spacing: .28em; text-transform: uppercase; color: var(--ink-mute); font-weight: 500; }
+  .audit-display { font-family: var(--serif); font-weight: 300; font-size: clamp(34px, 4.6vw, 56px); line-height: 1.04; letter-spacing: -.018em; margin: 0; max-width: 22ch; color: var(--ink); }
+  .audit-display em { font-style: italic; color: var(--metal); font-weight: 400; }
+  .audit-key { display: flex; gap: 28px; align-items: baseline; }
+  .audit-key .k-pair { display: flex; flex-direction: column; gap: 4px; min-width: 64px; }
+  .audit-key .k-num { font-family: var(--serif); font-weight: 300; font-size: 22px; line-height: 1; font-variant-numeric: tabular-nums; color: var(--metal); }
+  .audit-key .k-num.high { opacity: 1; }
+  .audit-key .k-num.mid { opacity: 0.65; }
+  .audit-key .k-num.low { opacity: 0.4; }
+  .audit-key .k-lbl { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); font-weight: 500; }
+  .audit-keystrip { max-width: 1280px; margin: 0 auto 28px; padding: 14px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); display: flex; align-items: center; gap: 32px; }
+  .audit-keystrip .ks-label { font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); font-weight: 500; }
+  .audit-keystrip .ks-pair { font-family: var(--serif); font-style: italic; font-size: 13px; color: var(--metal); letter-spacing: .02em; }
+  .audit-keystrip .ks-pair span { font-style: normal; font-family: var(--sans); font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-left: 6px; }
+  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 24px; max-width: 1280px; margin: 0 auto; }
+  .audit-foot { max-width: 1280px; margin: 56px auto 0; padding: 24px 0 0; border-top: 1px solid var(--rule); font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 13px; letter-spacing: .02em; text-align: center; }
+  .card { background: var(--noir-rise); border: 1px solid var(--rule); overflow: hidden; display: flex; flex-direction: column; }
+  .shot { width: 100%; aspect-ratio: 1280/800; background: var(--noir-deep) center/cover no-repeat; cursor: zoom-in; border-bottom: 1px solid var(--rule); }
+  .row { padding: 16px 18px 12px; }
+  .name { font-family: var(--serif); font-weight: 400; font-size: 17px; line-height: 1.25; color: var(--ink); }
+  .url { color: var(--ink-mute); font-size: 11px; word-break: break-all; margin-top: 4px; letter-spacing: 0.02em; }
+  .url a { color: var(--ink-mute); text-decoration: none; }
+  .url a:hover { color: var(--metal); }
+  .score-row { display: flex; align-items: baseline; gap: 14px; padding: 0 18px 14px; }
+  .score { font-family: var(--serif); font-variant-numeric: tabular-nums; font-weight: 300; font-size: 36px; line-height: 1; min-width: 56px; }
+  .score.high { color: var(--good); }
+  .score.mid { color: var(--warn); }
+  .score.low { color: var(--bad); }
+  .score-label { color: var(--ink-mute); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; line-height: 1.3; }
+  .palette { display: flex; padding: 0 18px 12px; gap: 3px; }
+  .swatch { flex: 1; aspect-ratio: 1; border: 1px solid var(--rule); }
+  .signals { padding: 0 18px 12px; display: flex; flex-wrap: wrap; gap: 6px; }
+  .pill { font-size: 10px; padding: 3px 8px; border: 1px solid var(--rule); letter-spacing: 0.08em; text-transform: uppercase; font-weight: 500; }
+  .pill.on { background: #0e2a1a; color: var(--good); border-color: #1a4a30; }
+  .pill.off { background: #2a0e0e; color: var(--bad); border-color: #4a1a1a; }
+  .sugg { background: var(--noir-deep); padding: 14px 18px; border-top: 1px solid var(--rule); font-size: 12px; color: var(--ink-mute); }
+  .sugg b { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--metal); font-size: 13px; display: block; margin-bottom: 6px; }
+  .sugg ul { margin: 0; padding-left: 18px; list-style: none; }
+  .sugg li { margin: 5px 0; line-height: 1.5; position: relative; }
+  .sugg li:before { content: '→'; color: var(--metal); position: absolute; left: -18px; }
+  .meta { padding: 0 18px 16px; font-size: 11px; color: var(--ink-mute); display: flex; gap: 16px; flex-wrap: wrap; letter-spacing: 0.04em; }
+  .meta b { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
+  .empty { padding: 80px 28px; text-align: center; color: var(--ink-mute); font-style: italic; }
+  .lightbox { position: fixed; inset: 0; background: rgba(5,5,7,0.95); display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px; cursor: zoom-out; }
+  .lightbox.show { display: flex; }
+  .lightbox img { max-width: 100%; max-height: 100%; border: 1px solid var(--rule); }
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style>
+</head>
+<body>
+
+<header>
+  <a href="/" style="display:inline-flex;align-items:center;gap:14px;text-decoration:none"><svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-aud" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-aud)" stroke-width="1"/><path fill="url(#cb-aud)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1 style="color:var(--ink)">Counsel <em>&amp;</em> Bar — Site Audit</h1></a>
+  <span class="stat" id="stat-count">loading…</span>
+  <span class="stat" id="stat-avg"></span>
+  <div class="filter">
+    <label class="stat">Min score <span id="min-score-val">0</span></label>
+    <input type="range" id="min-score" min="0" max="100" value="0" step="5">
+  </div>
+</header>
+
+<main>
+  <section class="audit-masthead">
+    <div>
+      <div class="public-vol">
+        <span class="v-rule"></span>
+        <span class="v-text">Volume I</span>
+        <span class="v-meta">Edition 2026 · Independent Site Audit</span>
+      </div>
+      <h2 class="audit-display">A standard the registry hasn't <em>codified.</em></h2>
+    </div>
+  </section>
+
+  <div class="audit-keystrip" aria-label="Score legend">
+    <span class="ks-label">Scoring</span>
+    <span class="ks-pair">80+ <span>High</span></span>
+    <span class="ks-pair">60–79 <span>Mid</span></span>
+    <span class="ks-pair">&lt;60 <span>Low</span></span>
+    <span class="ks-label" style="margin-left:auto">Accessibility · Performance · Mobile · Signals</span>
+  </div>
+
+  <div class="grid" id="grid"></div>
+  <div class="empty" id="empty" style="display:none">No audited sites yet — run <code>npx tsx src/enrich/site_audit.ts</code> first.</div>
+
+  <p class="audit-foot">Counsel &amp; Bar publishes this audit independently. No firm pays for inclusion or score adjustment.</p>
+</main>
+
+<div class="lightbox" id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img" alt=""></div>
+
+<script>
+let DATA = [];
+
+function fmtBytes(b) {
+  if (!b) return '–';
+  if (b > 1e6) return (b / 1e6).toFixed(1) + ' MB';
+  if (b > 1e3) return (b / 1e3).toFixed(0) + ' KB';
+  return b + ' B';
+}
+
+function scoreClass(s) {
+  if (s == null) return 'low';
+  if (s >= 70) return 'high';
+  if (s >= 45) return 'mid';
+  return 'low';
+}
+
+function pill(name, on) {
+  return `<span class="pill ${on ? 'on' : 'off'}">${on ? '✓' : '✗'} ${name}</span>`;
+}
+
+function render() {
+  const min = +document.getElementById('min-score').value;
+  document.getElementById('min-score-val').textContent = min;
+  const filtered = DATA.filter(r => (r.marketing_score || 0) >= min);
+  const grid = document.getElementById('grid');
+  const empty = document.getElementById('empty');
+  grid.innerHTML = '';
+  if (filtered.length === 0) { empty.style.display = 'block'; return; }
+  empty.style.display = 'none';
+
+  const avg = Math.round(filtered.reduce((a, r) => a + (r.marketing_score || 0), 0) / filtered.length);
+  document.getElementById('stat-count').textContent = `${filtered.length} sites`;
+  document.getElementById('stat-avg').textContent = `· avg score ${avg}`;
+
+  for (const r of filtered) {
+    const palette = (r.palette || []).slice(0, 6);
+    const swatchHtml = palette.map(c => `<div class="swatch" style="background:${c.hex}" title="${c.hex} — ${(c.fraction*100).toFixed(0)}%"></div>`).join('');
+    const sigs = [
+      pill('HTTPS', r.has_https),
+      pill('Favicon', r.has_favicon),
+      pill('OG image', r.has_og_image),
+      pill('Mobile', r.has_meta_viewport),
+      pill('Analytics', r.has_analytics),
+      pill('H1', r.has_h1),
+      pill('Schema.org', r.has_schema_org),
+      pill('Phone shown', r.has_phone_visible),
+    ].join('');
+    const sugList = (r.suggestions || []).slice(0, 4).map(s => `<li>${s.replace(/</g,'&lt;')}</li>`).join('');
+    const card = document.createElement('div');
+    card.className = 'card';
+    card.innerHTML = `
+      <div class="shot" style="background-image:url('${r.screenshot_path || ''}')" onclick="openLightbox('${r.screenshot_path || ''}')"></div>
+      <div class="row">
+        <div class="name">${r.name.replace(/</g,'&lt;')}</div>
+        <div class="url"><a href="${r.final_url || r.url}" target="_blank" rel="noopener">${(r.final_url || r.url).replace(/^https?:\/\//,'')}</a></div>
+      </div>
+      <div class="score-row">
+        <div class="score ${scoreClass(r.marketing_score)}">${r.marketing_score ?? '–'}</div>
+        <div class="score-label">marketing<br>health</div>
+      </div>
+      <div class="palette">${swatchHtml}</div>
+      <div class="signals">${sigs}</div>
+      <div class="meta">
+        <span><b>${fmtBytes(r.page_size_bytes)}</b> page</span>
+        <span><b>${r.load_time_ms ? (r.load_time_ms/1000).toFixed(1)+'s' : '–'}</b> load</span>
+        <span><b>${r.font_count ?? '–'}</b> fonts</span>
+        <span><b>${r.image_count ?? '–'}</b> images</span>
+      </div>
+      ${sugList ? `<div class="sugg"><b>Suggestions</b><ul>${sugList}</ul></div>` : ''}
+    `;
+    grid.appendChild(card);
+  }
+}
+
+function openLightbox(src) {
+  if (!src) return;
+  document.getElementById('lightbox-img').src = src;
+  document.getElementById('lightbox').classList.add('show');
+}
+
+async function load() {
+  const r = await fetch('/api/audits?limit=2000');
+  const j = await r.json();
+  DATA = j.rows || [];
+  render();
+}
+
+document.getElementById('min-score').addEventListener('input', render);
+load();
+</script>
+
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index fad7812..890c261 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,320 +2,542 @@
 <html lang="en">
 <head>
 <meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>LA Lawyer Directory — Firms</title>
-<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
-      integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>The California Bar — Curated.</title>
+<meta name="description" content="A curated directory of every licensed California attorney. 89,000+ verified profiles, 42,000+ firms, 1,000+ audited websites. Talk to one in 24 hours.">
+<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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
 <style>
-  :root { --bg:#0e1116; --panel:#161b22; --panel2:#1c2129; --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --line:#30363d; }
-  * { box-sizing: border-box; }
-  html, body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--text); font:14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
-  a { color: var(--accent); }
+  :root {
+    --noir:        #0a0a0c;
+    --noir-rise:   #131316;
+    --noir-deep:   #050507;
+    --rule:        #2a2724;
+    --rule-faint:  #1a1815;
+    --ink:         #f4f1ea;
+    --ink-soft:    #d8d2c5;
+    --ink-mute:    #8b857a;
+    --metal:       #b89968;
+    --metal-glow:  #d4b683;
+    --metal-deep:  #8a7044;
+    --metal-gradient: linear-gradient(180deg, #d4b683 0%, #b89968 38%, #9c7e4f 100%);
+    --metal-rule-gradient: linear-gradient(90deg, transparent 0%, #b89968 50%, transparent 100%);
 
-  header { padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--line); display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
-  header h1 { margin:0; font-size:15px; font-weight:600; }
-  header .stat { color:var(--muted); font-size:13px; }
-  header .stat b { color:var(--text); font-weight:600; }
+    --serif:  "Cormorant Garamond", "Cormorant", "Georgia", serif;
+    --sans:   "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
 
-  /* View toggle */
-  .toolbar { margin-left:auto; display:flex; align-items:center; gap:14px; }
-  .seg { display:inline-flex; background:#0d1117; border:1px solid var(--line); border-radius:6px; overflow:hidden; }
-  .seg button { background:transparent; border:0; color:var(--muted); padding:6px 12px; font:inherit; cursor:pointer; }
-  .seg button.active { background:var(--accent); color:#0d1117; font-weight:600; }
-  .seg button:hover:not(.active) { color:var(--text); }
-  .slider-wrap { display:none; align-items:center; gap:8px; color:var(--muted); font-size:13px; }
-  .slider-wrap.show { display:inline-flex; }
-  .slider-wrap input[type=range] { accent-color: var(--accent); }
-  .slider-wrap .num { color:var(--text); min-width:1.5em; display:inline-block; text-align:right; }
+    --hairline: 1px solid var(--rule);
+    --rh: 1.5;
+  }
 
-  main { display:grid; grid-template-columns: 300px 1fr; height:calc(100vh - 49px); }
-  aside { overflow:auto; background:var(--panel); border-right:1px solid var(--line); }
-  aside section { padding:12px 14px; border-bottom:1px solid var(--line); }
-  aside h2 { margin:0 0 8px 0; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
-  aside .row { display:flex; justify-content:space-between; padding:3px 0; font-variant-numeric: tabular-nums; gap:8px; }
-  aside .row a { color:var(--text); text-decoration:none; }
-  aside .row a:hover { color:var(--accent); }
-  aside .num { color:var(--muted); }
-  aside .row.active a { color:var(--accent); }
+  *, *::before, *::after { box-sizing: border-box; }
+  html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; scroll-behavior: smooth; }
+  body {
+    margin: 0;
+    background: var(--noir);
+    color: var(--ink);
+    font-family: var(--sans);
+    font-weight: 300;
+    font-size: 16px;
+    line-height: var(--rh);
+    overflow-x: hidden;
+  }
+  ::selection { background: var(--metal); color: var(--noir); }
 
-  /* Views */
-  .view { height:100%; overflow:auto; }
-  .view.hidden { display:none; }
-  #map.view { height: calc(100vh - 49px); }   /* leaflet needs explicit height */
+  /* ─── Top bar ──────────────────────────────────────────────────── */
+  .topbar {
+    position: fixed; top: 0; left: 0; right: 0; z-index: 50;
+    padding: 22px 48px;
+    display: flex; justify-content: space-between; align-items: center;
+    background: linear-gradient(180deg, rgba(10,10,12,0.92) 0%, rgba(10,10,12,0.0) 100%);
+    backdrop-filter: blur(8px);
+  }
+  .brand { font-family: var(--serif); font-size: 22px; font-weight: 400; letter-spacing: 0.01em; color: var(--ink); text-decoration: none; }
+  .brand .ampersand { color: var(--metal); font-style: italic; padding: 0 4px; }
+  .nav { display: flex; gap: 36px; align-items: center; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; }
+  .nav a { color: var(--ink-mute); text-decoration: none; transition: color 180ms ease; }
+  .nav a:hover { color: var(--ink); }
+  .nav a.cta-mini {
+    color: var(--metal); border: 1px solid var(--rule); padding: 9px 18px; transition: all 220ms ease;
+  }
+  .nav a.cta-mini:hover { color: var(--noir); background: var(--metal); border-color: var(--metal); }
+  @media (max-width: 720px) { .topbar { padding: 18px 24px; } .nav { gap: 18px; } .nav a:not(.cta-mini) { display: none; } }
 
-  /* Grid view */
-  #grid { padding:14px; display:grid; gap:12px; align-content:start; }
-  .card { background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:12px 14px; min-height:0; }
-  .card h3 { margin:0 0 6px 0; font-size:14px; font-weight:600; line-height:1.25; }
-  .card .addr { color:var(--muted); font-size:12px; line-height:1.4; min-height:2.6em; }
-  .card .meta { margin-top:8px; display:flex; flex-wrap:wrap; gap:6px; }
-  .card a.web { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }
-  .pill { display:inline-block; padding:1px 7px; border-radius:8px; background:#30363d; color:var(--muted); font-size:11px; }
-  .pill.size-biglaw { background:#3a1f54; color:#d6b6ff; }
-  .pill.size-large { background:#2a3a55; color:#a8c8ff; }
-  .pill.size-medium { background:#1d4335; color:#9be0c2; }
-  .pill.size-small { background:#3a3014; color:#e8c879; }
-  .pill.size-solo { background:#3a1f1f; color:#ffb1b1; }
+  /* ─── Hero ─────────────────────────────────────────────────────── */
+  .hero {
+    min-height: 100vh;
+    padding: 180px 48px 96px;
+    display: flex; flex-direction: column; justify-content: center;
+    background: radial-gradient(ellipse at 70% 30%, rgba(184,153,104,0.08) 0%, transparent 55%),
+                linear-gradient(180deg, var(--noir-deep) 0%, var(--noir) 60%);
+    position: relative; overflow: hidden;
+  }
+  .hero::after {
+    content: ''; position: absolute; bottom: 0; left: 48px; right: 48px;
+    height: 1px; background: linear-gradient(90deg, transparent, var(--rule) 20%, var(--rule) 80%, transparent);
+  }
+  .hero-inner { max-width: 1280px; margin: 0 auto; width: 100%; }
+  .eyebrow {
+    font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; margin: 0 0 32px;
+  }
+  .eyebrow .dot { display: inline-block; width: 4px; height: 4px; background: var(--metal); border-radius: 50%; margin: 0 14px; vertical-align: middle; }
+  h1.display {
+    font-family: var(--serif); font-weight: 300;
+    font-size: clamp(48px, 7.5vw, 96px); line-height: 1.02;
+    letter-spacing: -0.02em; margin: 0 0 28px; max-width: 16ch;
+    color: var(--ink);
+  }
+  h1.display em { font-style: italic; color: var(--metal); font-weight: 400; }
+  .lede {
+    font-size: clamp(16px, 1.6vw, 19px); line-height: 1.55; color: var(--ink-soft);
+    max-width: 56ch; margin: 0 0 56px; font-weight: 300;
+  }
+  .cta-row { display: flex; gap: 20px; flex-wrap: wrap; align-items: center; }
+  .cta {
+    display: inline-flex; align-items: center; gap: 14px;
+    padding: 18px 32px;
+    font-family: var(--sans); font-size: 12px; font-weight: 500;
+    letter-spacing: 0.18em; text-transform: uppercase;
+    text-decoration: none; transition: all 260ms cubic-bezier(.2,.7,.2,1);
+    border: 1px solid transparent; border-radius: 0;
+    cursor: pointer;
+  }
+  .cta-primary {
+    background: var(--metal-gradient); color: var(--noir); border-color: var(--metal);
+    box-shadow: inset 0 1px 0 rgba(255,235,200,0.35), 0 1px 0 rgba(0,0,0,0.4);
+  }
+  .cta-primary:hover {
+    background: linear-gradient(180deg, #e3c89a 0%, #c8a674 50%, #a98559 100%);
+    border-color: var(--metal-glow); transform: translateY(-1px);
+    box-shadow: inset 0 1px 0 rgba(255,240,210,0.5), 0 4px 18px rgba(184,153,104,0.18);
+  }
+  .cta-ghost { background: transparent; color: var(--ink); border-color: var(--rule); }
+  .cta-ghost:hover { border-color: var(--ink); }
+  .cta .arrow { transition: transform 240ms ease; }
+  .cta:hover .arrow { transform: translateX(4px); }
+  @media (max-width: 720px) { .hero { padding: 130px 24px 60px; } .hero::after { left: 24px; right: 24px; } }
 
-  /* List view (spreadsheet) */
-  #list { padding:0; }
-  #list table { width:100%; border-collapse: collapse; font-size:13px; }
-  #list thead th { position:sticky; top:0; z-index:1; background:var(--panel); padding:8px 10px; text-align:left; border-bottom:1px solid var(--line); color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; cursor:pointer; user-select:none; white-space:nowrap; }
-  #list thead th:hover { color:var(--text); }
-  #list thead th.sorted::after { content:' \25BE'; color:var(--accent); }
-  #list thead th.sorted.asc::after { content:' \25B4'; }
-  #list tbody td { padding:7px 10px; border-bottom:1px solid #21262d; vertical-align:top; }
-  #list tbody tr:hover { background:#0d1117; }
-  #list tbody td.name { font-weight:600; }
-  #list tbody td.addr { color:var(--muted); }
-  #list tbody td.web a { font-size:12px; }
-  #list tbody td.right { text-align:right; font-variant-numeric: tabular-nums; }
+  /* ─── Proof strip: "as listed on the public roll" ──────────────── */
+  .proof {
+    margin: 64px 0 0; padding: 28px 0 0;
+    border-top: 1px solid var(--rule);
+    display: flex; flex-direction: column; gap: 16px;
+  }
+  .proof .p-eyebrow {
+    font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; margin: 0;
+  }
+  .proof .p-row {
+    display: flex; flex-wrap: wrap; align-items: baseline; gap: 14px 32px;
+  }
+  .proof .p-firm {
+    font-family: var(--serif); font-weight: 400; font-size: 15px;
+    color: var(--ink); letter-spacing: 0.06em; text-transform: uppercase;
+    white-space: nowrap;
+  }
+  .proof .p-firm .p-city {
+    font-family: var(--sans); font-weight: 300; font-style: italic;
+    font-size: 11px; color: var(--ink-mute); letter-spacing: 0.04em;
+    text-transform: none; margin-left: 8px;
+  }
+  .proof .p-firm + .p-firm::before {
+    content: ''; display: inline-block; width: 3px; height: 3px;
+    border-radius: 50%; background: var(--metal);
+    margin-right: 32px; margin-left: -16px; vertical-align: 4px; opacity: 0.6;
+  }
+  .proof .p-meta {
+    font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500; margin: 8px 0 0;
+    font-style: italic; font-family: var(--serif);
+  }
+  @media (max-width: 720px) {
+    .proof { margin: 40px 0 0; padding: 22px 0 0; }
+    .proof .p-row { gap: 12px 22px; }
+    .proof .p-firm { font-size: 13px; }
+    .proof .p-firm + .p-firm::before { margin-right: 22px; margin-left: -11px; }
+  }
 
-  .leaflet-popup-content { font-size:13px; line-height:1.4; }
-  .leaflet-popup-content b { color:#0d1117; }
-  .leaflet-popup-content a { color:#0969da; }
-</style>
-</head>
-<body>
-<header>
-  <h1>LA Lawyer Directory</h1>
-  <span class="stat"><b id="s_firms">…</b> firms</span>
-  <span class="stat"><b id="s_pros">…</b> lawyers</span>
-  <span class="stat"><b id="s_geo">…</b> geo-mapped</span>
-  <span class="stat"><b id="s_phones">…</b> phones</span>
-  <span class="stat"><b id="s_emails">…</b> emails</span>
-  <span class="stat"><b id="s_buildings">…</b> multi-firm bldgs</span>
-  <span class="stat"><b id="s_cities">…</b> cities</span>
-  <div class="toolbar">
-    <span class="slider-wrap" id="slider_wrap">
-      <span>Columns</span>
-      <input type="range" id="cols" min="3" max="12" value="5" step="1">
-      <span class="num" id="cols_val">5</span>
-    </span>
-    <span class="seg" id="view_seg">
-      <button data-view="grid"  class="active">Grid</button>
-      <button data-view="list">List</button>
-      <button data-view="map">Map</button>
-    </span>
-  </div>
-</header>
-<main>
-  <aside>
-    <section>
-      <h2>Top areas</h2>
-      <div id="cities"></div>
-    </section>
-    <section>
-      <h2>Multi-firm buildings</h2>
-      <div id="buildings"></div>
-    </section>
-    <section>
-      <h2>Recent jobs</h2>
-      <div id="jobs"></div>
-    </section>
-  </aside>
-  <div id="content">
-    <div id="grid" class="view"></div>
-    <div id="list" class="view hidden">
-      <table>
-        <thead>
-          <tr>
-            <th data-sort="name">Firm</th>
-            <th data-sort="city">City</th>
-            <th data-sort="address">Address</th>
-            <th data-sort="phone">Phone</th>
-            <th data-sort="website">Website</th>
-            <th data-sort="firm_size_band">Size</th>
-            <th data-sort="attorney_count" class="right">Attys</th>
-          </tr>
-        </thead>
-        <tbody></tbody>
-      </table>
-    </div>
-    <div id="map" class="view hidden"></div>
-  </div>
-</main>
-<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
-        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
-<script>
-async function getJSON(url) { const r = await fetch(url); return r.json(); }
-const escape = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[c]));
-const shortHost = (u) => { try { return new URL(u).host.replace(/^www\./,''); } catch { return u || ''; } };
+  /* ─── Stat ledger ──────────────────────────────────────────────── */
+  .ledger { padding: 80px 48px; background: var(--noir); }
+  .ledger-inner { max-width: 1280px; margin: 0 auto; }
+  .ledger-grid {
+    display: grid; grid-template-columns: repeat(4, 1fr);
+    border-top: var(--hairline); border-bottom: var(--hairline);
+  }
+  .stat {
+    padding: 36px 28px;
+    border-right: var(--hairline);
+  }
+  .stat:last-child { border-right: 0; }
+  .stat .num {
+    font-family: var(--serif); font-weight: 300;
+    font-size: clamp(36px, 4.2vw, 56px); line-height: 1; color: var(--metal);
+    font-variant-numeric: tabular-nums; margin-bottom: 10px; letter-spacing: -0.01em;
+  }
+  .stat .lbl {
+    font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+  }
+  @media (max-width: 720px) {
+    .ledger { padding: 48px 24px; }
+    .ledger-grid { grid-template-columns: repeat(2, 1fr); }
+    .stat { padding: 28px 18px; border-right: 0; border-bottom: var(--hairline); }
+    .stat:nth-child(odd) { border-right: var(--hairline); }
+    .stat:nth-last-child(-n+2) { border-bottom: 0; }
+  }
 
-let MAP = null;
-let MAP_INITIALIZED = false;
-let MARKERS = null;
-let FIRMS = [];
-let SORT_KEY = 'name';
-let SORT_DIR = 1;     // 1 asc, -1 desc
+  /* ─── Features ─────────────────────────────────────────────────── */
+  .features { padding: 120px 48px; background: var(--noir); }
+  .features-inner { max-width: 1280px; margin: 0 auto; }
+  .section-eyebrow {
+    font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; margin: 0 0 24px;
+  }
+  .section-title {
+    font-family: var(--serif); font-weight: 300;
+    font-size: clamp(32px, 4vw, 52px); line-height: 1.1;
+    letter-spacing: -0.015em; margin: 0 0 80px; max-width: 22ch;
+    color: var(--ink);
+  }
+  .feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
+  .feat {
+    padding: 44px 36px 44px 0;
+    border-top: var(--hairline);
+    position: relative;
+  }
+  .feat:nth-child(2n) { padding-left: 36px; padding-right: 0; }
+  .feat:nth-child(-n+2) { border-top: 0; }
+  .feat .feat-num {
+    font-family: var(--serif); font-style: italic; font-weight: 300;
+    font-size: 18px; color: var(--metal); margin-bottom: 14px; display: block;
+  }
+  .feat h3 {
+    font-family: var(--serif); font-weight: 400;
+    font-size: 26px; line-height: 1.2; margin: 0 0 14px; color: var(--ink);
+    letter-spacing: -0.005em;
+  }
+  .feat p { margin: 0; color: var(--ink-mute); font-size: 15px; line-height: 1.65; max-width: 38ch; }
+  @media (max-width: 720px) {
+    .features { padding: 64px 24px; }
+    .feature-grid { grid-template-columns: 1fr; }
+    .feat, .feat:nth-child(2n) { padding: 32px 0; border-top: var(--hairline); }
+    .feat:first-child { border-top: 0; }
+  }
 
-// ─── view switching ─────────────────────────────────────────────────────────
-function setView(name) {
-  for (const el of document.querySelectorAll('.view')) el.classList.add('hidden');
-  document.getElementById(name).classList.remove('hidden');
-  for (const b of document.querySelectorAll('#view_seg button')) b.classList.toggle('active', b.dataset.view === name);
-  document.getElementById('slider_wrap').classList.toggle('show', name === 'grid');
-  if (name === 'map') {
-    if (!MAP_INITIALIZED) initMap();
-    setTimeout(() => MAP.invalidateSize(), 50);
-  }
-  localStorage.setItem('view', name);
-}
+  /* ─── Closer ───────────────────────────────────────────────────── */
+  .closer {
+    padding: 120px 48px; background: var(--noir-deep);
+    border-top: var(--hairline);
+    text-align: center;
+  }
+  .closer-inner { max-width: 720px; margin: 0 auto; }
+  .closer .quote {
+    font-family: var(--serif); font-style: italic; font-weight: 300;
+    font-size: clamp(26px, 3vw, 38px); line-height: 1.35;
+    color: var(--ink); margin: 0 0 40px; letter-spacing: -0.005em;
+  }
+  .closer .quote::before { content: '"'; color: var(--metal); margin-right: 4px; }
+  .closer .quote::after  { content: '"'; color: var(--metal); margin-left: 4px; }
+  @media (max-width: 720px) { .closer { padding: 64px 24px; } }
 
-document.getElementById('view_seg').addEventListener('click', (e) => {
-  const b = e.target.closest('button[data-view]');
-  if (b) setView(b.dataset.view);
-});
+  /* ─── Footer ───────────────────────────────────────────────────── */
+  footer {
+    padding: 56px 48px 48px;
+    border-top: var(--hairline);
+    background: var(--noir);
+    display: flex; justify-content: space-between; align-items: flex-start;
+    gap: 40px; flex-wrap: wrap;
+    font-size: 12px; color: var(--ink-mute);
+  }
+  footer .brand-mark { font-family: var(--serif); font-size: 18px; color: var(--ink); margin-bottom: 14px; }
+  footer .col { min-width: 180px; }
+  footer .col h4 { font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink); margin: 0 0 14px; font-weight: 500; }
+  footer .col a { display: block; color: var(--ink-mute); text-decoration: none; padding: 4px 0; transition: color 180ms ease; }
+  footer .col a:hover { color: var(--metal); }
+  footer .legal { font-size: 11px; opacity: 0.6; max-width: 320px; line-height: 1.6; }
+  @media (max-width: 720px) { footer { padding: 40px 24px 32px; } }
 
-// ─── grid view ──────────────────────────────────────────────────────────────
-const colsInput = document.getElementById('cols');
-const colsVal = document.getElementById('cols_val');
-function applyCols() {
-  const n = parseInt(colsInput.value, 10);
-  document.getElementById('grid').style.gridTemplateColumns = `repeat(${n}, minmax(0, 1fr))`;
-  colsVal.textContent = String(n);
-  localStorage.setItem('cols', String(n));
-}
-colsInput.addEventListener('input', applyCols);
+  /* ─── Monogram + brand mark ────────────────────────────────────── */
+  .monogram { width: 28px; height: 28px; flex: 0 0 28px; }
+  .monogram circle { fill: none; stroke: url(#mg-stroke); stroke-width: 1; }
+  .monogram path { fill: url(#mg-stroke); }
+  .brand-row { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; }
 
-function renderGrid() {
-  const html = FIRMS.map(f => `
-    <div class="card">
-      <h3>${escape(f.name)}</h3>
-      <div class="addr">${escape(f.address || (f.city ? f.city + ', CA' : ''))}</div>
-      <div class="meta">
-        ${f.phone ? `<span class="pill">📞 ${escape(f.phone)}</span>` : ''}
-        ${f.firm_size_band ? `<span class="pill size-${escape(f.firm_size_band)}">${escape(f.firm_size_band)}</span>` : ''}
-        ${f.neighborhood ? `<span class="pill">${escape(f.neighborhood)}</span>` : ''}
-      </div>
-      ${f.website ? `<div style="margin-top:8px;"><a class="web" href="${escape(f.website)}" target="_blank" rel="noopener">🌐 ${escape(shortHost(f.website))}</a></div>` : ''}
-    </div>
-  `).join('');
-  document.getElementById('grid').innerHTML = html;
-}
+  /* ─── Hero typographic watermark + corner marks ────────────────── */
+  .hero-watermark {
+    position: absolute; right: -1vw; top: 50%; transform: translateY(-52%);
+    font-family: var(--serif); font-style: italic; font-weight: 300;
+    font-size: clamp(420px, 72vw, 1100px); line-height: 0.78;
+    color: var(--metal); opacity: 0.045;
+    pointer-events: none; user-select: none; letter-spacing: -0.04em;
+    z-index: 0;
+  }
+  .hero-inner { position: relative; z-index: 1; }
+  .reg-mark {
+    position: absolute; width: 18px; height: 18px;
+    color: var(--metal); opacity: 0.45;
+    pointer-events: none;
+  }
+  .reg-mark.tl { top: 24px; left: 24px; }
+  .reg-mark.tr { top: 24px; right: 24px; }
+  .reg-mark.bl { bottom: 24px; left: 24px; }
+  .reg-mark.br { bottom: 24px; right: 24px; }
+  .reg-mark::before, .reg-mark::after {
+    content: ''; position: absolute; background: currentColor;
+  }
+  .reg-mark::before { left: 50%; top: 0; bottom: 0; width: 1px; transform: translateX(-50%); }
+  .reg-mark::after  { top: 50%; left: 0; right: 0; height: 1px; transform: translateY(-50%); }
+  .coord-marker {
+    position: absolute; top: 36px; right: 64px;
+    font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+    font-variant-numeric: tabular-nums;
+  }
+  .coord-marker .axis { color: var(--metal); }
+  @media (max-width: 720px) {
+    .coord-marker { top: 96px; right: 24px; font-size: 9px; }
+    .reg-mark { width: 12px; height: 12px; }
+    .reg-mark.tl, .reg-mark.tr { top: 96px; }
+    .hero-watermark { font-size: 60vw; opacity: 0.04; }
+  }
+
+  /* ─── Volume indicator strip ───────────────────────────────────── */
+  .vol-strip {
+    display: flex; align-items: center; gap: 18px;
+    margin: 0 0 32px; opacity: 0.78;
+  }
+  .vol-strip .v-rule { flex: 0 0 56px; height: 1px; background: var(--metal-rule-gradient); }
+  .vol-strip .v-text {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    font-size: 14px; color: var(--metal); letter-spacing: 0.02em;
+  }
+  .vol-strip .v-meta {
+    font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase;
+    color: var(--ink-mute); font-weight: 500;
+  }
+
+  /* ─── Stat ledger refinement ───────────────────────────────────── */
+  .stat .seal {
+    font-family: var(--serif); font-style: italic; font-weight: 400;
+    font-size: 11px; color: var(--metal-deep); letter-spacing: 0.06em;
+    margin-bottom: 18px; display: block;
+  }
 
-// ─── list view (sortable spreadsheet) ───────────────────────────────────────
-function renderList() {
-  const tbody = document.querySelector('#list tbody');
-  const rows = [...FIRMS].sort((a, b) => {
-    const av = a[SORT_KEY] ?? ''; const bv = b[SORT_KEY] ?? '';
-    if (typeof av === 'number' && typeof bv === 'number') return SORT_DIR * (av - bv);
-    return SORT_DIR * String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
-  });
-  tbody.innerHTML = rows.map(f => `
-    <tr>
-      <td class="name">${escape(f.name)}</td>
-      <td>${escape(f.city || f.neighborhood || '')}</td>
-      <td class="addr">${escape(f.address || '')}</td>
-      <td>${escape(f.phone || '')}</td>
-      <td class="web">${f.website ? `<a href="${escape(f.website)}" target="_blank" rel="noopener">${escape(shortHost(f.website))}</a>` : ''}</td>
-      <td>${f.firm_size_band ? `<span class="pill size-${escape(f.firm_size_band)}">${escape(f.firm_size_band)}</span>` : ''}</td>
-      <td class="right">${f.attorney_count || ''}</td>
-    </tr>
-  `).join('');
-  for (const th of document.querySelectorAll('#list thead th')) {
-    th.classList.toggle('sorted', th.dataset.sort === SORT_KEY);
-    th.classList.toggle('asc', SORT_DIR === 1);
-  }
-}
-document.querySelector('#list thead').addEventListener('click', (e) => {
-  const th = e.target.closest('th[data-sort]'); if (!th) return;
-  if (SORT_KEY === th.dataset.sort) SORT_DIR *= -1; else { SORT_KEY = th.dataset.sort; SORT_DIR = 1; }
-  renderList();
-});
+  /* ─── Feature outlined numerals ────────────────────────────────── */
+  .feat .feat-num {
+    font-family: var(--serif); font-style: italic; font-weight: 300;
+    font-size: 64px; line-height: 0.9; color: transparent;
+    -webkit-text-stroke: 1px var(--metal); margin: 0 0 22px; display: block;
+    letter-spacing: -0.02em;
+  }
+  @media (max-width: 720px) { .feat .feat-num { font-size: 48px; } }
+
+  /* ─── Section divider ornaments ────────────────────────────────── */
+  .ornament {
+    display: flex; align-items: center; justify-content: center; gap: 22px;
+    margin: 0 0 18px;
+  }
+  .ornament .o-rule { width: 64px; height: 1px; background: var(--rule); }
+  .ornament .o-glyph { color: var(--metal); font-family: var(--serif); font-style: italic; font-size: 20px; line-height: 1; }
 
-// ─── map view (lazy init) ───────────────────────────────────────────────────
-function initMap() {
-  MAP = L.map('map').setView([34.05, -118.35], 11);
-  L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
-    attribution: '© OSM © CARTO', subdomains: 'abcd', maxZoom: 19,
-  }).addTo(MAP);
-  MARKERS = L.layerGroup().addTo(MAP);
-  for (const f of FIRMS) {
-    if (!f.lat || !f.lng) continue;
-    const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
-    const m = L.circleMarker([f.lat, f.lng], {
-      radius, color: '#58a6ff', weight: 1, fillColor: '#58a6ff', fillOpacity: 0.55,
-    });
-    m.bindPopup(`
-      <b>${escape(f.name)}</b><br>
-      ${escape(f.address || '')}<br>
-      ${f.phone ? `📞 ${escape(f.phone)}<br>` : ''}
-      ${f.website ? `🌐 <a href="${escape(f.website)}" target="_blank" rel="noopener">${escape(shortHost(f.website))}</a><br>` : ''}
-      ${f.firm_size_band ? `<span class="pill">${escape(f.firm_size_band)}</span>` : ''}
-      ${f.neighborhood ? `<span class="pill">${escape(f.neighborhood)}</span>` : ''}
-    `);
-    MARKERS.addLayer(m);
-  }
-  MAP_INITIALIZED = true;
-}
+  /* ─── Subtle entrance ──────────────────────────────────────────── */
+  @media (prefers-reduced-motion: no-preference) {
+    .reveal { opacity: 0; transform: translateY(14px); animation: rise 800ms cubic-bezier(.16,.84,.3,1) forwards; }
+    .reveal.d1 { animation-delay: 80ms; }
+    .reveal.d2 { animation-delay: 160ms; }
+    .reveal.d3 { animation-delay: 260ms; }
+    .reveal.d4 { animation-delay: 360ms; }
+    .reveal.d5 { animation-delay: 460ms; }
+    @keyframes rise { to { opacity: 1; transform: none; } }
+    .hero-watermark { animation: watermark-fade 1600ms cubic-bezier(.16,.84,.3,1) forwards; opacity: 0; }
+    @keyframes watermark-fade { to { opacity: 0.045; } }
+
+    /* Scroll-triggered reveals for content below the hero */
+    .scroll-reveal { opacity: 0; transform: translateY(22px); transition: opacity 900ms cubic-bezier(.16,.84,.3,1), transform 900ms cubic-bezier(.16,.84,.3,1); }
+    .scroll-reveal.in-view { opacity: 1; transform: none; }
+    .scroll-reveal.s1 { transition-delay: 80ms; }
+    .scroll-reveal.s2 { transition-delay: 160ms; }
+    .scroll-reveal.s3 { transition-delay: 240ms; }
+    .scroll-reveal.s4 { transition-delay: 320ms; }
+  }
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style>
+</head>
+<body>
+
+<header class="topbar">
+  <a class="brand-row" href="/">
+    <svg class="monogram" viewBox="0 0 28 28" aria-hidden="true">
+      <defs>
+        <linearGradient id="mg-stroke" x1="0" y1="0" x2="0" y2="1">
+          <stop offset="0%" stop-color="#d4b683"/>
+          <stop offset="50%" stop-color="#b89968"/>
+          <stop offset="100%" stop-color="#8a7044"/>
+        </linearGradient>
+      </defs>
+      <circle cx="14" cy="14" r="13"/>
+      <path d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/>
+    </svg>
+    <span class="brand">Counsel <span class="ampersand">&amp;</span> Bar</span>
+  </a>
+  <nav class="nav">
+    <a href="/find-a-lawyer">Find counsel</a>
+    <a href="/audit.html">Directory</a>
+    <a href="/data">Data</a>
+    <a href="/login">Sign in</a>
+    <a class="cta-mini" href="/signup">Claim your firm</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="hero-watermark" aria-hidden="true">I</div>
+  <div class="hero-inner">
+    <div class="vol-strip reveal">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · Public Bar Registry</span>
+    </div>
+    <p class="eyebrow reveal d1">California<span class="dot"></span>Est. on the public bar registry</p>
+    <h1 class="display reveal d2">
+      Every licensed attorney<br>
+      in California, <em>curated.</em>
+    </h1>
+    <p class="lede reveal d3">
+      A complete index of the people authorized to practice law in this state — verified against the State Bar's public roll. Browse firms by city and practice area. Contact attorneys directly through their listed channels. We're a directory, not a referral service: we don't accept fees for sending you to a specific attorney, and we don't route your case anywhere on your behalf.
+    </p>
+    <div class="cta-row reveal d4">
+      <a class="cta cta-primary" href="/find-a-lawyer">
+        Find counsel
+        <span class="arrow">→</span>
+      </a>
+      <a class="cta cta-ghost" href="/signup">
+        Claim your listing
+        <span class="arrow">→</span>
+      </a>
+    </div>
 
-// ─── boot ───────────────────────────────────────────────────────────────────
-(async () => {
-  const stats = await getJSON('/api/stats');
-  const fmt = (n) => (n ?? 0).toLocaleString();
-  document.getElementById('s_firms').textContent = fmt(stats.firms_total);
-  document.getElementById('s_pros').textContent = fmt(stats.professionals_total);
-  document.getElementById('s_geo').textContent = fmt(stats.firms_with_geo);
-  document.getElementById('s_phones').textContent = fmt(stats.phones_total);
-  document.getElementById('s_emails').textContent = fmt(stats.emails_total);
-  document.getElementById('s_buildings').textContent = fmt(stats.multi_firm_buildings);
-  document.getElementById('s_cities').textContent = fmt(stats.cities);
+  </div>
+</section>
 
-  const cities = await getJSON('/api/cities');
-  document.getElementById('cities').innerHTML = cities.rows.slice(0, 25).map(c =>
-    `<div class="row"><a href="#" data-area="${encodeURIComponent(c.area)}">${escape(c.area || '(unknown)')}</a><span class="num">${c.firms}</span></div>`
-  ).join('');
+<section class="ledger scroll-reveal">
+  <div class="ledger-inner">
+    <div class="ledger-grid">
+      <div class="stat reveal">
+        <span class="seal">i.</span>
+        <div class="num">89,390</div>
+        <div class="lbl">Bar attorneys indexed</div>
+      </div>
+      <div class="stat reveal d1">
+        <span class="seal">ii.</span>
+        <div class="num">42,636</div>
+        <div class="lbl">Firms statewide</div>
+      </div>
+      <div class="stat reveal d2">
+        <span class="seal">iii.</span>
+        <div class="num">1,016</div>
+        <div class="lbl">Sites independently audited</div>
+      </div>
+      <div class="stat reveal d3">
+        <span class="seal">iv.</span>
+        <div class="num">24<span style="font-size:0.45em;color:var(--ink-mute);font-style:italic;">&nbsp;hrs</span></div>
+        <div class="lbl">From submission to first call</div>
+      </div>
+    </div>
 
-  const buildings = await getJSON('/api/buildings');
-  document.getElementById('buildings').innerHTML = buildings.rows.slice(0, 15).map(b =>
-    `<div class="row"><span title="${escape((b.names || []).join('\n'))}">${escape((b.address||'').slice(0, 38))}…</span><span class="num">${b.firms}</span></div>`
-  ).join('') || '<div class="num">No multi-firm buildings yet.</div>';
+    <div class="proof reveal" aria-label="Six firms drawn from the State Bar of California public registry, listed for illustration.">
+      <p class="p-eyebrow">Six of 42,636 firms · drawn from the public registry</p>
+      <div class="p-row">
+        <span class="p-firm">Hausfeld LLP<span class="p-city">San Francisco</span></span>
+        <span class="p-firm">Kabateck LLP<span class="p-city">Los Angeles</span></span>
+        <span class="p-firm">Nossaman LLP<span class="p-city">San Diego</span></span>
+        <span class="p-firm">Lagerlof LLP<span class="p-city">Pasadena</span></span>
+        <span class="p-firm">Buchalter LLP<span class="p-city">Sacramento</span></span>
+        <span class="p-firm">Lydecker LLP<span class="p-city">San Francisco</span></span>
+      </div>
+      <p class="p-meta">Drawn at random from State Bar of California public records. No affiliation, endorsement, or compensation. Counsel &amp; Bar is an independent directory operated by a non-attorney.</p>
+    </div>
+  </div>
+</section>
 
-  const jobs = await getJSON('/api/jobs');
-  document.getElementById('jobs').innerHTML = jobs.rows.slice(0, 10).map(j =>
-    `<div class="row"><span>${escape((j.source_name||'').slice(0, 22))}</span><span class="num">${j.records_inserted ?? 0}</span></div>`
-  ).join('') || '<div class="num">No jobs yet.</div>';
+<section class="features scroll-reveal">
+  <div class="features-inner">
+    <p class="section-eyebrow">A directory worth its name</p>
+    <h2 class="section-title">Built from the public registry. Held to a standard the registry doesn't enforce.</h2>
 
-  // Pull all firms once; client-side filter & re-render on city click.
-  const r = await getJSON('/api/firms?limit=1000');
-  FIRMS = r.rows;
+    <div class="feature-grid">
+      <div class="feat">
+        <span class="feat-num">01</span>
+        <h3>Verified, not listed</h3>
+        <p>Every attorney is checked against the California State Bar's active roll. No expired licenses. No out-of-state imitators. No bots claiming to practice law.</p>
+      </div>
+      <div class="feat">
+        <span class="feat-num">02</span>
+        <h3>Thirty minutes, by Zoom</h3>
+        <p>Pay a flat fee for a real consultation. The attorney shows up. So do you.</p>
+      </div>
+      <div class="feat">
+        <span class="feat-num">03</span>
+        <h3>Browse by city and practice area</h3>
+        <p>Filter California-licensed firms by ZIP, practice area, and firm size. View profiles, reviews, and contact details. You decide who to call — we don't route, refer, or accept fees from attorneys for placing your case with them.</p>
+      </div>
+      <div class="feat">
+        <span class="feat-num">04</span>
+        <h3>For attorneys: a premium directory listing</h3>
+        <p>A claim-and-edit profile, response-management tools, marketing audit, and Stripe payouts. Subscription-based — never a per-case referral fee.</p>
+      </div>
+    </div>
+  </div>
+</section>
 
-  // Restore prefs
-  const savedCols = parseInt(localStorage.getItem('cols') || '5', 10);
-  if (savedCols >= 3 && savedCols <= 12) colsInput.value = String(savedCols);
-  applyCols();
-  renderGrid();
-  renderList();
+<section class="closer scroll-reveal">
+  <div class="closer-inner">
+    <p class="quote">The right attorney for the right matter, found in less time than it takes to read three reviews on a competing site.</p>
+  </div>
+</section>
 
-  const savedView = localStorage.getItem('view') || 'grid';
-  setView(savedView);
+<footer>
+  <div>
+    <div class="brand-mark">Counsel <span style="color:var(--metal);font-style:italic;">&amp;</span> Bar</div>
+    <p class="legal"><strong>A directory and software platform.</strong> Not a law firm, not a lawyer referral service, not legal advice. Built from the public California State Bar registry. Not affiliated with the State Bar of California. Always verify license status with the State Bar before retaining anyone. © 2026.</p>
+  </div>
+  <div class="col">
+    <h4>Public</h4>
+    <a href="/find-a-lawyer">Find counsel</a>
+    <a href="/audit.html">Browse the directory</a>
+  </div>
+  <div class="col">
+    <h4>Attorneys</h4>
+    <a href="/signup">Claim your firm</a>
+    <a href="/login">Sign in</a>
+  </div>
+  <div class="col">
+    <h4>Legal</h4>
+    <a href="/privacy.html">Privacy Policy</a>
+    <a href="/terms.html">Terms of Service</a>
+  </div>
+</footer>
 
-  // City link drill-down: filter local FIRMS array and re-render current view.
-  document.getElementById('cities').addEventListener('click', async (e) => {
-    const a = e.target.closest('a[data-area]'); if (!a) return;
-    e.preventDefault();
-    const area = decodeURIComponent(a.dataset.area);
-    const rr = await getJSON('/api/firms?city=' + encodeURIComponent(area) + '&limit=1000');
-    FIRMS = rr.rows;
-    renderGrid(); renderList();
-    if (MAP_INITIALIZED && MARKERS) {
-      MARKERS.clearLayers();
-      for (const f of FIRMS) {
-        if (!f.lat || !f.lng) continue;
-        const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
-        const m = L.circleMarker([f.lat, f.lng], { radius, color:'#58a6ff', weight:1, fillColor:'#58a6ff', fillOpacity:0.55 });
-        MARKERS.addLayer(m);
-      }
-      const lats = FIRMS.filter(x => x.lat).map(x => x.lat);
-      const lngs = FIRMS.filter(x => x.lng).map(x => x.lng);
-      if (lats.length) {
-        MAP.setView([lats.reduce((a,b)=>a+b,0)/lats.length, lngs.reduce((a,b)=>a+b,0)/lngs.length], 13);
-      }
-    }
-    for (const row of document.querySelectorAll('#cities .row')) row.classList.toggle('active', row.querySelector('a')?.dataset.area === a.dataset.area);
-  });
-})();
+<script>
+  // Scroll-triggered reveals: editorial fade-up as sections enter viewport
+  if ('IntersectionObserver' in window && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+    const io = new IntersectionObserver((entries) => {
+      entries.forEach(e => {
+        if (e.isIntersecting) {
+          e.target.classList.add('in-view');
+          io.unobserve(e.target);
+        }
+      });
+    }, { threshold: 0.12, rootMargin: '0px 0px -10% 0px' });
+    document.querySelectorAll('.scroll-reveal').forEach(el => io.observe(el));
+  } else {
+    // No IO or reduced-motion: show everything immediately
+    document.querySelectorAll('.scroll-reveal').forEach(el => el.classList.add('in-view'));
+  }
 </script>
 </body>
 </html>
diff --git a/public/privacy.html b/public/privacy.html
new file mode 100644
index 0000000..9db4901
--- /dev/null
+++ b/public/privacy.html
@@ -0,0 +1,171 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Privacy Policy · Counsel &amp; Bar</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--noir:#0a0a0c;--noir-rise:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",-apple-system,system-ui,sans-serif}
+*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.7}
+header{padding:22px 48px;border-bottom:1px solid var(--rule);background:#050507}
+header h1{font-family:var(--serif);font-size:22px;font-weight:400;margin:0}
+header h1 a{color:var(--ink);text-decoration:none}
+header h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
+main{max-width:780px;margin:0 auto;padding:64px 28px 96px}
+.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 14px}
+h1{font-family:var(--serif);font-weight:300;font-size:48px;line-height:1.1;letter-spacing:-.02em;margin:0 0 12px}
+.lede{color:var(--ink-mute);font-size:14px;margin:0 0 36px}
+h2{font-family:var(--serif);font-weight:400;font-size:24px;color:var(--metal);margin:48px 0 14px;letter-spacing:-.005em}
+h3{font-family:var(--sans);font-weight:600;font-size:14px;text-transform:uppercase;letter-spacing:.12em;color:var(--ink);margin:24px 0 10px}
+p{color:var(--ink-soft);margin:0 0 14px}
+ul{color:var(--ink-soft);padding-left:22px}
+li{margin:6px 0}
+a{color:var(--metal)}
+strong,b{color:var(--ink);font-weight:500}
+table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
+th,td{border-bottom:1px solid var(--rule);padding:10px 12px;text-align:left;vertical-align:top}
+th{color:var(--ink-mute);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.14em}
+.toc{padding:18px 22px;background:var(--noir-rise);border:1px solid var(--rule);margin:24px 0 32px;font-size:13px}
+.toc a{display:block;padding:3px 0;color:var(--ink-soft)}
+.toc a:hover{color:var(--metal)}
+footer{padding:36px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase}
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style></head><body>
+<header><a href="/" style="display:inline-flex;align-items:center;gap:14px;text-decoration:none"><svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-priv" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-priv)" stroke-width="1"/><path fill="url(#cb-priv)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1 style="margin:0;font-family:var(--serif);font-size:22px;font-weight:400;color:var(--ink)">Counsel <span class="amp">&amp;</span> Bar</h1></a></header>
+<main>
+
+<p class="eyebrow">Privacy Policy · Effective May 3, 2026</p>
+<h1>Privacy Policy.</h1>
+<p class="lede">This policy explains what we collect, why, how we use it, who it goes to, and your rights as a California consumer under the CCPA / CPRA. Last updated 2026-05-03.</p>
+
+<div class="toc">
+  <a href="#who">1. Who we are</a>
+  <a href="#what">2. What we collect</a>
+  <a href="#why">3. Why we collect it</a>
+  <a href="#share">4. Who we share it with</a>
+  <a href="#sell">5. Sale and sharing of personal information</a>
+  <a href="#rights">6. Your CCPA / CPRA rights</a>
+  <a href="#cookies">7. Cookies and tracking</a>
+  <a href="#retention">8. How long we keep data</a>
+  <a href="#security">9. Security</a>
+  <a href="#kids">10. Children's privacy</a>
+  <a href="#contact">11. How to contact us</a>
+  <a href="#changes">12. Changes to this policy</a>
+</div>
+
+<h2 id="who">1. Who we are</h2>
+<p>"Counsel &amp; Bar" (we / us / our) operates the website at lawyers.agentabrams.com. We are a <strong>directory and software platform</strong> — not a law firm, not a lawyer referral service, not licensed to practice law in any jurisdiction. Operator: Steve Abrams, an individual, contactable at <a href="mailto:steve@designerwallcoverings.com">steve@designerwallcoverings.com</a>.</p>
+
+<h2 id="what">2. What we collect</h2>
+
+<h3>Information you give us</h3>
+<table>
+  <tr><th>What</th><th>When</th></tr>
+  <tr><td>Name, email, phone (optional)</td><td>You sign up, submit the lead form, or contact us</td></tr>
+  <tr><td>Practice area, ZIP, urgency, budget, brief case description</td><td>You submit the "Find a lawyer" form</td></tr>
+  <tr><td>Firm name, website, your bar number, professional details</td><td>You sign up as an attorney to claim a listing</td></tr>
+  <tr><td>Payment details</td><td>You purchase the EZ Upgrade or subscribe to Lawyer Pro. Card details are handled by <strong>Stripe</strong>; we never see or store full card numbers — only Stripe's customer/subscription IDs and the last 4 digits.</td></tr>
+  <tr><td>Account password (hashed, bcrypt)</td><td>You sign up</td></tr>
+</table>
+
+<h3>Information collected automatically</h3>
+<ul>
+  <li>IP address, browser user-agent string, request timestamps (server logs)</li>
+  <li>Session cookie (signed; used only to keep you logged in)</li>
+  <li>Pages you visit on the site</li>
+</ul>
+
+<h3>Information from public records</h3>
+<ul>
+  <li>California State Bar attorney profiles (name, bar number, license status, admission date, primary firm) from the Bar's <strong>public licensing database</strong></li>
+  <li>Firm contact details (address, phone, website) from publicly indexed sources and city business-license databases (e.g., LA City Active Business Licenses, NAICS 5411)</li>
+</ul>
+<p>This is data we hold <strong>about</strong> attorneys for the directory. It is sourced from public-government records. If you are an attorney and want your listing removed or corrected, see <a href="#rights">Your rights</a> below.</p>
+
+<h2 id="why">3. Why we collect it</h2>
+<ul>
+  <li><strong>Run the service</strong> — show you the directory, accept payments, deliver the EZ Upgrade, run the Lawyer Pro tier</li>
+  <li><strong>Verify identity</strong> — match an attorney to their bar number when claiming a listing</li>
+  <li><strong>Respond to you</strong> — when you ask a question or report an issue</li>
+  <li><strong>Comply with law</strong> — fraud prevention, abuse prevention, legal-process responses</li>
+  <li><strong>Improve the product</strong> — aggregated, non-identifying analytics</li>
+</ul>
+<p>We do <strong>not</strong> use your personal information for retargeted advertising on third-party platforms. We do <strong>not</strong> profile you for automated decisions that produce legal or similarly significant effects.</p>
+
+<h2 id="share">4. Who we share it with</h2>
+
+<h3>Service providers (processors), under contract</h3>
+<table>
+  <tr><th>Provider</th><th>What they get</th><th>Why</th></tr>
+  <tr><td><strong>Stripe</strong></td><td>Card details, your name, email, billing</td><td>Process payments, manage subscriptions</td></tr>
+  <tr><td><strong>Cloudflare</strong></td><td>Your IP, request headers</td><td>CDN, DDoS protection, TLS</td></tr>
+  <tr><td><strong>Kamatera</strong> (our hosting provider)</td><td>Server logs, app data at rest</td><td>Hosting</td></tr>
+</table>
+<p>Each provider processes data only to perform services for us, under contract.</p>
+
+<h3>Other firms or people</h3>
+<p>We do <strong>not</strong> automatically share your lead-form details with any law firm. <strong>You</strong> decide who to contact. If you click a "Contact this firm" link in our directory, your name, email, phone, and case description are sent only to the firm you selected, only at the moment you click. We do not auction your case to multiple firms, do not accept fees from firms for placing your case with them, and do not operate as a lawyer referral service under California Business &amp; Professions Code § 6155.</p>
+
+<h3>If we are compelled by law</h3>
+<p>Subpoena, court order, or legitimate law-enforcement request. We push back where the law allows and notify you where we're permitted.</p>
+
+<h3>If we sell or merge the business</h3>
+<p>Personal information may be transferred as part of the assets, with the same protections in place. We'll post a notice 30 days before any change in operator.</p>
+
+<h2 id="sell">5. Sale and sharing of personal information (CCPA / CPRA)</h2>
+<p>Under California law, "selling" and "sharing" personal information have specific meanings. Here's what we do and don't do:</p>
+<ul>
+  <li><strong>We do not sell your lead-form data.</strong> Consumer leads (your case description, contact details) are never sold, never licensed, and are not part of our data marketplace.</li>
+  <li><strong>Our "data marketplace" sells attorney directory data only.</strong> The CSV export at /data contains California-licensed-attorney information sourced from State Bar public records (name, bar number, license status, firm name, public business contact details). It does <strong>not</strong> contain consumer personal information collected from this site.</li>
+  <li><strong>We do not "share" personal information for cross-context behavioral advertising</strong> as defined under CPRA. We don't run advertising pixels for ad networks.</li>
+</ul>
+<p>If your reading of the law differs from ours, contact us at the address below and we'll address your concern.</p>
+
+<h2 id="rights">6. Your CCPA / CPRA rights (California residents)</h2>
+<p>If you are a California resident, you have the right to:</p>
+<ul>
+  <li><strong>Know</strong> what personal information we have about you, where it came from, why we have it, and who we shared it with in the last 12 months</li>
+  <li><strong>Access</strong> a copy of that information in a portable format</li>
+  <li><strong>Delete</strong> personal information we hold about you, subject to limited exceptions (legal-hold, fraud detection, completing a transaction you started)</li>
+  <li><strong>Correct</strong> inaccurate information we have about you</li>
+  <li><strong>Limit</strong> use of sensitive personal information (we don't collect any "sensitive" categories as defined under CPRA, but the right is yours regardless)</li>
+  <li><strong>Opt out</strong> of any sale or sharing — even though we don't sell or share consumer data, you can submit the opt-out anyway and we'll honor it</li>
+  <li><strong>Be free from retaliation</strong> for exercising any of these rights — we won't deny service, charge a different price, or downgrade your account</li>
+</ul>
+<p>To exercise any right: email <a href="mailto:steve@designerwallcoverings.com?subject=Privacy%20Request%20%E2%80%94%20Counsel%20%26%20Bar">steve@designerwallcoverings.com</a> with the subject "Privacy Request — Counsel &amp; Bar". We'll verify your identity (we may ask you to confirm details we already have) and respond within 45 days. You may use an authorized agent to submit on your behalf with proof of authorization.</p>
+<p><strong>Attorneys who appear in the directory:</strong> if you want your listing removed, suppressed, or corrected, the same process applies — email us. We'll process within 14 days. Note that we are required to display State Bar public-record information accurately; corrections are limited to facts that are demonstrably wrong against the State Bar's public roll.</p>
+
+<h2 id="cookies">7. Cookies and tracking</h2>
+<p>We use a single cookie: <strong>sid</strong>, a signed session cookie that keeps you logged in. It expires when your session ends or after 30 days of inactivity. We do not use third-party tracking cookies, advertising pixels, or fingerprinting.</p>
+<p>Server-side, we record IP and user-agent for each request as standard logs (90-day retention). These logs are not used to build profiles of your behavior across sites.</p>
+
+<h2 id="retention">8. How long we keep data</h2>
+<table>
+  <tr><th>Category</th><th>Retention</th></tr>
+  <tr><td>Account data (your login)</td><td>Until you delete your account, then 90 days for fraud / chargeback window</td></tr>
+  <tr><td>Lead-form submissions</td><td>2 years from submission, then deleted</td></tr>
+  <tr><td>Payment records</td><td>7 years (tax and accounting requirements)</td></tr>
+  <tr><td>Server access logs</td><td>90 days</td></tr>
+  <tr><td>Attorney directory data (public records)</td><td>Refreshed against State Bar source; we do not retain stale data once an attorney's license becomes inactive for &gt; 2 years</td></tr>
+</table>
+
+<h2 id="security">9. Security</h2>
+<p>Passwords are bcrypt-hashed (cost factor 12). Card details never touch our servers — Stripe handles all payment data. The site is served over HTTPS with TLS 1.2+. Access to the production database is restricted to the operator. Stripe webhook events are HMAC-SHA256-verified before any account state is changed. We don't promise the service is invulnerable — no service is — but these are the protections in place.</p>
+
+<h2 id="kids">10. Children's privacy</h2>
+<p>The service is intended for adults seeking legal services for themselves or a represented adult. We do not knowingly collect personal information from anyone under 16. If you believe a child has submitted information, contact us and we'll delete it.</p>
+
+<h2 id="contact">11. How to contact us</h2>
+<p>Email: <a href="mailto:steve@designerwallcoverings.com?subject=Privacy%20%E2%80%94%20Counsel%20%26%20Bar">steve@designerwallcoverings.com</a></p>
+<p>You may also report a concern to the California Attorney General's office at <a href="https://oag.ca.gov/privacy" target="_blank" rel="noopener">oag.ca.gov/privacy</a>. We'd prefer you talk to us first so we can fix the issue.</p>
+
+<h2 id="changes">12. Changes to this policy</h2>
+<p>We'll post the effective date at the top of this page when we make changes. For changes that materially expand how we use or share data, we'll email registered users at least 14 days before the change takes effect.</p>
+
+</main>
+<footer>Counsel &amp; Bar · A directory and software platform · Not a law firm · Not a lawyer referral service · Not legal advice</footer>
+</body></html>
diff --git a/public/terms.html b/public/terms.html
new file mode 100644
index 0000000..1233096
--- /dev/null
+++ b/public/terms.html
@@ -0,0 +1,133 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Terms of Service · Counsel &amp; Bar</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--noir:#0a0a0c;--noir-rise:#131316;--rule:#2a2724;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",-apple-system,system-ui,sans-serif}
+*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.7}
+header{padding:22px 48px;border-bottom:1px solid var(--rule);background:#050507}
+header h1{font-family:var(--serif);font-size:22px;font-weight:400;margin:0}
+header h1 a{color:var(--ink);text-decoration:none}
+header h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
+main{max-width:780px;margin:0 auto;padding:64px 28px 96px}
+.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 14px}
+h1{font-family:var(--serif);font-weight:300;font-size:48px;line-height:1.1;letter-spacing:-.02em;margin:0 0 12px}
+.lede{color:var(--ink-mute);font-size:14px;margin:0 0 36px}
+h2{font-family:var(--serif);font-weight:400;font-size:24px;color:var(--metal);margin:48px 0 14px;letter-spacing:-.005em}
+h3{font-family:var(--sans);font-weight:600;font-size:14px;text-transform:uppercase;letter-spacing:.12em;color:var(--ink);margin:24px 0 10px}
+p{color:var(--ink-soft);margin:0 0 14px}
+ul{color:var(--ink-soft);padding-left:22px}
+li{margin:6px 0}
+a{color:var(--metal)}
+strong,b{color:var(--ink);font-weight:500}
+.callout{padding:18px 22px;background:#3a2c10;border:1px solid #6b5320;color:#e8c98c;font-size:13px;font-weight:500;margin:24px 0;border-radius:0;line-height:1.55}
+.callout b{color:#fff;text-transform:uppercase;letter-spacing:.06em;font-size:11px;display:block;margin-bottom:6px}
+footer{padding:36px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase}
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style></head><body>
+<header><a href="/" style="display:inline-flex;align-items:center;gap:14px;text-decoration:none"><svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-tos" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-tos)" stroke-width="1"/><path fill="url(#cb-tos)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1 style="margin:0;font-family:var(--serif);font-size:22px;font-weight:400;color:var(--ink)">Counsel <span class="amp">&amp;</span> Bar</h1></a></header>
+<main>
+
+<p class="eyebrow">Terms of Service · Effective May 3, 2026</p>
+<h1>Terms of Service.</h1>
+<p class="lede">Plain language wherever possible. By using the site, you agree to these terms. Last updated 2026-05-03.</p>
+
+<div class="callout">
+<b>Read this part</b>
+Counsel &amp; Bar is a directory and software platform. <strong>We are not a law firm.</strong> We do not provide legal advice. We do not refer your case to attorneys for a fee. Nothing on this site creates an attorney-client relationship between you and Counsel &amp; Bar, and visiting the site does not create an attorney-client relationship between you and any attorney listed here. If you need legal advice for your situation, hire a California-licensed attorney.
+</div>
+
+<h2>1. What this site is, and what it isn't</h2>
+
+<h3>What it is</h3>
+<ul>
+  <li>A searchable directory of attorneys licensed by the State Bar of California, sourced from the State Bar's public licensing records and supplemented with publicly indexed firm contact details</li>
+  <li>A platform where attorneys can claim their listing, edit their public profile, and (with a paid subscription) access additional features</li>
+  <li>A tool you can use to view firms by city, practice area, and ZIP and to contact them directly through the channels each firm publishes</li>
+</ul>
+
+<h3>What it is <em>not</em></h3>
+<ul>
+  <li><strong>Not a law firm.</strong> No content on this site is legal advice. We have no licensed attorneys on staff, and the operator is not licensed to practice law.</li>
+  <li><strong>Not a lawyer referral service</strong> as defined under California Business &amp; Professions Code § 6155. We do not match you to attorneys, do not solicit consumers on behalf of attorneys, do not accept fees from attorneys for routing your case to them, and do not operate the kind of intake-and-route service that requires State Bar certification under § 6155.</li>
+  <li><strong>Not a guarantee of outcome.</strong> No statement on this site, including any review, rating, audit score, mockup design, or marketing-suggestion text, predicts or implies any specific legal outcome for any specific client.</li>
+</ul>
+
+<h2>2. Accuracy of information</h2>
+
+<h3>About attorney listings</h3>
+<p>We refresh attorney data periodically against the State Bar's public records. Even so, the public roll changes daily, and our copy may be out of date. <strong>Always verify license status, contact information, and practice areas directly with the attorney or the State Bar of California (calbar.ca.gov) before retaining anyone for your case.</strong> If you find an inaccuracy, email us at <a href="mailto:steve@designerwallcoverings.com?subject=Listing%20Correction">steve@designerwallcoverings.com</a> and we'll correct or remove the listing.</p>
+
+<h3>About marketing mockups</h3>
+<p>If you encounter a "mockup" or "concept design" of an attorney's website on this site, that mockup is a non-final design proposal generated by us, not by the firm. Stats, ratings, dollar amounts, and copy in the mockup are placeholders. The firm has not approved or verified that content. Mockups exist solely to demonstrate what a redesigned site could look like, and are pitched to the firm directly — not published as the firm's official content.</p>
+
+<h2>3. If you are an attorney or a firm</h2>
+
+<h3>Listings</h3>
+<p>Your listing is built from public-record information you have placed in the public domain by virtue of being licensed in California (your name, bar number, license status, primary firm of record). You can claim your listing, edit your contact details, add a profile photo, and (with the Lawyer Pro subscription) respond to public reviews and access additional engagement tools. You can also request removal at any time.</p>
+
+<h3>What you can't do on the platform</h3>
+<ul>
+  <li>You can't pay us to suppress a competitor's listing or to inflate your own search ranking beyond the publicly disclosed factors</li>
+  <li>You can't post content that misrepresents your license status, practice areas, or results in a way that would violate <strong>California Rules of Professional Conduct 7.1, 7.2, or 7.5</strong> (false or misleading communications, advertising restrictions, firm-name rules)</li>
+  <li>You can't use the platform to solicit clients in ways that violate Rule 7.3 (solicitation rules)</li>
+  <li>You agree that you alone are responsible for the lawyer-advertising compliance of any content you publish on your profile</li>
+</ul>
+
+<h3>Subscriptions and payments</h3>
+<p>The "Lawyer Pro" subscription is $29 per month, billed by Stripe, cancellable at any time from the Stripe customer portal you can access from your dashboard. The "EZ Upgrade" is a one-time $499 charge. Refund policy: Lawyer Pro subscriptions are pro-rated on cancellation; EZ Upgrade refunds are available within 14 days of payment if no work has begun.</p>
+
+<h2>4. If you are a consumer looking for an attorney</h2>
+
+<h3>How the directory works</h3>
+<p>Submit the lead form to see a list of California-licensed firms in your area that match your practice area and ZIP. <strong>You</strong> decide who to contact. We don't auto-route your details to firms. We don't sell your case. We don't charge attorneys for placing you in front of them.</p>
+
+<h3>Your responsibility</h3>
+<ul>
+  <li>Verify license status with the State Bar before retaining anyone</li>
+  <li>Don't include time-sensitive case details in form submissions — call the attorney directly for anything urgent</li>
+  <li>Don't include privileged or sensitive information until you have established an attorney-client relationship with a specific lawyer; communications through this site are <strong>not</strong> privileged</li>
+</ul>
+
+<h2>5. Data marketplace</h2>
+<p>The "Data" section of the site sells exports of California attorney directory data to business buyers. The data is sourced from State Bar public records and publicly indexed firm contacts. Buyers must comply with the State Bar's permitted-use rules and California consumer privacy law (CCPA/CPRA). The data <strong>cannot</strong> be used for unsolicited bulk SMS, robocalls, or any unlawful purpose. Buyers who violate this term will have their access terminated and any pending downloads invalidated. We may report violators to the State Bar.</p>
+
+<h2>6. Acceptable use</h2>
+<p>You agree not to: scrape the site at a rate or scale beyond ordinary browsing, attempt to access accounts that are not yours, attempt to bypass auth, post defamatory or false reviews, post content that violates anyone's IP rights, use the site to harass any individual or firm, or use the site to commit any state, federal, or international crime.</p>
+
+<h2>7. Intellectual property</h2>
+<p>The site's design, code, copy, and original content are owned by the operator. Attorney directory data (name, bar number, license status, public firm contact) is sourced from public records and not subject to our copyright. Reviews you post remain yours; you grant us a non-exclusive, royalty-free license to display them on the site and on the relevant attorney's listing. Mockups we generate are our work; the underlying firm name and city is public information, not licensed from us.</p>
+
+<h2>8. Disclaimer of warranties</h2>
+<p>The site is provided <strong>"as is"</strong> and <strong>"as available."</strong> We make no warranty that the directory is complete, current, accurate, or fit for any particular purpose. We do not warrant that any attorney listed here is competent, available, or appropriate for your matter. <strong>Hiring a lawyer is your decision; do your own diligence.</strong></p>
+
+<h2>9. Limitation of liability</h2>
+<p>To the maximum extent permitted by law, the operator's total liability for any claim arising out of your use of the site is limited to the greater of (a) the fees you paid us in the 12 months before the claim, or (b) one hundred US dollars. We are not liable for indirect, consequential, or punitive damages, lost profits, or lost data. Some jurisdictions don't allow these limitations; in those jurisdictions, our liability is limited to the maximum extent the law permits.</p>
+
+<h2>10. Indemnification</h2>
+<p>If your use of the site (or your content posted to it) causes us to be sued, you agree to defend and indemnify the operator against the claim, including reasonable legal fees, except where we are at fault.</p>
+
+<h2>11. Termination</h2>
+<p>You can stop using the site or delete your account at any time. We can suspend or terminate accounts that violate these terms, that we reasonably believe to be fraudulent, or that the law requires us to suspend.</p>
+
+<h2>12. Governing law and disputes</h2>
+<p>These terms are governed by California law. Any dispute that the parties cannot resolve informally will be brought exclusively in the state or federal courts located in Los Angeles County, California. You and we waive any right to a jury trial. <strong>You agree to first contact us at the address below to resolve any complaint informally before filing suit.</strong></p>
+
+<h2>13. Changes to these terms</h2>
+<p>We may update these terms. We'll post the new effective date at the top of the page. For material changes, we'll notify registered users by email at least 14 days before the new terms take effect. Your continued use of the site after the effective date counts as acceptance.</p>
+
+<h2>14. Contact</h2>
+<p>Email <a href="mailto:steve@designerwallcoverings.com?subject=Terms%20%E2%80%94%20Counsel%20%26%20Bar">steve@designerwallcoverings.com</a></p>
+
+<h2>15. Severability</h2>
+<p>If any provision of these terms is held unenforceable, the remaining provisions stay in effect.</p>
+
+</main>
+<footer>Counsel &amp; Bar · A directory and software platform · Not a law firm · Not a lawyer referral service · Not legal advice</footer>
+</body></html>
diff --git a/src/lib/match_firms.ts b/src/lib/match_firms.ts
new file mode 100644
index 0000000..c85d999
--- /dev/null
+++ b/src/lib/match_firms.ts
@@ -0,0 +1,180 @@
+/**
+ * Match a lead to top-N firms.
+ *
+ * Algorithm:
+ *  1. Compute the zip's centroid from our existing geocoded firms in that zip
+ *     (we have lat/lng on ~7,500 firms — most CA zips are covered). Fall back
+ *     to LA County centroid if the zip is unknown.
+ *  2. Rank candidate firms by:
+ *       - distance from the lead's zip centroid (asc)
+ *       - has-website (we can route the lead to a real online presence)
+ *       - attorney_count (bigger firms field calls faster)
+ *       - latest marketing_score (proxy for "is this firm responsive online")
+ *  3. Return the top N firm IDs (with metadata for display).
+ *
+ * Practice area is stored on the lead but NOT filtered against
+ * organizations.practice_areas because that column is mostly NULL today —
+ * admin reviews the queue and can manually re-route or veto matches.
+ */
+import { query } from '../db/pool.ts';
+
+const LA_CENTROID = { lat: 34.0522, lng: -118.2437 };
+
+// California bounding box. Used as a hard filter because the `state` column
+// is unreliable — CalBar parser left state NULL on many records and the DB
+// schema defaults to 'CA', so out-of-state attorneys got tagged CA. Lat/lng
+// is the source of truth.
+const CA_BOUNDS = { latMin: 32.5, latMax: 42.05, lngMin: -124.5, lngMax: -114.0 };
+
+export type MatchedFirm = {
+  id: number;
+  name: string;
+  address: string | null;
+  city: string | null;
+  zip: string | null;
+  phone: string | null;
+  website: string | null;
+  firm_size_band: string | null;
+  attorney_count: number | null;
+  marketing_score: number | null;
+  distance_km: number | null;
+};
+
+export async function matchFirmsForLead(opts: {
+  zip: string | null;
+  state?: string;        // default 'CA'
+  limit?: number;        // default 5
+  radiusKm?: number;     // default 80 (~50 mi). null = no cap.
+}): Promise<MatchedFirm[]> {
+  const limit = opts.limit ?? 5;
+  const state = opts.state ?? 'CA';
+  const radiusKm = opts.radiusKm === undefined ? 80 : opts.radiusKm;
+
+  // Find zip centroid. Two-step fallback so we don't claim a 400-mile-away
+  // LA firm is "near" a Truckee user. Order:
+  //   1. exact zip match
+  //   2. first-3-digit zip prefix (same SCF region)
+  //   3. no centroid → return empty
+  let centroid: { lat: number; lng: number } | null = null;
+  if (opts.zip) {
+    const r1 = await query<{ lat: number; lng: number; n: number }>(
+      `SELECT AVG(lat)::float AS lat, AVG(lng)::float AS lng, COUNT(*)::int AS n
+       FROM organizations
+       WHERE type='law_firm' AND zip = $1
+         AND lat BETWEEN $2 AND $3 AND lng BETWEEN $4 AND $5`,
+      [opts.zip, CA_BOUNDS.latMin, CA_BOUNDS.latMax, CA_BOUNDS.lngMin, CA_BOUNDS.lngMax]);
+    if (r1.rows[0] && r1.rows[0].n > 0) {
+      centroid = { lat: r1.rows[0].lat, lng: r1.rows[0].lng };
+    } else {
+      const prefix = opts.zip.slice(0, 3);
+      if (prefix.length === 3) {
+        const r2 = await query<{ lat: number; lng: number; n: number }>(
+          `SELECT AVG(lat)::float AS lat, AVG(lng)::float AS lng, COUNT(*)::int AS n
+           FROM organizations
+           WHERE type='law_firm' AND zip LIKE $1
+             AND lat BETWEEN $2 AND $3 AND lng BETWEEN $4 AND $5`,
+          [prefix + '%', CA_BOUNDS.latMin, CA_BOUNDS.latMax, CA_BOUNDS.lngMin, CA_BOUNDS.lngMax]);
+        if (r2.rows[0] && r2.rows[0].n > 0) {
+          centroid = { lat: r2.rows[0].lat, lng: r2.rows[0].lng };
+        }
+      }
+    }
+  } else {
+    centroid = LA_CENTROID;
+  }
+  if (!centroid) return [];
+
+  // Distance via the great-circle haversine, in km.
+  // 6371 km = Earth radius. Sin/cos in degrees → radians via radians().
+  const r = await query<MatchedFirm>(`
+    SELECT
+      o.id, o.name, o.address, o.city, o.zip, o.phone, o.website,
+      o.firm_size_band, o.attorney_count,
+      (SELECT marketing_score FROM site_audits a
+         WHERE a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
+         ORDER BY a.audited_at DESC LIMIT 1) AS marketing_score,
+      (
+        2 * 6371 *
+        ASIN(SQRT(
+          POW(SIN(RADIANS((o.lat - $1) / 2)), 2) +
+          COS(RADIANS($1)) * COS(RADIANS(o.lat)) *
+          POW(SIN(RADIANS((o.lng - $2) / 2)), 2)
+        ))
+      )::float AS distance_km
+    FROM organizations o
+    WHERE o.type='law_firm'
+      AND o.lat BETWEEN $3 AND $4
+      AND o.lng BETWEEN $5 AND $6
+    ORDER BY
+      distance_km ASC,
+      (o.website IS NOT NULL) DESC,
+      o.attorney_count DESC NULLS LAST,
+      (SELECT marketing_score FROM site_audits a
+         WHERE a.organization_id = o.id AND a.status_code BETWEEN 200 AND 399
+         ORDER BY a.audited_at DESC LIMIT 1) DESC NULLS LAST
+    LIMIT $7
+  `, [centroid.lat, centroid.lng,
+      CA_BOUNDS.latMin, CA_BOUNDS.latMax, CA_BOUNDS.lngMin, CA_BOUNDS.lngMax,
+      limit]);
+  if (radiusKm == null) return r.rows;
+  return r.rows.filter(f => f.distance_km == null || f.distance_km <= radiusKm);
+}
+
+export type EmptyReason = 'ok' | 'no_zip' | 'no_anchor' | 'no_firms_in_radius';
+
+/**
+ * Same as matchFirmsForLead, but reports WHY the result is empty so the UI
+ * can render an honest empty state. Three distinct cases:
+ *   - no_zip: caller didn't supply a ZIP at all
+ *   - no_anchor: ZIP given but no exact-match or 3-prefix-region firm exists
+ *                (we can't compute proximity at all — "widen radius" is a lie)
+ *   - no_firms_in_radius: anchor exists but the radius cap excluded everyone
+ */
+export async function matchFirmsWithReason(opts: {
+  zip: string | null;
+  limit?: number;
+  radiusKm?: number;
+}): Promise<{ firms: MatchedFirm[]; reason: EmptyReason; anchor: { lat: number; lng: number } | null }> {
+  const limit = opts.limit ?? 5;
+  const radiusKm = opts.radiusKm === undefined ? 80 : opts.radiusKm;
+
+  if (!opts.zip) {
+    const firms = await matchFirmsForLead({ zip: null, limit, radiusKm });
+    return { firms, reason: firms.length ? 'ok' : 'no_zip', anchor: LA_CENTROID };
+  }
+
+  // Replicate centroid resolution to get the anchor.
+  let anchor: { lat: number; lng: number } | null = null;
+  const r1 = await query<{ lat: number; lng: number; n: number }>(
+    `SELECT AVG(lat)::float AS lat, AVG(lng)::float AS lng, COUNT(*)::int AS n
+     FROM organizations
+     WHERE type='law_firm' AND zip = $1
+       AND lat BETWEEN $2 AND $3 AND lng BETWEEN $4 AND $5`,
+    [opts.zip, CA_BOUNDS.latMin, CA_BOUNDS.latMax, CA_BOUNDS.lngMin, CA_BOUNDS.lngMax]);
+  if (r1.rows[0] && r1.rows[0].n > 0) {
+    anchor = { lat: r1.rows[0].lat, lng: r1.rows[0].lng };
+  } else {
+    const prefix = opts.zip.slice(0, 3);
+    if (prefix.length === 3) {
+      const r2 = await query<{ lat: number; lng: number; n: number }>(
+        `SELECT AVG(lat)::float AS lat, AVG(lng)::float AS lng, COUNT(*)::int AS n
+         FROM organizations
+         WHERE type='law_firm' AND zip LIKE $1
+           AND lat BETWEEN $2 AND $3 AND lng BETWEEN $4 AND $5`,
+        [prefix + '%', CA_BOUNDS.latMin, CA_BOUNDS.latMax, CA_BOUNDS.lngMin, CA_BOUNDS.lngMax]);
+      if (r2.rows[0] && r2.rows[0].n > 0) {
+        anchor = { lat: r2.rows[0].lat, lng: r2.rows[0].lng };
+      }
+    }
+  }
+
+  if (!anchor) {
+    return { firms: [], reason: 'no_anchor', anchor: null };
+  }
+
+  const firms = await matchFirmsForLead({ zip: opts.zip, limit, radiusKm });
+  if (firms.length === 0) {
+    return { firms: [], reason: 'no_firms_in_radius', anchor };
+  }
+  return { firms, reason: 'ok', anchor };
+}
diff --git a/src/lib/mockup_templates.ts b/src/lib/mockup_templates.ts
new file mode 100644
index 0000000..26de159
--- /dev/null
+++ b/src/lib/mockup_templates.ts
@@ -0,0 +1,297 @@
+/**
+ * Three full-page HTML mockup templates for law-firm websites. Stamped with
+ * firm-specific data (name, phone, address, city) and rendered to a PNG by
+ * src/enrich/render_mockups.ts. Each template is deliberately distinct in
+ * type, palette, and tone so the prospect can pick a direction.
+ *
+ * VARIANT NAMES (don't change — referenced from DB & UI):
+ *   'a' — Editorial Noir       (Cormorant + Inter, near-black, champagne)
+ *   'b' — Clean Modern Light   (Playfair + Inter, ivory, navy)
+ *   'c' — Bold Confident       (Plus Jakarta Sans, charcoal, oxblood)
+ *
+ * NOTE: every template renders a prominent "MOCKUP — verify all info" banner
+ * at the top so a screenshot pasted into a meeting can never be mistaken for
+ * a live site. The banner is sticky / above-the-fold.
+ */
+
+export type MockupInput = {
+  firmName: string;
+  city: string | null;
+  phone: string | null;
+  address: string | null;
+};
+
+const esc = (s: string | null | undefined): string =>
+  String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
+
+const DISCLAIMER_BANNER = `
+<div style="position:sticky;top:0;left:0;right:0;background:linear-gradient(180deg,#2a2010 0%,#1a1611 100%);color:#d4a04a;font-family:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;font-size:11px;font-weight:500;letter-spacing:.22em;text-transform:uppercase;padding:11px 22px;text-align:center;z-index:9999;border-bottom:1px solid #4a3a1a">
+  <span style="color:#e8c98c;font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-size:13px;letter-spacing:.04em;text-transform:none;margin-right:10px">Proposal</span>
+  Mockup design · firm details to be verified · Counsel &amp; Bar
+</div>
+`;
+
+// ─── Variant A — Editorial Noir ────────────────────────────────────────────
+export function variantA(d: MockupInput): string {
+  const city = d.city || 'California';
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>${esc(d.firmName)} — Attorneys</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{font-family:'Inter',-apple-system,system-ui,sans-serif;font-weight:300;color:#f4f1ea;background:#0a0a0c;font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}
+.serif{font-family:'Cormorant Garamond','Cormorant',Georgia,serif}
+header{padding:26px 64px;border-bottom:1px solid #2a2724;display:flex;align-items:center;justify-content:space-between;background:#050507}
+header .logo{font-family:'Cormorant Garamond',serif;font-size:24px;font-weight:400;letter-spacing:.005em}
+header .logo em{font-style:italic;color:#b89968;padding:0 4px}
+nav a{color:#8b857a;font-size:11px;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;margin-left:32px;font-weight:400}
+nav a:hover{color:#f4f1ea}
+nav a.cta{color:#b89968;border:1px solid #b89968;padding:9px 18px;letter-spacing:.16em}
+.hero{padding:120px 64px 100px;background:radial-gradient(ellipse at 30% 0%,#1a1815 0%,#0a0a0c 60%);position:relative}
+.eyebrow{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:#b89968;font-weight:500;margin-bottom:28px}
+h1{font-family:'Cormorant Garamond',serif;font-size:74px;font-weight:300;line-height:1.05;letter-spacing:-.02em;max-width:14ch;margin-bottom:32px}
+h1 em{font-style:italic;color:#b89968;font-weight:400}
+.lede{font-size:18px;color:#d8d2c5;max-width:54ch;font-weight:300;line-height:1.55;margin-bottom:48px}
+.cta-row{display:flex;gap:18px;align-items:center}
+.btn{display:inline-block;padding:16px 32px;font-size:11px;letter-spacing:.22em;text-transform:uppercase;font-weight:500;text-decoration:none;border:1px solid #b89968;background:#b89968;color:#0a0a0c}
+.btn.ghost{background:transparent;color:#b89968}
+.creds{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:0 64px;border-top:1px solid #2a2724;border-bottom:1px solid #2a2724}
+.cred{padding:36px 28px;border-right:1px solid #2a2724}
+.cred:last-child{border-right:0}
+.cred .num{font-family:'Cormorant Garamond',serif;font-size:46px;font-weight:300;line-height:1;color:#b89968;font-variant-numeric:tabular-nums}
+.cred .lbl{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#8b857a;margin-top:10px}
+.practice{padding:120px 64px}
+h2{font-family:'Cormorant Garamond',serif;font-size:44px;font-weight:300;letter-spacing:-.01em;margin-bottom:8px}
+h2 em{font-style:italic;color:#b89968;font-weight:400}
+.section-eyebrow{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:#8b857a;font-weight:500;margin-bottom:18px}
+.areas{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:#2a2724;margin-top:48px;border:1px solid #2a2724}
+.area{background:#131316;padding:36px 28px;min-height:180px;display:flex;flex-direction:column;justify-content:flex-end}
+.area .n{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:14px;color:#b89968;margin-bottom:14px}
+.area .t{font-family:'Cormorant Garamond',serif;font-size:24px;font-weight:400;line-height:1.15;color:#f4f1ea}
+.contact{padding:100px 64px;border-top:1px solid #2a2724;background:#050507;display:grid;grid-template-columns:1fr 1fr;gap:64px;align-items:center}
+.contact .phone{font-family:'Cormorant Garamond',serif;font-size:54px;font-weight:300;color:#b89968;letter-spacing:-.01em;line-height:1.1}
+.contact .addr{color:#d8d2c5;font-size:15px;line-height:1.7}
+footer{padding:32px 64px;font-size:11px;color:#8b857a;letter-spacing:.14em;text-transform:uppercase;border-top:1px solid #2a2724}
+</style></head><body>
+${DISCLAIMER_BANNER}
+<header>
+  <div class="logo">${esc(d.firmName).split(' ')[0]} <em>&amp;</em> Associates</div>
+  <nav><a href="#">Practice</a><a href="#">Attorneys</a><a href="#">About</a><a href="#" class="cta">Schedule consult</a></nav>
+</header>
+<section class="hero">
+  <div class="eyebrow">Attorneys at Law · ${esc(city)}</div>
+  <h1>${esc(d.firmName)}.<br><em>Counsel built for outcomes.</em></h1>
+  <p class="lede">A boutique ${esc(city)} practice serving discerning clients across complex civil, family, and business matters. Discreet. Deliberate. Decisive.</p>
+  <div class="cta-row"><a href="#" class="btn">Request consultation</a><a href="#" class="btn ghost">Our practice areas</a></div>
+</section>
+<div class="creds">
+  <div class="cred"><div class="num">—</div><div class="lbl">Years in practice (firm to verify)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Total clients served (firm to verify)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Public ratings (firm to verify)</div></div>
+</div>
+<section class="practice">
+  <div class="section-eyebrow">Practice areas</div>
+  <h2>Where the firm <em>concentrates.</em></h2>
+  <div class="areas">
+    <div class="area"><div class="n">— 01</div><div class="t">Personal Injury</div></div>
+    <div class="area"><div class="n">— 02</div><div class="t">Family Law</div></div>
+    <div class="area"><div class="n">— 03</div><div class="t">Business Litigation</div></div>
+    <div class="area"><div class="n">— 04</div><div class="t">Estate Planning</div></div>
+  </div>
+</section>
+<section class="contact">
+  <div>
+    <div class="section-eyebrow">Speak with the firm</div>
+    <div class="phone">${esc(d.phone || '(310) 555-0100')}</div>
+  </div>
+  <div class="addr">${esc(d.address || `${city}, California`)}<br>By appointment · evenings &amp; weekends available<br>Free 30-minute consultation</div>
+</section>
+<footer>© ${new Date().getFullYear()} ${esc(d.firmName)} · Attorney advertising · Prior results do not guarantee a similar outcome</footer>
+</body></html>`;
+}
+
+// ─── Variant B — Clean Modern Light ────────────────────────────────────────
+export function variantB(d: MockupInput): string {
+  const city = d.city || 'California';
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>${esc(d.firmName)}</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=Playfair+Display:ital,wght@0,400;0,500;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{font-family:'Inter',sans-serif;color:#1a2b4a;background:#f9f7f3;font-size:15px;line-height:1.6;font-weight:400;-webkit-font-smoothing:antialiased}
+.serif{font-family:'Playfair Display',Georgia,serif}
+header{padding:24px 64px;display:flex;align-items:center;justify-content:space-between;background:#fff;border-bottom:1px solid #e8e2d6}
+header .logo{font-family:'Playfair Display',serif;font-size:22px;color:#1a2b4a;font-weight:500}
+nav a{color:#5a6b85;font-size:13px;text-decoration:none;margin-left:36px;font-weight:500}
+nav a:hover{color:#1a2b4a}
+nav a.cta{background:#1a2b4a;color:#fff;padding:11px 22px;border-radius:2px;font-weight:500}
+.hero{padding:120px 64px;display:grid;grid-template-columns:1.4fr 1fr;gap:80px;align-items:center;background:#fff}
+.hero .badge{display:inline-block;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#a87935;background:#fbf3e3;padding:6px 14px;border-radius:99px;font-weight:600;margin-bottom:24px}
+h1{font-family:'Playfair Display',serif;font-size:64px;font-weight:400;line-height:1.05;letter-spacing:-.02em;color:#1a2b4a;margin-bottom:24px}
+h1 em{font-style:italic;color:#a87935;font-weight:400}
+.lede{font-size:17px;color:#5a6b85;max-width:48ch;line-height:1.65;margin-bottom:36px;font-weight:400}
+.cta-row{display:flex;gap:14px}
+.btn{display:inline-block;padding:14px 26px;font-size:14px;font-weight:500;text-decoration:none;border-radius:2px;transition:all 200ms ease}
+.btn.solid{background:#1a2b4a;color:#fff;border:1px solid #1a2b4a}
+.btn.solid:hover{background:#0f1d36}
+.btn.ghost{background:transparent;color:#1a2b4a;border:1px solid #c7cdd9}
+.hero-card{background:#1a2b4a;color:#fff;padding:48px 36px;border-radius:4px;box-shadow:0 24px 60px rgba(26,43,74,.18)}
+.hero-card h3{font-family:'Playfair Display',serif;font-size:24px;font-weight:400;margin-bottom:18px}
+.hero-card .row{display:flex;justify-content:space-between;padding:14px 0;border-bottom:1px solid rgba(255,255,255,.12);font-size:14px}
+.hero-card .row:last-child{border-bottom:0}
+.hero-card .row b{color:#e8c98c;font-weight:500}
+.hero-card .phone-cta{margin-top:24px;text-align:center;background:#a87935;padding:16px;border-radius:2px;font-family:'Playfair Display',serif;font-size:24px;font-weight:500}
+.creds{padding:80px 64px;background:#f4f0e8;display:grid;grid-template-columns:repeat(3,1fr);gap:40px}
+.cred{text-align:center}
+.cred .num{font-family:'Playfair Display',serif;font-size:54px;font-weight:400;color:#a87935;line-height:1;margin-bottom:8px}
+.cred .lbl{font-size:13px;color:#5a6b85;font-weight:500}
+.practice{padding:100px 64px;background:#fff}
+.practice-head{text-align:center;max-width:680px;margin:0 auto 64px}
+h2{font-family:'Playfair Display',serif;font-size:42px;font-weight:400;color:#1a2b4a;letter-spacing:-.01em;margin-bottom:14px}
+h2 em{font-style:italic;color:#a87935}
+.section-sub{color:#5a6b85;font-size:16px}
+.areas{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
+.area{padding:36px 28px;border:1px solid #e8e2d6;border-radius:4px;background:#fff;transition:all 200ms ease}
+.area:hover{border-color:#a87935;transform:translateY(-2px)}
+.area .icon{width:44px;height:44px;background:#fbf3e3;color:#a87935;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Playfair Display',serif;font-size:20px;font-weight:500;margin-bottom:18px}
+.area h4{font-family:'Playfair Display',serif;font-size:22px;font-weight:500;color:#1a2b4a;margin-bottom:10px}
+.area p{font-size:14px;color:#5a6b85;line-height:1.55}
+footer{padding:36px 64px;font-size:13px;color:#7a8aa3;background:#1a2b4a;color:#c7cdd9;text-align:center}
+footer b{color:#fff}
+</style></head><body>
+${DISCLAIMER_BANNER}
+<header>
+  <div class="logo">${esc(d.firmName)}</div>
+  <nav><a href="#">Attorneys</a><a href="#">Practice</a><a href="#">Results</a><a href="#">Reviews</a><a href="#" class="cta">Free consultation</a></nav>
+</header>
+<section class="hero">
+  <div>
+    <span class="badge">Established · ${esc(city)}</span>
+    <h1>Trusted counsel for <em>life's</em> defining moments.</h1>
+    <p class="lede">${esc(d.firmName)} represents individuals and businesses in ${esc(city)} and across California with the precision your matter deserves and the compassion you deserve.</p>
+    <div class="cta-row"><a href="#" class="btn solid">Schedule a free consult</a><a href="#" class="btn ghost">Read client reviews →</a></div>
+  </div>
+  <div class="hero-card">
+    <h3>What to expect</h3>
+    <div class="row"><span>First call</span><b>30 min · free</b></div>
+    <div class="row"><span>Same-day response</span><b>Mon – Sun</b></div>
+    <div class="row"><span>Fees</span><b>Contingency available</b></div>
+    <div class="row"><span>Languages</span><b>English · Spanish</b></div>
+    <div class="phone-cta">${esc(d.phone || '(310) 555-0100')}</div>
+  </div>
+</section>
+<section class="creds">
+  <div class="cred"><div class="num">—</div><div class="lbl">Years in ${esc(city)} (firm to verify)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Cases handled (firm to verify)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Reviews (firm to verify)</div></div>
+</section>
+<section class="practice">
+  <div class="practice-head">
+    <h2>Where we <em>excel.</em></h2>
+    <p class="section-sub">Five focused practice areas — every matter handled by an attorney, never paralegal-only.</p>
+  </div>
+  <div class="areas">
+    <div class="area"><div class="icon">PI</div><h4>Personal Injury</h4><p>Auto, slip-and-fall, premises liability — no fee unless we recover.</p></div>
+    <div class="area"><div class="icon">FL</div><h4>Family Law</h4><p>Divorce, custody, support, prenuptial agreements with discretion.</p></div>
+    <div class="area"><div class="icon">BL</div><h4>Business Litigation</h4><p>Contract disputes, partnership wind-downs, IP, employment matters.</p></div>
+  </div>
+</section>
+<footer>© ${new Date().getFullYear()} <b>${esc(d.firmName)}</b> · ${esc(d.address || city + ', CA')} · Attorney advertising</footer>
+</body></html>`;
+}
+
+// ─── Variant C — Bold Confident ────────────────────────────────────────────
+export function variantC(d: MockupInput): string {
+  const city = d.city || 'California';
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<title>${esc(d.firmName)}</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=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{font-family:'Plus Jakarta Sans',-apple-system,system-ui,sans-serif;color:#16181d;background:#fff;font-size:16px;line-height:1.55;font-weight:500;-webkit-font-smoothing:antialiased}
+header{padding:22px 64px;background:#16181d;color:#fff;display:flex;align-items:center;justify-content:space-between;position:sticky;top:32px;z-index:100}
+header .logo{font-size:20px;font-weight:800;letter-spacing:-.02em}
+header .logo span{color:#b8332e}
+nav a{color:#cfd1d6;font-size:14px;text-decoration:none;margin-left:32px;font-weight:600}
+nav a:hover{color:#fff}
+nav a.cta{background:#b8332e;color:#fff;padding:11px 22px;border-radius:6px}
+.hero{padding:140px 64px 100px;background:linear-gradient(180deg,#16181d 0%,#1f242c 100%);color:#fff}
+.tag{display:inline-block;background:rgba(184,51,46,.18);color:#ff8a85;padding:7px 14px;border-radius:99px;font-size:12px;font-weight:700;letter-spacing:.04em;margin-bottom:24px;border:1px solid rgba(184,51,46,.4)}
+h1{font-size:84px;font-weight:800;line-height:.98;letter-spacing:-.035em;max-width:18ch;margin-bottom:28px}
+h1 .accent{color:#b8332e}
+.lede{font-size:20px;color:#a8acb8;max-width:56ch;font-weight:500;line-height:1.5;margin-bottom:40px}
+.cta-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
+.btn{display:inline-flex;align-items:center;gap:10px;padding:18px 30px;font-size:15px;font-weight:700;text-decoration:none;border-radius:8px;transition:all 200ms ease}
+.btn.solid{background:#b8332e;color:#fff}
+.btn.solid:hover{background:#9c2a26;transform:translateY(-1px)}
+.btn.ghost{background:rgba(255,255,255,.06);color:#fff;border:1px solid rgba(255,255,255,.18)}
+.creds{padding:60px 64px;background:#16181d;display:grid;grid-template-columns:repeat(4,1fr);gap:40px;border-top:1px solid #2a2f38}
+.cred{color:#fff}
+.cred .num{font-size:42px;font-weight:800;letter-spacing:-.025em;line-height:1;margin-bottom:6px}
+.cred .num .accent{color:#b8332e}
+.cred .lbl{font-size:13px;color:#8e94a3;font-weight:600}
+.practice{padding:120px 64px;background:#fff}
+.practice-head{margin-bottom:64px;display:grid;grid-template-columns:1fr 1fr;align-items:end;gap:40px}
+.section-eyebrow{font-size:13px;font-weight:700;color:#b8332e;letter-spacing:.08em;text-transform:uppercase;margin-bottom:16px}
+h2{font-size:54px;font-weight:800;letter-spacing:-.025em;line-height:1.05;color:#16181d}
+.practice-head .lede2{color:#5a6168;font-size:17px;font-weight:500}
+.areas{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:#e8eaee;border:1px solid #e8eaee;border-radius:14px;overflow:hidden}
+.area{background:#fff;padding:48px;display:grid;grid-template-columns:auto 1fr;gap:28px;align-items:start}
+.area .nbr{font-size:46px;font-weight:800;color:#b8332e;letter-spacing:-.04em;line-height:.9}
+.area h4{font-size:22px;font-weight:700;margin-bottom:10px;letter-spacing:-.015em}
+.area p{font-size:14px;color:#5a6168;font-weight:500;line-height:1.55}
+.cta-band{padding:80px 64px;background:#b8332e;color:#fff;display:flex;justify-content:space-between;align-items:center;gap:60px}
+.cta-band h3{font-size:38px;font-weight:800;letter-spacing:-.02em;line-height:1.15}
+.cta-band .phone{font-size:42px;font-weight:800;letter-spacing:-.025em}
+.cta-band .phone .lbl{display:block;font-size:13px;font-weight:600;opacity:.8;letter-spacing:.06em;margin-bottom:6px}
+footer{padding:36px 64px;background:#0e1014;color:#6e7383;font-size:13px;font-weight:500;text-align:center}
+footer b{color:#fff}
+</style></head><body>
+${DISCLAIMER_BANNER}
+<header>
+  <div class="logo">${esc(d.firmName.split(' ')[0])}<span>.</span></div>
+  <nav><a href="#">Practice</a><a href="#">Wins</a><a href="#">About</a><a href="#" class="cta">Free case review →</a></nav>
+</header>
+<section class="hero">
+  <span class="tag">★ Trial-tested · ${esc(city)}</span>
+  <h1>We don't <span class="accent">settle.</span><br>We <span class="accent">win.</span></h1>
+  <p class="lede">${esc(d.firmName)} fights for ${esc(city)} clients in personal injury, civil litigation, and contested family matters. Aggressive when you need it. Strategic when it matters.</p>
+  <div class="cta-row"><a href="#" class="btn solid">Get my free case review →</a><a href="#" class="btn ghost">Watch results video</a></div>
+</section>
+<section class="creds">
+  <div class="cred"><div class="num">—</div><div class="lbl">Total recovered (firm to verify)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Win/settlement rate (firm to verify)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Response window (firm to set)</div></div>
+  <div class="cred"><div class="num">—</div><div class="lbl">Fee structure (firm to set)</div></div>
+</section>
+<section class="practice">
+  <div class="practice-head">
+    <div>
+      <div class="section-eyebrow">What we fight for</div>
+      <h2>Five practice areas. One standard: <em style="font-style:italic">excellence.</em></h2>
+    </div>
+    <p class="lede2">From the first call to the final verdict, your case is handled by an attorney who knows the courthouse, the judges, and the opposition's playbook.</p>
+  </div>
+  <div class="areas">
+    <div class="area"><div class="nbr">01</div><div><h4>Personal Injury</h4><p>Auto accidents, premises, product liability — for ${esc(city)} clients. Specifics and case results to be added by the firm.</p></div></div>
+    <div class="area"><div class="nbr">02</div><div><h4>Civil Litigation</h4><p>Contract disputes, partnership disputes, employment matters — courtroom-ready from day one.</p></div></div>
+    <div class="area"><div class="nbr">03</div><div><h4>Family Law</h4><p>Contested divorce, custody, support modifications. Discreet, decisive, prepared.</p></div></div>
+    <div class="area"><div class="nbr">04</div><div><h4>Criminal Defense</h4><p>DUI, white-collar, felony defense. Former prosecutors who know how the other side thinks.</p></div></div>
+  </div>
+</section>
+<section class="cta-band">
+  <h3>Free case review.<br>Speak with an attorney today.</h3>
+  <div class="phone"><span class="lbl">Call now · 24/7</span>${esc(d.phone || '(310) 555-0100')}</div>
+</section>
+<footer>© ${new Date().getFullYear()} <b>${esc(d.firmName)}</b> · ${esc(d.address || city + ', CA')} · Attorney advertising · Prior results do not guarantee a similar outcome</footer>
+</body></html>`;
+}
+
+export const VARIANTS: Array<{ key: 'a' | 'b' | 'c'; label: string; render: (d: MockupInput) => string }> = [
+  { key: 'a', label: 'Editorial Noir',     render: variantA },
+  { key: 'b', label: 'Clean Modern Light', render: variantB },
+  { key: 'c', label: 'Bold Confident',     render: variantC },
+];
diff --git a/src/server/data_market.ts b/src/server/data_market.ts
new file mode 100644
index 0000000..89d45c8
--- /dev/null
+++ b/src/server/data_market.ts
@@ -0,0 +1,666 @@
+/**
+ * Data marketplace — sell lists of California-licensed attorneys filtered by city.
+ *
+ *   GET  /data                       — public catalog: pick cities, see counts + price
+ *   POST /data/checkout              — creates data_orders row + Stripe Checkout (one_time | subscription)
+ *   GET  /data/thanks?session_id=…   — Stripe redirect, marks order paid, gives download link
+ *   GET  /data/download/:token       — gated CSV stream (live one_time + active subscription)
+ *   GET  /admin/data-orders          — admin queue
+ *
+ * Pricing model:
+ *   one_time:     base $49 + $0.04 per attorney, capped at $299 / city
+ *                 multi-city (≥3 cities) = 30 % off
+ *   subscription: $99 / month, unlimited California cities, monthly refresh
+ *
+ * NOTE: Stripe keys are LIVE — sessions only created from authenticated user
+ * intent (POST after explicit buy click), never on smoke / GET.
+ */
+import express from 'express';
+import crypto from 'node:crypto';
+import Stripe from 'stripe';
+import { query } from '../db/pool.ts';
+import { requireAdmin } from './auth.ts';
+
+const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || '';
+const stripe: Stripe | null = STRIPE_KEY.startsWith('sk_') ? new Stripe(STRIPE_KEY) : null;
+
+const SUBSCRIPTION_CENTS_MONTHLY = 9900;
+const ONETIME_BASE_CENTS = 4900;
+const ONETIME_PER_ATTORNEY_CENTS = 4;     // $0.04
+const ONETIME_CAP_PER_CITY_CENTS = 29900; // $299
+const BUNDLE_DISCOUNT_PCT = 30;           // 3+ cities
+
+const router = express.Router();
+router.use(express.urlencoded({ extended: false }));
+
+// ─── helpers ──────────────────────────────────────────────────────────────
+
+function esc(s: any): string {
+  if (s === null || s === undefined) return '';
+  return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
+}
+
+function shellHead(title: string, opts: { wide?: boolean } = {}): string {
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · Counsel &amp; Bar</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;--rule:#2a2724;--rule-faint:#1a1815;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;--good:#34d399;--warn:#d4a04a;--bad:#d87a7a;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",-apple-system,system-ui,sans-serif}
+*{box-sizing:border-box}html{-webkit-font-smoothing:antialiased}
+body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55}
+::selection{background:var(--metal);color:var(--noir)}
+a{color:var(--metal);text-decoration:none}a:hover{color:var(--metal-glow)}
+header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
+header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400}
+header.brand h1 a{color:var(--ink)}
+header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
+.topnav{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);display:flex;gap:24px}
+.topnav a{color:var(--ink-mute)}.topnav a:hover{color:var(--ink)}
+main{max-width:${opts.wide ? '1240px' : '980px'};margin:0 auto;padding:64px 48px}
+@media (max-width:520px){main{padding:40px 20px}header.brand{padding:16px 20px;flex-wrap:wrap;gap:10px}.topnav{flex-wrap:wrap;gap:14px;font-size:10px;width:100%}}
+.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
+h1.display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,68px);line-height:1.05;letter-spacing:-.02em;margin:0 0 16px;color:var(--ink)}
+h1.display em{font-style:italic;color:var(--metal);font-weight:400}
+.lede{font-size:17px;color:var(--ink-soft);max-width:60ch;margin:0 0 40px;font-weight:300}
+.flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
+.flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+.flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.muted{color:var(--ink-mute);font-size:13px}
+button,input,select{font:inherit;font-family:var(--sans);color:var(--ink);background:var(--noir-rise);border:1px solid var(--rule);padding:13px 16px;transition:border-color 180ms ease}
+input:focus,select:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
+button.btn{background:var(--metal);border-color:var(--metal);color:var(--noir);font-weight:500;font-size:12px;letter-spacing:.18em;text-transform:uppercase;padding:14px 26px;cursor:pointer}
+button.btn:hover{background:var(--metal-glow);border-color:var(--metal-glow)}
+button.btn.ghost{background:transparent;color:var(--metal)}
+button.btn.ghost:hover{background:#1a1611;color:var(--metal-glow)}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{padding:14px 12px;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
+th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
+.pill{display:inline-block;font-size:10px;padding:3px 9px;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
+.pill.pending_payment{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
+.pill.paid{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.pill.refunded{background:#2a1410;color:var(--warn);border-color:#4a2520}
+.pill.cancelled,.pill.sub_cancelled{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style></head><body>
+<header class="brand">
+  <a href="/" style="display:inline-flex;align-items:center;gap:14px;text-decoration:none"><svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-data" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-data)" stroke-width="1"/><path fill="url(#cb-data)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1 style="margin:0"><span style="color:var(--ink)">Counsel</span> <span class="amp">&amp;</span> <span style="color:var(--ink)">Bar</span></h1></a>
+  <nav class="topnav">
+    <a href="/find-a-lawyer">Find counsel</a>
+    <a href="/directory">Directory</a>
+    <a href="/data">Data</a>
+    <a href="/login">Sign in</a>
+  </nav>
+</header>
+<main>`;
+}
+
+const FOOT = `</main>
+<footer style="padding:36px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase">
+  Counsel &amp; Bar · California-licensed attorney directory · Data sourced from State Bar of California public records
+</footer>
+</body></html>`;
+
+// ─── pricing math (pure) ──────────────────────────────────────────────────
+
+function computePrice(kind: 'one_time'|'subscription', citiesCount: number, attorneyCount: number): number {
+  if (kind === 'subscription') return SUBSCRIPTION_CENTS_MONTHLY;
+  // one_time — base + per-attorney, capped per city, then bundle discount
+  const raw = ONETIME_BASE_CENTS + (attorneyCount * ONETIME_PER_ATTORNEY_CENTS);
+  const cappedPerCity = Math.min(raw, ONETIME_CAP_PER_CITY_CENTS * Math.max(1, citiesCount));
+  const discounted = citiesCount >= 3 ? Math.round(cappedPerCity * (1 - BUNDLE_DISCOUNT_PCT / 100)) : cappedPerCity;
+  return Math.max(discounted, 1900); // floor at $19
+}
+
+function fmtCents(c: number): string {
+  if (c % 100 === 0) return `$${(c / 100).toLocaleString()}`;
+  return `$${(c / 100).toFixed(2)}`;
+}
+
+// ─── city-count snapshot used by GET /data and POST /data/checkout ────────
+
+type CityRow = { city: string; attorney_count: number; firm_count: number };
+
+async function loadCityRollup(): Promise<CityRow[]> {
+  const r = await query<CityRow>(`
+    SELECT
+      INITCAP(LOWER(o.city))                               AS city,
+      COUNT(DISTINCT pl.professional_id)::int              AS attorney_count,
+      COUNT(DISTINCT o.id)::int                            AS firm_count
+    FROM organizations o
+    JOIN professional_locations pl ON pl.organization_id = o.id
+    JOIN professionals p           ON p.id = pl.professional_id
+    WHERE o.type='law_firm'
+      AND o.city IS NOT NULL AND o.city <> ''
+      AND p.bar_number IS NOT NULL
+    GROUP BY INITCAP(LOWER(o.city))
+    HAVING COUNT(DISTINCT pl.professional_id) >= 5
+    ORDER BY attorney_count DESC
+    LIMIT 80
+  `);
+  return r.rows;
+}
+
+async function attorneyCountForCities(cities: string[]): Promise<number> {
+  if (cities.length === 0) return 0;
+  const r = await query<{ n: string }>(`
+    SELECT COUNT(DISTINCT pl.professional_id)::text AS n
+    FROM organizations o
+    JOIN professional_locations pl ON pl.organization_id = o.id
+    JOIN professionals p           ON p.id = pl.professional_id
+    WHERE o.type='law_firm'
+      AND p.bar_number IS NOT NULL
+      AND LOWER(o.city) = ANY($1::text[])
+  `, [cities.map(c => c.toLowerCase())]);
+  return parseInt(r.rows[0]?.n || '0', 10);
+}
+
+// ─── GET /data — public catalog ───────────────────────────────────────────
+
+router.get('/data', async (req, res) => {
+  const flash = req.query.err
+    ? `<div class="flash error">${esc(String(req.query.err).slice(0, 200))}</div>`
+    : req.query.msg
+    ? `<div class="flash success">${esc(String(req.query.msg).slice(0, 200))}</div>`
+    : '';
+
+  const [cityRows, totalAttorneys] = await Promise.all([
+    loadCityRollup(),
+    query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM professionals WHERE bar_number IS NOT NULL`).then(r => parseInt(r.rows[0].n, 10)),
+  ]);
+
+  const cityListHtml = cityRows.map(c => `
+    <label class="city-pick">
+      <input type="checkbox" name="cities" value="${esc(c.city)}" data-attorneys="${c.attorney_count}">
+      <span class="cn">${esc(c.city)}</span>
+      <span class="cc">${c.attorney_count.toLocaleString()}</span>
+    </label>
+  `).join('');
+
+  res.send(shellHead('Attorney data lists', { wide: true }) + `
+  <style>
+    .data-grid{display:grid;grid-template-columns:1.4fr 1fr;gap:64px;align-items:start;margin-top:32px}
+    @media (max-width:760px){.data-grid{grid-template-columns:1fr;gap:36px}.city-grid{grid-template-columns:1fr;max-height:420px}}
+    .city-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:0;border:1px solid var(--rule);max-height:560px;overflow:auto}
+    .city-pick{display:flex;align-items:center;gap:12px;padding:11px 16px;border-bottom:1px solid var(--rule-faint);cursor:pointer;transition:background 160ms ease}
+    .city-pick:hover{background:var(--noir-rise)}
+    .city-pick:nth-child(even){border-left:1px solid var(--rule-faint)}
+    .city-pick input{width:auto;padding:0;margin:0;accent-color:var(--metal)}
+    .city-pick .cn{flex:1;font-size:14px}
+    .city-pick .cc{font-variant-numeric:tabular-nums;color:var(--metal);font-size:12px;font-weight:500;letter-spacing:.04em}
+    .pricing-card{border:1px solid var(--rule);background:var(--noir-rise);padding:32px}
+    .pricing-card h3{font-family:var(--serif);font-weight:400;font-size:24px;margin:0 0 8px}
+    .pricing-card h3 em{font-style:italic;color:var(--metal)}
+    .pricing-card .price{font-family:var(--serif);font-weight:300;font-size:54px;line-height:1;color:var(--metal);font-variant-numeric:tabular-nums;margin:18px 0 8px}
+    .pricing-card .price-sub{font-size:13px;color:var(--ink-mute);margin-bottom:20px}
+    .pricing-card ul{list-style:none;padding:0;margin:0 0 24px}
+    .pricing-card li{padding:8px 0;border-bottom:1px solid var(--rule-faint);font-size:13px;color:var(--ink-soft);display:flex;justify-content:space-between}
+    .pricing-card li:before{content:"✓ ";color:var(--metal);margin-right:8px}
+    .pricing-card.sub{border-color:var(--metal);position:relative}
+    .pricing-card.sub:before{content:"Best value";position:absolute;top:-11px;left:24px;background:var(--metal);color:var(--noir);font-size:10px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;padding:3px 10px}
+    .quick-pills{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 18px}
+    .quick-pills button{background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink-soft);padding:7px 14px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer}
+    .quick-pills button:hover{border-color:var(--metal);color:var(--metal)}
+    .selected-summary{padding:18px 20px;background:var(--noir-rise);border:1px solid var(--rule);margin-bottom:18px;font-size:13px}
+    .selected-summary b{color:var(--metal);font-variant-numeric:tabular-nums}
+    .compliance{padding:20px 24px;background:linear-gradient(180deg,rgba(184,153,104,.025),transparent);border:1px solid var(--rule);font-size:12px;line-height:1.65;letter-spacing:.02em;margin-top:32px;color:var(--ink-soft)}
+    .compliance b{color:var(--ink);font-weight:500}
+    .compliance .c-eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;display:block;margin-bottom:10px}
+    .public-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
+    .public-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
+    .public-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
+    .public-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
+    label.field{display:block;margin-top:14px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
+    label.field input{display:block;width:100%;margin-top:6px}
+  </style>
+  <div class="public-vol">
+    <span class="v-rule"></span>
+    <span class="v-text">Volume I</span>
+    <span class="v-meta">Edition 2026 · Data Marketplace · ${totalAttorneys.toLocaleString()} attorneys indexed</span>
+  </div>
+  <h1 class="display">Lawyer lists, <em>cleaned and current.</em></h1>
+  <p class="lede">Built from the California State Bar's public licensing records and enriched with firm contact data. Pick a city or build a multi-city set — buy once for a snapshot, or subscribe for monthly refreshes.</p>
+
+  ${flash}
+
+  <form method="post" action="/data/checkout" class="data-grid" id="orderForm">
+    <div>
+      <p class="eyebrow" style="margin-top:0;display:flex;align-items:center;gap:14px"><span style="font-family:var(--serif);font-style:italic;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:32px;line-height:1;text-transform:none;letter-spacing:-.02em">i</span> &nbsp;·&nbsp; Pick markets</p>
+      <div class="quick-pills">
+        <button type="button" data-pick="LA">Los Angeles County</button>
+        <button type="button" data-pick="BAY">Bay Area</button>
+        <button type="button" data-pick="SD">San Diego</button>
+        <button type="button" data-pick="ALL">All California</button>
+        <button type="button" data-pick="CLEAR">Clear</button>
+      </div>
+      <div class="city-grid">${cityListHtml}</div>
+      <p class="muted" style="margin-top:14px;font-size:12px">${cityRows.length} cities listed · only cities with 5+ attorneys are shown · attorney counts are de-duplicated by bar number.</p>
+    </div>
+
+    <div>
+      <div class="selected-summary" id="summary">
+        <div>Selected cities: <b id="sumCities">0</b></div>
+        <div>Attorneys in set: <b id="sumAttorneys">0</b></div>
+      </div>
+
+      <div class="pricing-card" style="margin-bottom:24px">
+        <h3>One-time <em>purchase</em></h3>
+        <div class="price" id="onetimePrice">$49</div>
+        <div class="price-sub">snapshot CSV · download today</div>
+        <ul>
+          <li><span>Format</span><span>CSV (Excel-compatible)</span></li>
+          <li><span>Fields</span><span>Name · Bar # · Firm · Phone · Address · Web</span></li>
+          <li><span>Refunds</span><span>14-day, no-questions</span></li>
+          <li><span>License</span><span>Single use, no resale</span></li>
+        </ul>
+        <button class="btn" type="submit" name="kind" value="one_time" style="width:100%">Buy snapshot →</button>
+      </div>
+
+      <div class="pricing-card sub">
+        <h3>Subscription · <em>$99/mo</em></h3>
+        <div class="price">$99</div>
+        <div class="price-sub">all California cities · monthly refresh</div>
+        <ul>
+          <li><span>Cities</span><span>Unlimited (CA only)</span></li>
+          <li><span>Refresh</span><span>Monthly diff + full</span></li>
+          <li><span>Cancel</span><span>Anytime, in 1 click</span></li>
+          <li><span>Format</span><span>CSV + JSON</span></li>
+        </ul>
+        <button class="btn ghost" type="submit" name="kind" value="subscription" style="width:100%">Start subscription →</button>
+      </div>
+
+      <label class="field">Email <input name="email" type="email" required placeholder="you@firm.com" autocomplete="email"></label>
+      <label class="field">Name <input name="full_name" type="text" placeholder="Your name"></label>
+      <label class="field">Company <input name="company" type="text" placeholder="Optional"></label>
+    </div>
+  </form>
+
+  <div class="compliance">
+    <span class="c-eyebrow">Use restrictions · for the record</span>
+    <b>About this data.</b> Records are sourced from the State Bar of California's public licensing database and supplemented with firm-level contact information from publicly indexed websites and LA City Active Business Licenses (NAICS 5411). All buyers must comply with the State Bar's permitted use rules and California consumer-privacy law (CCPA/CPRA). <b>Data may not be used for unsolicited bulk SMS, robocalls, or any unlawful purpose.</b> We exclude attorneys flagged as inactive, suspended, or deceased on the date of export.
+  </div>
+
+  <script>
+    const cb = document.querySelectorAll('input[name="cities"]');
+    const sumC = document.getElementById('sumCities');
+    const sumA = document.getElementById('sumAttorneys');
+    const otp  = document.getElementById('onetimePrice');
+    function recompute(){
+      const sel = [...cb].filter(c => c.checked);
+      const aa = sel.reduce((a,c) => a + parseInt(c.dataset.attorneys||'0',10), 0);
+      sumC.textContent = sel.length;
+      sumA.textContent = aa.toLocaleString();
+      // mirror server-side computePrice() for one-time
+      const base = 4900;
+      const perAtt = 4;
+      const cap = 29900 * Math.max(1, sel.length);
+      let raw = base + (aa * perAtt);
+      raw = Math.min(raw, cap);
+      if (sel.length >= 3) raw = Math.round(raw * 0.7);
+      raw = Math.max(raw, 1900);
+      otp.textContent = '$' + (raw/100).toLocaleString(undefined,{minimumFractionDigits: raw%100?2:0, maximumFractionDigits:2});
+    }
+    cb.forEach(c => c.addEventListener('change', recompute));
+    recompute();
+
+    const PICKS = {
+      LA: ['Los Angeles','Beverly Hills','Santa Monica','Pasadena','Long Beach','Glendale','Burbank','Culver City','West Hollywood','El Segundo','Torrance','Hermosa Beach','Manhattan Beach','Sherman Oaks','Encino','Studio City','Van Nuys','Marina Del Rey','Hollywood','North Hollywood'],
+      BAY:['San Francisco','Oakland','San Jose','Palo Alto','Berkeley','Mountain View','Santa Clara','Redwood City','San Mateo','Walnut Creek'],
+      SD: ['San Diego','La Jolla','Carlsbad','Encinitas','Del Mar','Chula Vista','Escondido'],
+      ALL:'*',
+      CLEAR:''
+    };
+    document.querySelectorAll('button[data-pick]').forEach(b => b.addEventListener('click', () => {
+      const k = b.dataset.pick;
+      const set = PICKS[k];
+      cb.forEach(c => {
+        if (set === '*') c.checked = true;
+        else if (set === '') c.checked = false;
+        else c.checked = (set || []).map(s=>s.toLowerCase()).includes(c.value.toLowerCase());
+      });
+      recompute();
+    }));
+  </script>
+  ` + FOOT);
+});
+
+// ─── POST /data/checkout — start Stripe Checkout ──────────────────────────
+
+router.post('/data/checkout', async (req, res) => {
+  if (!stripe) return res.redirect('/data?err=' + encodeURIComponent('Payments not configured'));
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const b = req.body as Record<string, any>;
+  const kind = (b.kind === 'subscription') ? 'subscription' : 'one_time';
+  const email = String(b.email || '').trim().toLowerCase();
+  if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
+    return res.redirect('/data?err=' + encodeURIComponent('Valid email required'));
+
+  // Normalize cities — POST may send a single string OR an array.
+  const rawCities = b.cities;
+  let cities: string[] = [];
+  if (Array.isArray(rawCities)) cities = rawCities.map((c: any) => String(c).trim()).filter(Boolean);
+  else if (typeof rawCities === 'string' && rawCities.trim()) cities = [rawCities.trim()];
+
+  if (kind === 'one_time' && cities.length === 0)
+    return res.redirect('/data?err=' + encodeURIComponent('Pick at least one city'));
+
+  // Snapshot the attorney count for chosen cities (one_time) or all-CA (subscription).
+  const attorneyCount = kind === 'subscription'
+    ? (await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM professionals WHERE bar_number IS NOT NULL`)).rows[0].n
+    : String(await attorneyCountForCities(cities));
+  const attorneyCountInt = parseInt(attorneyCount, 10);
+  const amount = computePrice(kind, cities.length, attorneyCountInt);
+  const downloadToken = crypto.randomBytes(32).toString('hex');
+
+  const ins = await query<{ id: number }>(`
+    INSERT INTO data_orders (
+      kind, email, full_name, company, cities, attorney_count, amount_cents,
+      download_token, ip, user_agent
+    ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id
+  `, [
+    kind, email,
+    String(b.full_name || '').trim().slice(0, 120) || null,
+    String(b.company   || '').trim().slice(0, 120) || null,
+    kind === 'subscription' ? [] : cities,
+    attorneyCountInt, amount, downloadToken,
+    req.ip || null, (req.headers['user-agent'] || '').toString().slice(0, 200),
+  ]);
+  const orderId = ins.rows[0].id;
+
+  // Stripe redirect URLs are pinned to PUBLIC_BASE_URL — never derived from
+  // request headers, which can be forged on a direct (non-proxied) hit.
+  const base = process.env.PUBLIC_BASE_URL?.replace(/\/+$/, '')
+    || 'https://lawyers.agentabrams.com';
+
+  try {
+    let session: Stripe.Checkout.Session;
+    if (kind === 'subscription') {
+      session = await stripe.checkout.sessions.create({
+        mode: 'subscription',
+        payment_method_types: ['card'],
+        customer_email: email,
+        line_items: [{
+          price_data: {
+            currency: 'usd',
+            recurring: { interval: 'month' },
+            product_data: {
+              name: 'Counsel & Bar — Attorney Data, monthly subscription',
+              description: 'Unlimited California-city downloads · monthly refresh · cancel anytime',
+            },
+            unit_amount: amount,
+          },
+          quantity: 1,
+        }],
+        metadata: { kind: 'data_subscription', order_id: String(orderId) },
+        success_url: `${base}/data/thanks?session_id={CHECKOUT_SESSION_ID}`,
+        cancel_url: `${base}/data?err=${encodeURIComponent('Checkout cancelled')}`,
+      });
+    } else {
+      session = await stripe.checkout.sessions.create({
+        mode: 'payment',
+        payment_method_types: ['card'],
+        customer_email: email,
+        line_items: [{
+          price_data: {
+            currency: 'usd',
+            product_data: {
+              name: `Counsel & Bar — Attorney List, ${cities.length} ${cities.length === 1 ? 'city' : 'cities'}`,
+              description: `${attorneyCountInt.toLocaleString()} California-licensed attorneys · ${cities.slice(0, 6).join(', ')}${cities.length > 6 ? ', …' : ''}`,
+            },
+            unit_amount: amount,
+          },
+          quantity: 1,
+        }],
+        metadata: {
+          kind: 'data_one_time',
+          order_id: String(orderId),
+          cities: cities.slice(0, 8).join('|').slice(0, 480),
+          attorney_count: String(attorneyCountInt),
+        },
+        success_url: `${base}/data/thanks?session_id={CHECKOUT_SESSION_ID}`,
+        cancel_url: `${base}/data?err=${encodeURIComponent('Checkout cancelled')}`,
+      });
+    }
+    await query(`UPDATE data_orders SET payment_link = $2, stripe_session_id = $3 WHERE id = $1`,
+      [orderId, session.url, session.id]);
+    res.redirect(303, session.url || '/data');
+  } catch (e) {
+    console.error('[data] checkout error:', e);
+    res.redirect('/data?err=' + encodeURIComponent('Stripe error: ' + (e as Error).message.slice(0, 160)));
+  }
+});
+
+// ─── GET /data/thanks — Stripe redirect, mark paid ────────────────────────
+
+router.get('/data/thanks', async (req, res) => {
+  const sid = (req.query.session_id as string) || '';
+  let paid = false;
+  let token = '';
+  let kind: 'one_time' | 'subscription' = 'one_time';
+  if (stripe && sid) {
+    try {
+      const s = await stripe.checkout.sessions.retrieve(sid);
+      const orderId = s.metadata?.order_id ? parseInt(s.metadata.order_id, 10) : null;
+      const subId   = (typeof s.subscription === 'string') ? s.subscription : s.subscription?.id;
+      const custId  = (typeof s.customer === 'string') ? s.customer : s.customer?.id;
+      const isPaid = s.payment_status === 'paid' || s.payment_status === 'no_payment_required';
+      if (orderId && isPaid) {
+        const r = await query<{ download_token: string; kind: string }>(`
+          UPDATE data_orders
+             SET status = 'paid',
+                 paid_at = NOW(),
+                 stripe_subscription_id = COALESCE($2, stripe_subscription_id),
+                 stripe_customer_id     = COALESCE($3, stripe_customer_id)
+           WHERE id = $1 AND status = 'pending_payment'
+           RETURNING download_token, kind
+        `, [orderId, subId || null, custId || null]);
+        if (r.rowCount && r.rows[0]) {
+          paid = true;
+          token = r.rows[0].download_token;
+          kind = r.rows[0].kind === 'subscription' ? 'subscription' : 'one_time';
+        } else {
+          // Maybe already marked paid on a prior page-load. Re-read.
+          const r2 = await query<{ download_token: string; kind: string; status: string }>(
+            `SELECT download_token, kind, status FROM data_orders WHERE id = $1`, [orderId]);
+          if (r2.rowCount && r2.rows[0].status === 'paid') {
+            paid = true;
+            token = r2.rows[0].download_token;
+            kind = r2.rows[0].kind === 'subscription' ? 'subscription' : 'one_time';
+          }
+        }
+      }
+    } catch (e) {
+      console.error('[data] thanks lookup err:', e);
+    }
+  }
+  const dlUrl = token ? `/data/download/${token}` : '';
+  const body = paid
+    ? `<p class="eyebrow">Order received · payment confirmed</p>
+       <h1 class="display">Your data is <em>ready.</em></h1>
+       <p class="lede">${kind === 'subscription'
+          ? 'Your subscription is active. Bookmark this page — your token is the same key for every monthly refresh.'
+          : 'Your one-time export is ready. The download link below is yours; bookmark it for the next 30 days.'}</p>
+       <p style="margin:30px 0">
+         <a href="${esc(dlUrl)}" class="btn" style="display:inline-block;background:var(--metal);color:var(--noir);padding:16px 28px;font-size:12px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;text-decoration:none;border:1px solid var(--metal)">Download CSV →</a>
+       </p>
+       <p class="muted">Bookmark this URL — your private download link:<br><code style="background:var(--noir-rise);padding:8px 14px;display:inline-block;margin-top:8px;border:1px solid var(--rule);color:var(--ink-soft);font-size:12px">https://lawyers.agentabrams.com${esc(dlUrl)}</code></p>`
+    : `<p class="eyebrow">Almost there</p>
+       <h1 class="display">Reservation noted.</h1>
+       <p class="lede">If your payment is still processing, give it a moment and refresh. If you cancelled, no charge was made.</p>
+       <p><a href="/data">← Back to data</a></p>`;
+  res.send(shellHead('Thanks') + body + FOOT);
+});
+
+// ─── GET /data/download/:token — CSV export ───────────────────────────────
+
+router.get('/data/download/:token', async (req, res) => {
+  const token = String(req.params.token || '').trim();
+  if (!/^[a-f0-9]{32,128}$/i.test(token))
+    return res.status(400).send('Invalid token format');
+
+  const r = await query<{
+    id: number; kind: string; status: string; cities: string[];
+    stripe_subscription_id: string | null; paid_at: string | null;
+  }>(`SELECT id, kind, status, cities, stripe_subscription_id, paid_at
+        FROM data_orders WHERE download_token = $1`, [token]);
+  if (!r.rowCount) return res.status(404).send('Token not found');
+  const o = r.rows[0];
+  if (o.status !== 'paid') return res.status(402).send('Order not paid');
+
+  // Subscription: verify Stripe subscription is still active.
+  if (o.kind === 'subscription') {
+    if (!stripe) return res.status(503).send('Subscription verification unavailable');
+    if (!o.stripe_subscription_id) return res.status(402).send('Subscription not on file');
+    try {
+      const sub = await stripe.subscriptions.retrieve(o.stripe_subscription_id);
+      if (sub.status !== 'active' && sub.status !== 'trialing') {
+        await query(`UPDATE data_orders SET status='sub_cancelled', cancelled_at=NOW() WHERE id=$1`, [o.id]);
+        return res.status(402).send('Subscription is no longer active');
+      }
+    } catch (e) {
+      console.error('[data] sub verify err:', e);
+      return res.status(502).send('Could not verify subscription');
+    }
+  }
+
+  // Build the CSV. Subscription orders return all CA; one-time returns the chosen cities.
+  const params: unknown[] = [];
+  let where = `WHERE p.bar_number IS NOT NULL AND o.type='law_firm'`;
+  if (o.kind !== 'subscription' && o.cities && o.cities.length > 0) {
+    params.push(o.cities.map(c => c.toLowerCase()));
+    where += ` AND LOWER(o.city) = ANY($${params.length}::text[])`;
+  }
+  const rows = await query<{
+    full_name: string; bar_number: string; license_status: string | null;
+    firm_name: string | null; phone: string | null; address: string | null;
+    city: string | null; zip: string | null; website: string | null;
+  }>(`
+    SELECT
+      p.full_name, p.bar_number, p.license_status,
+      o.name      AS firm_name,
+      COALESCE(o.phone, ph.phone) AS phone,
+      o.address, o.city, o.zip,
+      o.website
+    FROM professionals p
+    JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary
+    JOIN organizations o            ON o.id = pl.organization_id
+    LEFT JOIN LATERAL (
+      SELECT phone FROM phones WHERE organization_id = o.id LIMIT 1
+    ) ph ON true
+    ${where}
+    ORDER BY p.full_name
+  `, params);
+
+  await query(`UPDATE data_orders SET downloads_count = downloads_count + 1, last_downloaded_at = NOW() WHERE id = $1`, [o.id]);
+
+  const today = new Date().toISOString().slice(0, 10);
+  const fname = `counsel-and-bar-attorneys-${today}.csv`;
+  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+  res.setHeader('Content-Disposition', `attachment; filename="${fname}"`);
+  // CSV escaper — wraps every field in double-quotes, doubles internal quotes.
+  const csvCell = (v: any) => {
+    const s = v == null ? '' : String(v);
+    return '"' + s.replace(/"/g, '""') + '"';
+  };
+  res.write(['Full Name','Bar #','License Status','Firm','Phone','Address','City','ZIP','Website'].map(csvCell).join(',') + '\n');
+  for (const row of rows.rows) {
+    res.write([
+      row.full_name, row.bar_number, row.license_status,
+      row.firm_name, row.phone, row.address, row.city, row.zip, row.website,
+    ].map(csvCell).join(',') + '\n');
+  }
+  res.end();
+});
+
+// ─── /admin/data-orders ───────────────────────────────────────────────────
+
+router.get('/admin/data-orders', requireAdmin, async (req, res) => {
+  const rows = await query<{
+    id: number; kind: string; email: string; full_name: string | null;
+    company: string | null; cities: string[]; attorney_count: number;
+    amount_cents: number; status: string; downloads_count: number;
+    paid_at: string | null; created_at: string; payment_link: string | null;
+  }>(`SELECT id, kind, email, full_name, company, cities, attorney_count,
+              amount_cents, status, downloads_count, paid_at, created_at, payment_link
+         FROM data_orders ORDER BY id DESC LIMIT 200`);
+
+  const counts = await query<{ k: string; v: string }>(`
+    SELECT 'total'    AS k, COUNT(*)::text  AS v FROM data_orders
+    UNION ALL SELECT 'paid',     COUNT(*)::text FROM data_orders WHERE status='paid'
+    UNION ALL SELECT 'subs',     COUNT(*)::text FROM data_orders WHERE kind='subscription' AND status='paid'
+    UNION ALL SELECT 'revenue',  COALESCE(SUM(amount_cents),0)::text FROM data_orders WHERE status='paid'
+  `);
+  const cm: Record<string, string> = {}; for (const r of counts.rows) cm[r.k] = r.v;
+
+  const trs = rows.rows.map(o => `<tr>
+    <td>#${o.id}<br><span class="muted">${new Date(o.created_at).toISOString().slice(0,16).replace('T',' ')}</span></td>
+    <td><b>${esc(o.email)}</b><br><span class="muted">${esc(o.full_name || '—')} · ${esc(o.company || '—')}</span></td>
+    <td>${esc(o.kind)}<br><span class="muted">${o.kind === 'subscription' ? 'all CA' : (o.cities || []).slice(0,3).join(', ') + ((o.cities||[]).length > 3 ? ' +' + ((o.cities||[]).length - 3) : '')}</span></td>
+    <td>${o.attorney_count.toLocaleString()}</td>
+    <td>${fmtCents(o.amount_cents)}<br><span class="muted">downloads: ${o.downloads_count}</span></td>
+    <td><span class="pill ${esc(o.status)}">${esc(o.status)}</span>${o.paid_at ? `<br><span class="muted">${new Date(o.paid_at).toISOString().slice(0,10)}</span>` : ''}</td>
+    <td>${o.payment_link ? `<a href="${esc(o.payment_link)}" target="_blank">Stripe ↗</a>` : '<span class="muted">—</span>'}</td>
+  </tr>`).join('');
+
+  res.send(shellHead('Admin · Data Orders', { wide: true }) + `
+    <p class="eyebrow">Admin · Data marketplace</p>
+    <h1 class="display">Data <em>orders.</em></h1>
+    <div style="display:flex;gap:32px;margin:24px 0 32px;font-family:var(--serif)">
+      <div><div style="font-size:32px;color:var(--metal);font-variant-numeric:tabular-nums">${cm.total || 0}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">total orders</div></div>
+      <div><div style="font-size:32px;color:var(--good);font-variant-numeric:tabular-nums">${cm.paid || 0}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">paid</div></div>
+      <div><div style="font-size:32px;color:var(--metal);font-variant-numeric:tabular-nums">${cm.subs || 0}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">active subs</div></div>
+      <div><div style="font-size:32px;color:var(--metal-glow);font-variant-numeric:tabular-nums">$${(parseInt(cm.revenue || '0', 10) / 100).toLocaleString()}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">gross revenue</div></div>
+    </div>
+    <table>
+      <thead><tr><th>#</th><th>Buyer</th><th>Kind / Cities</th><th>Attorneys</th><th>Amount</th><th>Status</th><th>Stripe</th></tr></thead>
+      <tbody>${trs || '<tr><td colspan="7" class="muted" style="padding:32px;text-align:center">No orders yet.</td></tr>'}</tbody>
+    </table>
+  ` + FOOT);
+});
+
+// ─── /webhooks/stripe-data — separate endpoint (registered separately in Stripe) ──
+
+router.post('/webhooks/stripe-data', express.raw({ type: 'application/json' }), async (req, res) => {
+  const secret = process.env.STRIPE_DATA_WEBHOOK_SECRET;
+  if (!stripe || !secret) return res.status(503).send('webhook not configured');
+  const sig = req.headers['stripe-signature'] as string;
+  let event: Stripe.Event;
+  try { event = stripe.webhooks.constructEvent(req.body, sig, secret); }
+  catch (e) { return res.status(400).send(`webhook signature err: ${(e as Error).message}`); }
+
+  switch (event.type) {
+    case 'checkout.session.completed': {
+      const s = event.data.object as Stripe.Checkout.Session;
+      const orderId = s.metadata?.order_id ? parseInt(s.metadata.order_id, 10) : null;
+      if (orderId && (s.metadata?.kind === 'data_one_time' || s.metadata?.kind === 'data_subscription')) {
+        const subId  = (typeof s.subscription === 'string') ? s.subscription : s.subscription?.id;
+        const custId = (typeof s.customer === 'string') ? s.customer : s.customer?.id;
+        await query(`
+          UPDATE data_orders
+             SET status='paid', paid_at=NOW(),
+                 stripe_subscription_id = COALESCE($2, stripe_subscription_id),
+                 stripe_customer_id     = COALESCE($3, stripe_customer_id)
+           WHERE id = $1 AND status='pending_payment'`,
+          [orderId, subId || null, custId || null]);
+      }
+      break;
+    }
+    case 'customer.subscription.deleted': {
+      const sub = event.data.object as Stripe.Subscription;
+      await query(`UPDATE data_orders SET status='sub_cancelled', cancelled_at=NOW()
+                    WHERE stripe_subscription_id = $1 AND status='paid'`, [sub.id]);
+      break;
+    }
+  }
+  res.json({ received: true });
+});
+
+export default router;
diff --git a/src/server/index.ts b/src/server/index.ts
index f6d7b9f..323dbb1 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -6,16 +6,42 @@ import express from 'express';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { pool, query } from '../db/pool.ts';
+import { authMiddleware } from './auth.ts';
+import portalRouter from './portal.ts';
+import leadsRouter from './leads.ts';
+import directoryRouter from './directory.ts';
+import upgradeRouter from './upgrade.ts';
+import dataMarketRouter from './data_market.ts';
+import showcaseRouter from './showcase.ts';
+import communityRouter from './community.ts';
+import billingRouter from './billing.ts';
+import adminRouter from './admin.ts';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const PORT = parseInt(process.env.PORT || '9701', 10);
 
 const app = express();
-app.use(express.json());
+// Stripe webhooks need the raw body for signature verification — skip JSON parsing for them.
+app.use((req, res, next) => {
+  if (req.path.startsWith('/webhooks/')) return next();
+  return express.json()(req, res, next);
+});
 app.use((_req, res, next) => {
   res.setHeader('Access-Control-Allow-Origin', '*');
   next();
 });
+app.use(authMiddleware);
+// adminRouter mounted FIRST so its new /admin overview wins over portal.ts's
+// older /admin user-management page (which is still reachable at /portal/admin).
+app.use(adminRouter);
+app.use(portalRouter);
+app.use(leadsRouter);
+app.use(directoryRouter);
+app.use(upgradeRouter);
+app.use(dataMarketRouter);
+app.use(showcaseRouter);
+app.use(communityRouter);
+app.use(billingRouter);
 
 // ─── Read API ───────────────────────────────────────────────────────────────
 
@@ -45,22 +71,106 @@ app.get('/api/stats', async (_req, res) => {
 app.get('/api/firms', async (req, res) => {
   const city = (req.query.city as string | undefined)?.trim() || null;
   const zip = (req.query.zip as string | undefined)?.trim() || null;
+  const q = (req.query.q as string | undefined)?.trim() || null;
+  const sizeBand = (req.query.firm_size_band as string | undefined)?.trim() || null;
+  const prospect = (req.query.prospect as string | undefined)?.trim() || null;
+  const sort = (req.query.sort as string | undefined)?.trim() || 'score';
   const limit = Math.min(parseInt((req.query.limit as string) || '200', 10), 1000);
+  const offset = Math.max(parseInt((req.query.offset as string) || '0', 10), 0);
 
-  const where: string[] = ["type='law_firm'"];
+  const where: string[] = ["o.type='law_firm'"];
   const params: unknown[] = [];
-  if (city) { params.push(city); where.push(`(LOWER(city) = LOWER($${params.length}) OR LOWER(neighborhood) = LOWER($${params.length}))`); }
-  if (zip)  { params.push(zip);  where.push(`zip = $${params.length}`); }
+  if (city) { params.push(city); where.push(`(LOWER(o.city) = LOWER($${params.length}) OR LOWER(o.neighborhood) = LOWER($${params.length}))`); }
+  if (zip)  { params.push(zip);  where.push(`o.zip = $${params.length}`); }
+  if (q)    { params.push('%' + q.toLowerCase() + '%'); where.push(`LOWER(o.name) LIKE $${params.length}`); }
+  if (sizeBand) { params.push(sizeBand); where.push(`o.firm_size_band = $${params.length}`); }
+  if (prospect === 'has_site')   where.push(`o.website ~ '^https?://'`);
+  if (prospect === 'needs_site') where.push(`(o.website IS NULL OR o.website !~ '^https?://')`);
+  if (prospect === 'has_phone')  where.push(`o.phone IS NOT NULL`);
+  if (prospect === 'audited')    where.push(`EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399)`);
+  if (prospect === 'high_score') where.push(`EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score >= 70)`);
+  if (prospect === 'low_score')  where.push(`EXISTS (SELECT 1 FROM site_audits a WHERE a.organization_id=o.id AND a.marketing_score IS NOT NULL AND a.marketing_score < 50)`);
+
+  let orderBy = 'attorney_count DESC NULLS LAST, name ASC';
+  if (sort === 'name')        orderBy = 'o.name ASC';
+  else if (sort === 'attorneys') orderBy = 'o.attorney_count DESC NULLS LAST, o.name ASC';
+  else if (sort === 'zip')    orderBy = 'o.zip ASC NULLS LAST, o.name ASC';
+  else if (sort === 'score')  orderBy = 'audit_score DESC NULLS LAST, o.attorney_count DESC NULLS LAST, o.name ASC';
+  else if (sort === 'recent') orderBy = 'o.contact_crawled_at DESC NULLS LAST, o.id DESC';
+
   params.push(limit);
+  params.push(offset);
 
   const r = await query(`
-    SELECT id, name, address, city, neighborhood, zip, phone, website, lat, lng, firm_size_band, attorney_count
-    FROM organizations
+    SELECT
+      o.id, o.name, o.address, o.city, o.neighborhood, o.zip, o.phone, o.website,
+      o.lat, o.lng, o.firm_size_band, o.attorney_count, o.contact_crawled_at,
+      (SELECT marketing_score FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_score,
+      (SELECT primary_color FROM site_audits a WHERE a.organization_id=o.id AND a.status_code BETWEEN 200 AND 399 ORDER BY a.audited_at DESC LIMIT 1) AS audit_color,
+      (SELECT COUNT(*)::int FROM phones WHERE organization_id=o.id) AS phone_count,
+      (SELECT COUNT(*)::int FROM emails WHERE organization_id=o.id) AS email_count,
+      (SELECT COUNT(*)::int FROM professional_locations pl WHERE pl.organization_id=o.id) AS lawyer_count
+    FROM organizations o
     WHERE ${where.join(' AND ')}
-    ORDER BY attorney_count DESC NULLS LAST, name ASC
-    LIMIT $${params.length}
+    ORDER BY ${orderBy}
+    LIMIT $${params.length - 1} OFFSET $${params.length}
   `, params);
-  res.json({ count: r.rowCount, rows: r.rows });
+  res.json({ count: r.rowCount, offset, limit, rows: r.rows });
+});
+
+app.get('/api/attorneys', async (req, res) => {
+  const q = (req.query.q as string | undefined)?.trim() || null;
+  const status = (req.query.status as string | undefined)?.trim() || null;
+  const sort = (req.query.sort as string | undefined)?.trim() || 'name';
+  const limit = Math.min(parseInt((req.query.limit as string) || '200', 10), 1000);
+  const offset = Math.max(parseInt((req.query.offset as string) || '0', 10), 0);
+
+  const where: string[] = ['p.bar_number IS NOT NULL'];
+  const params: unknown[] = [];
+  if (q) {
+    params.push('%' + q.toLowerCase() + '%');
+    where.push(`(LOWER(p.full_name) LIKE $${params.length} OR LOWER(p.last_name) LIKE $${params.length})`);
+  }
+  if (status === 'active') where.push(`p.license_status = 'Active'`);
+  if (status === 'inactive') where.push(`p.license_status <> 'Active'`);
+
+  let orderBy = 'p.full_name ASC';
+  if (sort === 'admission') orderBy = 'p.admission_date DESC NULLS LAST';
+  else if (sort === 'bar') orderBy = '(p.bar_number)::int ASC';
+
+  params.push(limit);
+  params.push(offset);
+
+  const r = await query(`
+    SELECT
+      p.id, p.full_name, p.first_name, p.last_name, p.bar_number, p.license_status, p.admission_date,
+      p.law_school, p.primary_practice_area,
+      pl.organization_id AS firm_id,
+      o.name AS firm_name, o.city, o.zip, o.phone AS firm_phone, o.website AS firm_website
+    FROM professionals p
+    LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
+    LEFT JOIN organizations o ON o.id = pl.organization_id
+    WHERE ${where.join(' AND ')}
+    ORDER BY ${orderBy}
+    LIMIT $${params.length - 1} OFFSET $${params.length}
+  `, params);
+  res.json({ count: r.rowCount, offset, limit, rows: r.rows });
+});
+
+app.get('/api/dashboard/stats', async (_req, res) => {
+  const r = await query<{ key: string; value: string }>(`
+    SELECT 'firms'      AS key, COUNT(*)::text AS value FROM organizations WHERE type='law_firm'
+    UNION ALL SELECT 'attorneys',  COUNT(*)::text FROM professionals
+    UNION ALL SELECT 'with_site',  COUNT(*)::text FROM organizations WHERE type='law_firm' AND website ~ '^https?://'
+    UNION ALL SELECT 'with_phone', COUNT(*)::text FROM organizations WHERE type='law_firm' AND phone IS NOT NULL
+    UNION ALL SELECT 'phones',     COUNT(*)::text FROM phones
+    UNION ALL SELECT 'emails',     COUNT(*)::text FROM emails
+    UNION ALL SELECT 'audits',     COUNT(*)::text FROM site_audits WHERE status_code BETWEEN 200 AND 399
+    UNION ALL SELECT 'cities',     COUNT(DISTINCT city)::text FROM organizations WHERE type='law_firm'
+  `);
+  const out: Record<string, number> = {};
+  for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
+  res.json(out);
 });
 
 app.get('/api/firms/:id', async (req, res) => {
@@ -118,15 +228,122 @@ app.get('/api/jobs', async (_req, res) => {
   res.json({ count: r.rowCount, rows: r.rows });
 });
 
-// ─── Static dashboard ───────────────────────────────────────────────────────
+app.get('/api/professionals/search', async (req, res) => {
+  const q = ((req.query.q as string) || '').trim();
+  if (q.length < 3) return res.json({ rows: [] });
+  // Match against full_name (loose ILIKE), prefer last_name prefix matches
+  // and active CA-bar attorneys with phone/firm.
+  const r = await query(`
+    SELECT
+      p.id, p.full_name, p.bar_number, p.license_status,
+      pl.organization_id AS firm_id,
+      o.name AS firm_name, o.city, o.zip, o.website AS firm_website,
+      o.phone AS firm_phone
+    FROM professionals p
+    LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary = true
+    LEFT JOIN organizations o ON o.id = pl.organization_id
+    WHERE p.bar_number IS NOT NULL
+      AND (
+        LOWER(p.full_name) LIKE LOWER($1) || '%'
+        OR LOWER(p.last_name) LIKE LOWER($1) || '%'
+        OR LOWER(p.full_name) LIKE '%' || LOWER($1) || '%'
+      )
+    ORDER BY
+      (LOWER(p.last_name) LIKE LOWER($1) || '%') DESC,
+      (LOWER(p.full_name) LIKE LOWER($1) || '%') DESC,
+      (p.license_status = 'Active') DESC,
+      p.full_name ASC
+    LIMIT 8
+  `, [q]);
+  res.json({ rows: r.rows });
+});
+
+app.get('/api/audits', async (req, res) => {
+  const minScore = parseInt((req.query.min_score as string) || '0', 10);
+  const limit = Math.min(parseInt((req.query.limit as string) || '500', 10), 2000);
+  const r = await query(`
+    SELECT
+      a.id, a.organization_id AS firm_id, o.name, a.url, a.final_url,
+      a.status_code, a.screenshot_path, a.primary_color, a.palette,
+      a.has_https, a.has_favicon, a.has_og_image, a.has_meta_viewport,
+      a.has_analytics, a.has_h1, a.has_schema_org, a.has_phone_visible,
+      a.page_size_bytes, a.load_time_ms, a.font_count, a.image_count, a.link_count,
+      a.marketing_score, a.suggestions, a.audited_at,
+      o.city, o.neighborhood, o.zip
+    FROM site_audits a
+    JOIN organizations o ON o.id = a.organization_id
+    WHERE a.status_code BETWEEN 200 AND 399
+      AND a.marketing_score >= $1
+    ORDER BY a.marketing_score DESC NULLS LAST, a.audited_at DESC
+    LIMIT $2
+  `, [minScore, limit]);
+  res.json({ count: r.rowCount, rows: r.rows });
+});
+
+// ─── Static dashboard + audit viewer ────────────────────────────────────────
 
 app.use('/', express.static(path.resolve(__dirname, '../../public')));
 
+// ─── Branded 404 fallback ───────────────────────────────────────────────────
+app.use((req, res) => {
+  if (req.accepts('html')) {
+    res.status(404).type('html').send(`<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Not found · Counsel &amp; Bar</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--noir:#0a0a0c;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--rule:#2a2724;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",system-ui,sans-serif}
+*{box-sizing:border-box;margin:0;padding:0}
+body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
+header{padding:22px 48px;border-bottom:1px solid var(--rule);background:#050507}
+header a{display:inline-flex;align-items:center;gap:14px;text-decoration:none}
+header h1{font-family:var(--serif);font-size:22px;font-weight:400;color:var(--ink)}
+header h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
+main{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;padding:80px 48px;text-align:center;position:relative;overflow:hidden}
+.code{font-family:var(--serif);font-style:italic;font-weight:300;color:var(--metal);opacity:.08;font-size:clamp(280px,42vw,560px);line-height:.85;letter-spacing:-.04em;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;user-select:none}
+.eyebrow{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 24px;position:relative;z-index:1}
+h2{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,72px);line-height:1.05;letter-spacing:-.02em;margin:0 0 22px;max-width:18ch;color:var(--ink);position:relative;z-index:1}
+h2 em{font-style:italic;color:var(--metal);font-weight:400}
+p.lede{color:var(--ink-soft);font-size:17px;line-height:1.6;max-width:48ch;margin:0 0 40px;position:relative;z-index:1}
+.cta-row{display:flex;gap:18px;flex-wrap:wrap;justify-content:center;position:relative;z-index:1}
+.cta{display:inline-flex;align-items:center;gap:10px;padding:18px 32px;font-size:12px;font-weight:500;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;border:1px solid var(--rule);transition:all 240ms ease}
+.cta-primary{background:linear-gradient(180deg,#d4b683,#b89968 38%,#9c7e4f);color:var(--noir);border-color:var(--metal);box-shadow:inset 0 1px 0 rgba(255,235,200,.35)}
+.cta-primary:hover{transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18)}
+.cta-ghost{background:transparent;color:var(--ink)}
+.cta-ghost:hover{border-color:var(--ink)}
+footer{padding:28px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase}
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style></head><body>
+<header><a href="/"><svg viewBox="0 0 28 28" width="28" height="28" aria-hidden="true"><defs><linearGradient id="cb-404" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-404)" stroke-width="1"/><path fill="url(#cb-404)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg><h1>Counsel <span class="amp">&amp;</span> Bar</h1></a></header>
+<main>
+  <div class="code" aria-hidden="true">404</div>
+  <p class="eyebrow">Volume I · Edition 2026</p>
+  <h2>Not in the <em>registry.</em></h2>
+  <p class="lede">The page you're looking for isn't on this site. The California State Bar's public roll is — try searching there, or head back to the directory.</p>
+  <div class="cta-row">
+    <a class="cta cta-primary" href="/">Return home <span style="font-family:var(--serif);font-style:italic;font-size:18px;line-height:1">→</span></a>
+    <a class="cta cta-ghost" href="/find-a-lawyer">Find counsel</a>
+  </div>
+</main>
+<footer>Counsel &amp; Bar · A directory and software platform · Not a law firm, not a lawyer referral service</footer>
+</body></html>`);
+  } else {
+    res.status(404).type('text').send('Not found');
+  }
+});
+
 app.listen(PORT, () => {
   console.log(`[lawyer-directory] listening on http://localhost:${PORT}`);
   console.log(`  open               http://localhost:${PORT}/`);
   console.log(`  api stats          http://localhost:${PORT}/api/stats`);
   console.log(`  api heatmap        http://localhost:${PORT}/api/heatmap`);
+  console.log(`  audit viewer       http://localhost:${PORT}/audit.html`);
 });
 
 process.on('SIGINT',  async () => { await pool.end(); process.exit(0); });
diff --git a/src/server/leads.ts b/src/server/leads.ts
new file mode 100644
index 0000000..607f07d
--- /dev/null
+++ b/src/server/leads.ts
@@ -0,0 +1,761 @@
+/**
+ * Lead-gen funnel.
+ *
+ *   GET  /find-a-lawyer       public form
+ *   POST /find-a-lawyer       creates a lead row, redirects to /find-a-lawyer/thanks
+ *   GET  /find-a-lawyer/thanks  confirmation page
+ *   GET  /admin/leads         admin queue (list, filter by status/area)
+ *   POST /admin/leads/:id/status   set status (new/matched/contacted/won/lost/spam)
+ *   POST /admin/leads/:id/notes    update admin notes
+ */
+import express from 'express';
+import { requireAdmin } from './auth.ts';
+import { query } from '../db/pool.ts';
+import { matchFirmsForLead, matchFirmsWithReason } from '../lib/match_firms.ts';
+
+const router = express.Router();
+router.use(express.urlencoded({ extended: false }));
+
+const PRACTICE_AREAS = [
+  ['personal_injury', 'Personal Injury · Car accident · Slip & fall'],
+  ['family', 'Family · Divorce · Custody · Support'],
+  ['criminal', 'Criminal Defense · DUI · Misdemeanor'],
+  ['immigration', 'Immigration · Visa · Citizenship · Green card'],
+  ['business', 'Business · Contract · Employment'],
+  ['estate', 'Estate · Wills · Trusts · Probate'],
+  ['real_estate', 'Real Estate · Landlord-tenant · Property'],
+  ['bankruptcy', 'Bankruptcy · Debt'],
+  ['ip', 'Intellectual Property · Trademark · Copyright'],
+  ['other', 'Something else'],
+] as const;
+
+const URGENCY = [
+  ['immediate', 'I need to talk to someone today'],
+  ['within_week', 'This week'],
+  ['within_month', 'Within a month'],
+  ['just_researching', "Just researching for now"],
+];
+
+const BUDGETS = [
+  ['under_500', 'Under $500 (consultation only)'],
+  ['500_2k', '$500 – $2,000'],
+  ['2k_10k', '$2,000 – $10,000'],
+  ['10k_plus', '$10,000+'],
+  ['unsure', 'Not sure yet'],
+];
+
+const STATUSES = ['new', 'matched', 'contacted', 'won', 'lost', 'spam'] as const;
+
+function esc(s: any): string {
+  if (s === null || s === undefined) return '';
+  return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
+}
+
+function layout(title: string, body: string, opts: { user?: any; variant?: 'public' | 'admin' | 'default' } = {}) {
+  const userBar = opts.user
+    ? `<div class="topnav-user">Signed in as <b>${esc(opts.user.email)}</b> · <a href="/dashboard">Dashboard</a>${opts.user.role === 'admin' ? ' · <a href="/admin">Admin</a> · <a href="/admin/leads">Leads</a>' : ''} · <form action="/logout" method="post" style="display:inline"><button class="link" type="submit">Sign out</button></form></div>`
+    : `<div class="topnav-user"><a href="/login">Sign in</a> · <a href="/signup">Sign up</a></div>`;
+  const isAdmin = title.includes('Admin');
+  const monogram = `<svg viewBox="0 0 28 28" aria-hidden="true"><defs><linearGradient id="cb-mono-${opts.variant || 'd'}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-mono-${opts.variant || 'd'})" stroke-width="1"/><path fill="url(#cb-mono-${opts.variant || 'd'})" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg>`;
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · Counsel &amp; Bar</title>
+<meta name="description" content="${esc(title)} on Counsel &amp; Bar — California's curated attorney directory.">
+<meta name="robots" content="index,follow">
+<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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{
+  --noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;
+  --rule:#2a2724;--rule-faint:#1a1815;
+  --ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;
+  --metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;
+  --good:#34d399;--warn:#d4a04a;--bad:#d87a7a;
+  --serif:"Cormorant Garamond","Cormorant","Georgia",serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
+}
+*{box-sizing:border-box}
+html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
+body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55}
+::selection{background:var(--metal);color:var(--noir)}
+a{color:var(--metal);text-decoration:none;transition:color 180ms ease}
+a:hover{color:var(--metal-glow)}
+header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
+header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400;letter-spacing:.01em}
+header.brand h1 a{color:var(--ink)}
+header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
+.topnav-user{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
+.topnav-user b{color:var(--ink);font-weight:500}
+.topnav-user a{color:var(--ink-mute);text-transform:uppercase}
+.topnav-user a:hover{color:var(--ink)}
+main{max-width:780px;margin:0 auto;padding:64px 48px}
+main.wide{max-width:1280px}
+h2{margin:0 0 16px;font-family:var(--serif);font-weight:300;font-size:clamp(34px,5vw,52px);line-height:1.1;letter-spacing:-.015em;color:var(--ink)}
+h3{margin:32px 0 12px;font-family:var(--serif);font-weight:400;font-size:22px;color:var(--ink);letter-spacing:-.005em}
+.lede{color:var(--ink-soft);font-size:17px;margin:0 0 36px;max-width:60ch;font-weight:300}
+form.stack{display:flex;flex-direction:column;gap:18px}
+label{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
+input,select,textarea,button{font:inherit;font-family:var(--sans);color:var(--ink);background:var(--noir-rise);border:1px solid var(--rule);border-radius:0;padding:14px 16px;width:100%;transition:border-color 180ms ease,box-shadow 180ms ease}
+input:focus,select:focus,textarea:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
+textarea{resize:vertical;min-height:120px;font-family:var(--sans)}
+button{background:var(--metal);border-color:var(--metal);color:var(--noir);font-weight:500;font-size:12px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;width:auto;padding:16px 28px;transition:background 220ms ease,transform 220ms ease}
+button:hover{background:var(--metal-glow);border-color:var(--metal-glow);transform:translateY(-1px)}
+button.danger{background:transparent;border-color:#5a2222;color:var(--bad)}
+button.warn{background:transparent;border-color:#5a4520;color:var(--warn)}
+button.good{background:transparent;border-color:#1a4a30;color:var(--good)}
+button.link{background:none;border:none;padding:0;color:var(--metal);cursor:pointer;text-decoration:none;font:inherit;font-size:11px;letter-spacing:.14em;text-transform:uppercase;width:auto}
+.row2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
+@media (max-width:520px){.row2{grid-template-columns:1fr}main{padding:36px 20px}header.brand{padding:16px 20px;flex-wrap:wrap;gap:10px}}
+.card{background:var(--noir-rise);border:1px solid var(--rule);padding:32px}
+.flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
+.flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{padding:14px 12px;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
+th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
+.pill{display:inline-block;font-size:10px;padding:3px 9px;border-radius:0;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
+.pill.new{background:#1a1a2a;color:#a5a0d4;border-color:#2a2a4a}
+.pill.matched{background:#0e2a26;color:#7fd1c4;border-color:#1a4a44}
+.pill.contacted{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
+.pill.won{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.pill.lost{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
+.pill.spam{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+.muted{color:var(--ink-mute);font-size:13px}
+.cta{display:inline-block;padding:16px 28px;background:var(--metal);color:var(--noir)!important;text-decoration:none;font-weight:500;font-size:12px;letter-spacing:.18em;text-transform:uppercase;transition:background 220ms ease}
+.cta:hover{background:var(--metal-glow)}
+.actions{display:flex;gap:6px;flex-wrap:wrap}
+.actions form{display:inline}
+.actions button{padding:7px 12px;font-size:10px;letter-spacing:.12em;width:auto}
+
+/* ─── Public configurator (find-a-lawyer / signup) ─────────────────── */
+.public main{max-width:980px;padding:80px 48px 96px}
+@media (max-width:720px){.public main{padding:48px 24px 64px}}
+.public-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
+.public-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
+.public-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
+.public-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
+.public-display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6.2vw,72px);line-height:1.04;letter-spacing:-.018em;margin:0 0 22px;max-width:18ch;color:var(--ink)}
+.public-display em{font-style:italic;color:var(--metal);font-weight:400}
+.public-lede{color:var(--ink-soft);font-size:17px;line-height:1.6;margin:0 0 56px;max-width:62ch;font-weight:300}
+.public-lede b,.public-lede strong{color:var(--ink);font-weight:500}
+.config{display:flex;flex-direction:column;gap:0}
+.config-section{padding:36px 0;border-top:1px solid var(--rule);display:grid;grid-template-columns:140px 1fr;gap:48px;align-items:start}
+.config-section:first-of-type{border-top:none;padding-top:8px}
+@media (max-width:720px){.config-section{grid-template-columns:1fr;gap:18px;padding:28px 0}}
+.config-step{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:56px;line-height:.9;letter-spacing:-.02em;margin:0}
+.config-step .label{display:block;font-family:var(--sans);font-style:normal;font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;-webkit-text-stroke:0;margin-top:10px}
+.config-fields{display:flex;flex-direction:column;gap:22px}
+.config-fields .row2{display:grid;grid-template-columns:1fr 1fr;gap:22px}
+@media (max-width:520px){.config-fields .row2{grid-template-columns:1fr}}
+.config-fields label{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--ink-mute);font-weight:500;display:block;margin-bottom:10px}
+.config-fields input,.config-fields select,.config-fields textarea{padding:18px 18px;font-size:16px;background:rgba(184,153,104,.018);border:0;border-bottom:1px solid var(--rule);border-radius:0}
+.config-fields input:hover,.config-fields select:hover,.config-fields textarea:hover{background:rgba(184,153,104,.035)}
+.config-fields input:focus,.config-fields select:focus,.config-fields textarea:focus{border-color:var(--metal);box-shadow:none;background:rgba(184,153,104,.07)}
+.config-fields textarea{font-size:15px;border:1px solid var(--rule);padding:16px;min-height:120px}
+.config-fields .field-note{font-size:12px;letter-spacing:.04em;color:var(--ink-mute);margin-top:8px;font-style:italic;font-family:var(--serif)}
+.config-cta-row{display:flex;justify-content:space-between;align-items:center;gap:24px;padding:36px 0 0;border-top:1px solid var(--rule);margin-top:8px;flex-wrap:wrap}
+.cta-metal{display:inline-flex;align-items:center;gap:14px;padding:20px 36px;background:linear-gradient(180deg,#d4b683 0%,#b89968 38%,#9c7e4f 100%);color:var(--noir);border:1px solid var(--metal);font-size:12px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;cursor:pointer;text-decoration:none;transition:all 240ms ease;box-shadow:inset 0 1px 0 rgba(255,235,200,.35),0 1px 0 rgba(0,0,0,.4);width:auto}
+.cta-metal:hover{background:linear-gradient(180deg,#e3c89a 0%,#c8a674 50%,#a98559 100%);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18);color:var(--noir)}
+.public-trust{margin-top:64px;padding:32px 36px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.025),transparent)}
+.public-trust .t-eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 12px}
+.public-trust p{margin:0;color:var(--ink-soft);font-size:13px;line-height:1.7;font-weight:300}
+.public-trust p+p{margin-top:10px}
+.brand-mono{display:inline-flex;align-items:center;gap:14px;text-decoration:none}
+.brand-mono svg{width:28px;height:28px;flex:0 0 28px}
+
+/* ─── Thanks / inquiry-logged "what happens next" ──────────────────── */
+.thanks-next{margin:64px 0 0;padding:48px 0 0;border-top:1px solid var(--rule)}
+.thanks-step{font-family:var(--serif);font-weight:300;font-size:28px;color:var(--ink);margin:0 0 32px;letter-spacing:-.005em}
+.thanks-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0}
+.thanks-cell{padding:32px 32px 32px 0;border-right:1px solid var(--rule);position:relative}
+.thanks-cell:last-child{border-right:0;padding-right:0}
+.thanks-cell:not(:first-child){padding-left:32px}
+@media (max-width:720px){.thanks-grid{grid-template-columns:1fr}.thanks-cell{padding:24px 0;border-right:0;border-bottom:1px solid var(--rule)}.thanks-cell:not(:first-child){padding-left:0}.thanks-cell:last-child{border-bottom:0}}
+.thanks-num{font-family:var(--serif);font-style:italic;font-weight:400;color:var(--metal);font-size:14px;letter-spacing:.04em;display:block;margin-bottom:14px}
+.thanks-cell h4{font-family:var(--serif);font-weight:400;font-size:20px;color:var(--ink);margin:0 0 12px;letter-spacing:-.005em;line-height:1.25}
+.thanks-cell p{margin:0 0 18px;color:var(--ink-soft);font-size:14px;line-height:1.65;font-weight:300}
+.thanks-link{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal);font-weight:500;border-bottom:1px solid var(--rule);padding-bottom:2px;transition:border-color 200ms ease,color 200ms ease}
+.thanks-link:hover{color:var(--metal-glow);border-bottom-color:var(--metal)}
+
+/* ─── Inquiry receipt strip ─────────────────────────────────────── */
+.receipt{margin:32px 0 12px;padding:22px 28px;border:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,.025),transparent);display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
+@media (max-width:720px){.receipt{grid-template-columns:1fr;gap:14px;padding:18px 22px}}
+.receipt-row{display:flex;flex-direction:column;gap:4px}
+.r-label{font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
+.r-val{font-family:var(--serif);font-style:italic;color:var(--metal);font-size:16px;font-variant-numeric:tabular-nums;letter-spacing:.02em}
+
+/* ─── Firm result cards (find-a-lawyer/results) ──────────────────── */
+.firms{display:flex;flex-direction:column;gap:0;margin:0 0 24px;border-top:1px solid var(--rule)}
+.firm-card{display:grid;grid-template-columns:64px 1fr;gap:24px;padding:32px 28px;border-bottom:1px solid var(--rule);align-items:start;margin:0 -28px;border-left:1px solid transparent;border-right:1px solid transparent;transition:background 280ms ease,border-color 280ms ease,transform 280ms cubic-bezier(.16,.84,.3,1)}
+.firm-card:hover{background:linear-gradient(180deg,rgba(184,153,104,.025),rgba(184,153,104,.012));border-left-color:var(--metal);border-right-color:var(--rule);transform:translateX(2px)}
+.firm-card:hover .firm-num{-webkit-text-stroke-color:var(--metal-glow);opacity:1}
+.firm-card:hover .firm-name{color:var(--metal-glow)}
+@media (max-width:520px){.firm-card{grid-template-columns:1fr;gap:8px;padding:24px 16px;margin:0 -16px}}
+.firm-num{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:36px;line-height:.9;letter-spacing:-.02em;margin:0;transition:opacity 280ms ease,-webkit-text-stroke-color 280ms ease}
+.firm-name{font-family:var(--serif);font-weight:400;font-size:24px;line-height:1.18;color:var(--ink);margin:0 0 8px;letter-spacing:-.005em;transition:color 280ms ease}
+.firm-meta{font-size:12px;letter-spacing:.04em;color:var(--ink-mute);margin:0 0 18px;display:flex;flex-wrap:wrap;gap:8px}
+.firm-meta .firm-dot{color:var(--metal);opacity:.6}
+.firm-actions{display:flex;flex-wrap:wrap;gap:14px 28px;align-items:baseline}
+.firm-tel{font-family:var(--serif);font-style:italic;font-weight:400;color:var(--metal);font-size:18px;letter-spacing:.02em;font-variant-numeric:tabular-nums}
+.firm-web{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal);font-weight:500;border-bottom:1px solid var(--rule);padding-bottom:2px;transition:border-color 200ms ease,color 200ms ease}
+.firm-web:hover{color:var(--metal-glow);border-bottom-color:var(--metal)}
+
+/* ─── Empty state (zero firms in proximity) ─────────────────────── */
+.empty-state{position:relative;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:96px 0 88px;margin:0 0 48px;overflow:hidden}
+.empty-state::before{content:"\\2014";position:absolute;top:50%;left:50%;transform:translate(-50%,-58%);font-family:var(--serif);font-style:italic;font-weight:300;font-size:40vw;line-height:1;color:transparent;-webkit-text-stroke:1.5px rgba(184,153,104,.28);pointer-events:none;z-index:0;letter-spacing:-.04em}
+@media (max-width:720px){.empty-state{padding:64px 0 60px}.empty-state::before{font-size:54vw;-webkit-text-stroke-width:1.25px}}
+.empty-eyebrow{position:relative;z-index:1;font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 22px;text-align:center}
+.empty-headline{position:relative;z-index:1;font-family:var(--serif);font-weight:300;font-size:56px;line-height:1.05;color:var(--ink);margin:0 auto 18px;letter-spacing:-.015em;text-align:center;max-width:14ch}
+.empty-headline em{font-style:italic;color:var(--metal);font-weight:300}
+@media (max-width:720px){.empty-headline{font-size:38px}}
+.empty-body{position:relative;z-index:1;font-size:15px;line-height:1.7;color:var(--ink-soft);max-width:54ch;margin:0 auto 44px;text-align:center}
+.empty-paths{position:relative;z-index:1;display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid var(--rule);max-width:880px;margin:0 auto}
+@media (max-width:720px){.empty-paths{grid-template-columns:1fr}}
+.empty-cell{padding:32px 28px;border-right:1px solid var(--rule);display:flex;flex-direction:column;gap:12px;transition:background 280ms ease}
+.empty-cell:last-child{border-right:none}
+.empty-cell:hover{background:linear-gradient(180deg,rgba(184,153,104,.025),transparent)}
+@media (max-width:720px){.empty-cell{border-right:none;border-bottom:1px solid var(--rule);padding:28px 16px}.empty-cell:last-child{border-bottom:none}}
+.empty-num{font-family:var(--serif);font-style:italic;font-weight:300;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:28px;line-height:1;letter-spacing:-.02em}
+.empty-cell h4{font-family:var(--serif);font-weight:400;font-size:18px;line-height:1.25;color:var(--ink);margin:0;letter-spacing:-.005em}
+.empty-cell p{font-size:13px;line-height:1.6;color:var(--ink-mute);margin:0;flex:1}
+.empty-cell a.empty-link{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal);font-weight:500;border-bottom:1px solid var(--rule);padding-bottom:3px;align-self:flex-start;transition:border-color 200ms ease,color 200ms ease;text-decoration:none}
+.empty-cell a.empty-link:hover{color:var(--metal-glow);border-bottom-color:var(--metal)}
+
+/* ─── Print stylesheet — receipt-friendly /thanks page ───────────── */
+@media print {
+  :root{--noir:#fff;--noir-rise:#fff;--noir-deep:#fff;--ink:#000;--ink-soft:#222;--ink-mute:#555;--metal:#7a5d2a;--metal-glow:#7a5d2a;--rule:#aaa;--rule-faint:#ddd}
+  body{background:#fff!important;color:#000!important;font-size:11pt;line-height:1.5}
+  header.brand,.topnav-user,nav,.cta-metal,.cta-row,.config-cta-row,.scroll-reveal{opacity:1!important;transform:none!important;animation:none!important}
+  header.brand{padding:0 0 18pt;border-bottom:1px solid #000;margin-bottom:18pt;background:transparent!important}
+  header.brand h1, header.brand h1 *{color:#000!important}
+  .topnav-user, .signup-vol .v-rule, .signup-vol .v-meta, footer{display:none!important}
+  main{padding:0!important;max-width:none}
+  /* Receipt prominent */
+  .receipt{border:2px solid #000!important;background:#fff!important;page-break-inside:avoid;margin:0 0 14pt!important;padding:12pt 16pt!important}
+  .r-label{color:#666!important;font-size:8pt!important}
+  .r-val{color:#000!important;font-size:12pt!important}
+  /* Display headline */
+  .public-display{color:#000!important;font-size:24pt!important;margin:0 0 10pt!important}
+  .public-display em{color:#7a5d2a!important}
+  .public-vol .v-text, .public-vol .v-meta{color:#7a5d2a!important}
+  .public-lede{color:#000!important;font-size:11pt!important;margin:0 0 14pt!important}
+  /* Steps as bullet list */
+  .thanks-next{margin:14pt 0 0;padding:14pt 0 0;border-top:1px solid #000}
+  .thanks-step{font-size:14pt!important;color:#000!important;margin:0 0 10pt!important}
+  .thanks-grid{display:block!important}
+  .thanks-cell{display:block!important;padding:6pt 0!important;border:none!important;page-break-inside:avoid}
+  .thanks-cell:not(:first-child){padding-left:0!important}
+  .thanks-num{color:#7a5d2a!important;font-size:10pt!important;margin:0 0 4pt!important}
+  .thanks-cell h4{color:#000!important;font-size:12pt!important;margin:0 0 4pt!important}
+  .thanks-cell p{color:#000!important;font-size:10pt!important;margin:0 0 6pt!important}
+  .thanks-link{display:none!important}
+  /* Trust block prominent */
+  .public-trust{margin-top:14pt!important;padding:10pt 12pt!important;border:1px solid #000!important;background:#fff!important;page-break-inside:avoid}
+  .public-trust .t-eyebrow{color:#7a5d2a!important}
+  .public-trust p{color:#000!important;font-size:10pt!important}
+  /* Hide all CTAs/buttons in print */
+  button,.cta-metal,.config-cta-row{display:none!important}
+}
+
+/* ─── Scroll-reveal stagger (matches landing) ────────────────────── */
+@media (prefers-reduced-motion: no-preference) {
+  .scroll-reveal{opacity:0;transform:translateY(22px);transition:opacity 900ms cubic-bezier(.16,.84,.3,1),transform 900ms cubic-bezier(.16,.84,.3,1)}
+  .scroll-reveal.in-view{opacity:1;transform:none}
+  .scroll-reveal.s1{transition-delay:80ms}
+  .scroll-reveal.s2{transition-delay:160ms}
+  .scroll-reveal.s3{transition-delay:240ms}
+  .scroll-reveal.s4{transition-delay:320ms}
+}
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style>
+</head><body class="${opts.variant === 'public' ? 'public' : ''}">
+<header class="brand">
+  <a class="brand-mono" href="/">${monogram}<h1 style="margin:0"><span style="color:var(--ink)">Counsel</span> <span class="amp">&amp;</span> <span style="color:var(--ink)">Bar</span></h1></a>
+  ${userBar}
+</header>
+<main${isAdmin ? ' class="wide"' : ''}>${body}</main>
+<script>
+(function(){
+  if(!('IntersectionObserver' in window))return;
+  if(window.matchMedia('(prefers-reduced-motion: reduce)').matches){
+    document.querySelectorAll('.scroll-reveal').forEach(function(el){el.classList.add('in-view');});return;
+  }
+  var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in-view');io.unobserve(e.target);}});},{threshold:0.12,rootMargin:'0px 0px -10% 0px'});
+  document.querySelectorAll('.scroll-reveal').forEach(function(el){io.observe(el);});
+})();
+</script>
+</body></html>`;
+}
+
+function flashFromQuery(req: express.Request): string {
+  const msg = (req.query.msg as string) || '';
+  const err = (req.query.err as string) || '';
+  if (err) return `<div class="flash error">${esc(err)}</div>`;
+  if (msg) return `<div class="flash success">${esc(msg)}</div>`;
+  return '';
+}
+
+// ─── public form ────────────────────────────────────────────────────────────
+
+router.get('/find-a-lawyer', (req, res) => {
+  const areaOpts = PRACTICE_AREAS.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('');
+  const urgencyOpts = URGENCY.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('');
+  const budgetOpts = BUDGETS.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('');
+  res.send(layout('Find a Lawyer', `
+    <div class="public-vol">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · Public Directory</span>
+    </div>
+    <h2 class="public-display">Find your California attorney, <em>curated.</em></h2>
+    <p class="public-lede">A short configurator. Three sections — about you, the matter, where you are. We'll show California-licensed firms that handle your practice area in your county. <b>You decide who to call.</b></p>
+    <p class="config-tagline" style="font-family:var(--serif);font-style:italic;color:var(--metal);font-size:14px;letter-spacing:.04em;margin:-32px 0 56px">Three short sections. About sixty seconds. The State Bar's public roll, indexed.</p>
+    ${flashFromQuery(req)}
+    <form class="config" method="post" action="/find-a-lawyer" autocomplete="on">
+
+      <div class="config-section scroll-reveal">
+        <h3 class="config-step"><em>i</em><span class="label">You</span></h3>
+        <div class="config-fields">
+          <div>
+            <label for="full_name">Your name</label>
+            <input id="full_name" name="full_name" required autocomplete="name" placeholder="Jane Doe">
+          </div>
+          <div class="row2">
+            <div>
+              <label for="email">Email</label>
+              <input id="email" name="email" type="email" required autocomplete="email" placeholder="jane@example.com">
+            </div>
+            <div>
+              <label for="phone">Phone <span style="text-transform:none;letter-spacing:.04em;font-style:italic;color:var(--ink-mute)">(optional)</span></label>
+              <input id="phone" name="phone" type="tel" autocomplete="tel" placeholder="(415) 555-0100">
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="config-section scroll-reveal">
+        <h3 class="config-step"><em>ii</em><span class="label">The matter</span></h3>
+        <div class="config-fields">
+          <div class="row2">
+            <div>
+              <label for="practice_area">Practice area</label>
+              <select id="practice_area" name="practice_area" required>${areaOpts}</select>
+            </div>
+            <div>
+              <label for="urgency">How soon?</label>
+              <select id="urgency" name="urgency" required>${urgencyOpts}</select>
+            </div>
+          </div>
+          <div>
+            <label for="case_description">Briefly, what's going on?</label>
+            <textarea id="case_description" name="case_description" rows="4" placeholder="A few sentences. Don't include sensitive details — that's for the consult." maxlength="2000"></textarea>
+            <p class="field-note">Optional. Kept private — only shown to a firm if you click to contact them.</p>
+          </div>
+        </div>
+      </div>
+
+      <div class="config-section scroll-reveal">
+        <h3 class="config-step"><em>iii</em><span class="label">Geography</span></h3>
+        <div class="config-fields">
+          <div class="row2">
+            <div>
+              <label for="zip">ZIP code</label>
+              <input id="zip" name="zip" pattern="[0-9]{5}" placeholder="90210" required>
+            </div>
+            <div>
+              <label for="budget">Budget</label>
+              <select id="budget" name="budget">${budgetOpts}</select>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="config-cta-row">
+        <button type="submit" class="cta-metal">See California-licensed firms <span style="font-family:var(--serif);font-style:italic;font-size:18px;line-height:1">→</span></button>
+        <p class="field-note" style="margin:0;max-width:32ch">By submitting you authorize us to display matching firms. Your details are not sent to any firm until you click to contact one.</p>
+      </div>
+    </form>
+
+    <aside class="public-trust scroll-reveal">
+      <p class="t-eyebrow">A directory, not a referral service</p>
+      <p>Every attorney is checked against the State Bar of California's active roll — verify status yourself at <a href="https://www.calbar.ca.gov" rel="noopener">calbar.ca.gov</a> before retaining anyone. We do not route, sell, or share your lead-form data; communications here are <strong>not</strong> privileged until an attorney-client relationship exists.</p>
+    </aside>
+  `, { variant: 'public' }));
+});
+
+router.post('/find-a-lawyer', async (req, res) => {
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const b = req.body as any;
+  try {
+    if (!b.full_name || !b.email || !b.practice_area || !b.urgency || !b.zip) {
+      throw new Error('Please fill the required fields');
+    }
+    if (!/^\d{5}$/.test(b.zip)) throw new Error('ZIP must be 5 digits');
+    const allowedAreas = PRACTICE_AREAS.map(p => p[0]);
+    if (!allowedAreas.includes(b.practice_area)) throw new Error('Invalid practice area');
+
+    const ins = await query<{ id: number }>(`
+      INSERT INTO leads (full_name, email, phone, practice_area, zip, case_description, budget, urgency, consent_to_contact, ip, user_agent, source)
+      VALUES ($1, LOWER($2), $3, $4, $5, $6, $7, $8, $9, $10, $11, 'public_form')
+      RETURNING id
+    `, [
+      String(b.full_name).trim().slice(0, 120),
+      String(b.email).trim().slice(0, 200),
+      b.phone ? String(b.phone).trim().slice(0, 40) : null,
+      b.practice_area,
+      String(b.zip).trim(),
+      b.case_description ? String(b.case_description).trim().slice(0, 2000) : null,
+      b.budget || null,
+      b.urgency,
+      !!b.consent_to_contact,
+      req.ip || null,
+      (req.headers['user-agent'] || '').toString().slice(0, 200),
+    ]);
+    const leadId = ins.rows[0].id;
+    // §6155 NOTE: this lookup populates the list of nearby firms we show to
+    // the CONSUMER on the thanks page so they can pick who to contact. We do
+    // NOT push the lead to firms automatically, do not email firms about the
+    // lead, and do not change status to "matched" — this is directory display,
+    // not a referral. Status stays 'new' until the consumer explicitly clicks
+    // to contact a specific firm (handled in a separate /contact-firm route).
+    try {
+      const candidates = await matchFirmsForLead({ zip: String(b.zip).trim(), limit: 5 });
+      if (candidates.length > 0) {
+        const firmIds = candidates.map(m => m.id);
+        await query(
+          `UPDATE leads SET matched_firm_ids = $2 WHERE id = $1`,
+          [leadId, firmIds]);
+      }
+    } catch (matchErr) {
+      console.error(`[leads] candidate firm lookup error for #${leadId}:`, matchErr);
+    }
+    const areaLabel = (PRACTICE_AREAS.find(a => a[0] === b.practice_area)?.[1]) || b.practice_area;
+    res.redirect(`/find-a-lawyer/thanks?zip=${encodeURIComponent(String(b.zip).trim())}&area=${encodeURIComponent(areaLabel)}`);
+  } catch (e) {
+    res.redirect('/find-a-lawyer?err=' + encodeURIComponent((e as Error).message));
+  }
+});
+
+router.get('/find-a-lawyer/results', async (req, res) => {
+  const zip = ((req.query.zip as string) || '').trim().slice(0, 5);
+  const area = ((req.query.area as string) || '').trim().slice(0, 80);
+  const radiusRaw = parseInt((req.query.radius as string) || '', 10);
+  const radiusKm = Number.isFinite(radiusRaw) && radiusRaw > 0 && radiusRaw <= 500 ? radiusRaw : 80;
+  const safeZip = /^\d{5}$/.test(zip) ? zip : null;
+  let firms: any[] = [];
+  let reason: 'ok' | 'no_zip' | 'no_anchor' | 'no_firms_in_radius' = 'ok';
+  try {
+    const result = await matchFirmsWithReason({ zip: safeZip, limit: 8, radiusKm });
+    firms = result.firms;
+    reason = result.reason;
+  } catch (e) {
+    console.error('[find/results] match error', e);
+  }
+  const firmCards = firms.length === 0
+    ? ''
+    : firms.map((f, i) => `
+        <div class="firm-card scroll-reveal">
+          <div class="firm-num">${String(i + 1).padStart(2, '0')}</div>
+          <div class="firm-body">
+            <h3 class="firm-name">${esc(f.name || 'Firm')}</h3>
+            <div class="firm-meta">
+              ${f.city ? `<span>${esc(f.city)}${f.zip ? ', CA ' + esc(f.zip) : ''}</span>` : ''}
+              ${f.distance_km != null ? `<span class="firm-dot">·</span><span>${f.distance_km.toFixed(1)} km</span>` : ''}
+              ${f.attorney_count ? `<span class="firm-dot">·</span><span>${f.attorney_count} attorney${f.attorney_count === 1 ? '' : 's'}</span>` : ''}
+            </div>
+            <div class="firm-actions">
+              ${f.phone ? `<a class="firm-tel" href="tel:${esc(f.phone)}">${esc(f.phone)}</a>` : ''}
+              ${f.website ? `<a class="firm-web" href="${esc(f.website)}" target="_blank" rel="noopener">Visit website →</a>` : ''}
+            </div>
+          </div>
+        </div>`).join('');
+
+  const isEmpty = firms.length === 0;
+
+  // Reason-branched copy. Three cases produce different headlines, ledes, and path cells.
+  const VOL_META: Record<typeof reason, string> = {
+    ok: 'Your Directory Results',
+    no_zip: 'Browse The Directory',
+    no_anchor: 'Unmapped ZIP',
+    no_firms_in_radius: 'No Match In Radius',
+  };
+
+  let headline: string;
+  let lede: string;
+  if (!isEmpty) {
+    headline = `<h2 class="public-display">${firms.length} firm${firms.length === 1 ? '' : 's'} <em>near ${safeZip || 'you'}</em></h2>`;
+    lede = `<p class="public-lede">Drawn from the State Bar of California public roll. <strong>Sorted by proximity${safeZip ? ' to ZIP ' + safeZip : ''}.</strong> You choose who to call. Verify license status before retaining anyone.</p>`;
+  } else if (reason === 'no_anchor') {
+    headline = `<h2 class="public-display">No anchor for <em>ZIP ${safeZip || ''}.</em></h2>`;
+    lede = `<p class="public-lede">This directory has no firms registered in or adjacent to that ZIP, so we cannot compute a proximity ranking for it. <strong>This isn't a search failure — it's an honest limit of the data we hold.</strong> Two paths below.</p>`;
+  } else if (reason === 'no_firms_in_radius') {
+    headline = `<h2 class="public-display">No firms <em>within ${radiusKm}&nbsp;km.</em></h2>`;
+    lede = `<p class="public-lede">This directory lists no California-licensed firms within ${radiusKm}&nbsp;km of ZIP ${safeZip}. Tens of thousands of attorneys are licensed statewide; the closest may simply sit outside this radius. Three honest options below.</p>`;
+  } else {
+    headline = `<h2 class="public-display">No results.</h2>`;
+    lede = `<p class="public-lede">Try entering a California ZIP code to see firms ranked by proximity.</p>`;
+  }
+
+  // Path cells vary by reason. "Widen radius" suppressed when no_anchor (would still return empty).
+  const cellWiden = `
+    <div class="empty-cell scroll-reveal s1">
+      <span class="empty-num">i.</span>
+      <h4>Widen the radius</h4>
+      <p>Re-run the search at 160&nbsp;km (~100 mi) to capture firms in the next county. Most will travel for a qualifying matter.</p>
+      <a class="empty-link" href="/find-a-lawyer/results?zip=${safeZip}&radius=160">Search 160&nbsp;km →</a>
+    </div>`;
+  const cellMetro = `
+    <div class="empty-cell scroll-reveal s2">
+      <span class="empty-num">${reason === 'no_anchor' ? 'i.' : 'ii.'}</span>
+      <h4>Anchor on a metro</h4>
+      <p>Statewide-licensed firms in Los Angeles, San Francisco, and San Diego accept matters across California. Browse by metro instead of ZIP.</p>
+      <a class="empty-link" href="/find-a-lawyer/results?zip=90013">Try downtown LA →</a>
+    </div>`;
+  const cellVerify = `
+    <div class="empty-cell scroll-reveal s3">
+      <span class="empty-num">${reason === 'no_anchor' ? 'ii.' : 'iii.'}</span>
+      <h4>Verify a name</h4>
+      <p>If you already have a lawyer in mind, look them up directly on the State Bar's public roll to confirm active license and standing.</p>
+      <a class="empty-link" href="https://apps.calbar.ca.gov/attorney/Licensee/Search" target="_blank" rel="noopener">State Bar lookup →</a>
+    </div>`;
+
+  const cells = reason === 'no_anchor'
+    ? cellMetro + cellVerify
+    : cellWiden + cellMetro + cellVerify;
+
+  const eyebrowText = reason === 'no_anchor' ? '— Outside the registry —' : '— Outside the radius —';
+  const emptyHeadline = reason === 'no_anchor'
+    ? `<h3 class="empty-headline">Try a <em>metro anchor</em>, or look the lawyer up directly.</h3>`
+    : `<h3 class="empty-headline">A wider <em>circle</em> is usually all it takes.</h3>`;
+  const emptyBody = reason === 'no_anchor'
+    ? `<p class="empty-body">California is the country's largest legal market. Most matters can be handled by counsel statewide; physical proximity matters less than fit, fee structure, and bar standing. Pick the path that suits your matter.</p>`
+    : `<p class="empty-body">California is the country's largest legal market. Most matters can be handled by counsel statewide; physical proximity matters less than fit, fee structure, and bar standing. Pick the path that suits your matter.</p>`;
+
+  const emptyBlock = isEmpty ? `
+    <section class="empty-state scroll-reveal" aria-label="${eyebrowText}">
+      <p class="empty-eyebrow">${eyebrowText}</p>
+      ${emptyHeadline}
+      ${emptyBody}
+      <div class="empty-paths" style="${reason === 'no_anchor' ? 'grid-template-columns:repeat(2,1fr);max-width:620px' : ''}">
+        ${cells}
+      </div>
+    </section>
+  ` : '';
+
+  const firmsBlock = isEmpty ? '' : `<section class="firms">${firmCards}</section>`;
+
+  // Trust block varies — populated case references "names above"; empty cases don't.
+  const trustBlock = !isEmpty
+    ? `<aside class="public-trust scroll-reveal" style="margin-top:48px">
+         <p class="t-eyebrow">For the record</p>
+         <p>Names and contacts above are sourced from the State Bar of California's public licensing database. <strong>Counsel &amp; Bar does not refer your case</strong>, does not accept fees from these firms for placement, and has not contacted any of them on your behalf. Always confirm license status at <a href="https://www.calbar.ca.gov" rel="noopener">calbar.ca.gov</a> before engaging.</p>
+       </aside>`
+    : `<aside class="public-trust scroll-reveal" style="margin-top:48px">
+         <p class="t-eyebrow">For the record</p>
+         <p>This directory lists California-licensed attorneys and firms drawn from the State Bar's public roll. <strong>Counsel &amp; Bar does not refer cases, does not accept placement fees, and does not contact firms on a user's behalf.</strong> The State Bar at <a href="https://www.calbar.ca.gov" rel="noopener">calbar.ca.gov</a> is the authoritative source for license status — always verify before engaging.</p>
+       </aside>`;
+
+  res.send(layout('California-licensed firms', `
+    <div class="public-vol">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · ${VOL_META[reason]}</span>
+    </div>
+    ${headline}
+    ${lede}
+    ${area && !isEmpty ? `<p class="config-tagline" style="font-family:var(--serif);font-style:italic;color:var(--metal);font-size:14px;letter-spacing:.04em;margin:-32px 0 56px">Your inquiry: ${esc(area)} &nbsp;·&nbsp; <span style="color:var(--ink-mute);font-style:normal;font-family:var(--sans);font-size:11px;letter-spacing:.18em;text-transform:uppercase">practice-area filter coming soon — verify each firm handles your matter before contacting</span></p>` : ''}
+
+    ${emptyBlock}
+    ${firmsBlock}
+    ${trustBlock}
+  `, { variant: 'public' }));
+});
+
+router.get('/find-a-lawyer/thanks', (req, res) => {
+  // Generate a non-PII receipt token: YYYY-MM-DD-HHMM-XXXX (4-char rand)
+  const now = new Date();
+  const pad = (n: number) => String(n).padStart(2, '0');
+  const rand = Math.random().toString(36).slice(2, 6).toUpperCase();
+  const inquiryNo = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}-${rand}`;
+  const tstamp = `${pad(now.getHours())}:${pad(now.getMinutes())} PT · ${now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
+  const zip = esc((req.query.zip as string) || '');
+  const area = esc((req.query.area as string) || '');
+  const receiptDetails = [zip && `ZIP ${zip}`, area && area].filter(Boolean).join(' · ');
+  const resultsHref = `/find-a-lawyer/results${zip || area ? `?${[zip && `zip=${zip}`, area && `area=${encodeURIComponent(area)}`].filter(Boolean).join('&')}` : ''}`;
+  res.send(layout('Inquiry logged', `
+    <div class="public-vol">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · Inquiry Logged</span>
+    </div>
+    <h2 class="public-display">Your inquiry is <em>logged.</em></h2>
+    <p class="public-lede">We've recorded your details for our administrative records. <strong>We do not contact firms on your behalf.</strong> This is a directory, not a referral service — you choose who to call, when to call, and what to say.</p>
+
+    <div class="receipt scroll-reveal">
+      <div class="receipt-row">
+        <span class="r-label">Inquiry №.</span>
+        <span class="r-val">${inquiryNo}</span>
+      </div>
+      <div class="receipt-row">
+        <span class="r-label">Logged</span>
+        <span class="r-val">${tstamp}</span>
+      </div>
+      ${receiptDetails ? `<div class="receipt-row"><span class="r-label">Filed under</span><span class="r-val">${receiptDetails}</span></div>` : ''}
+    </div>
+
+    <section class="thanks-next">
+      <h3 class="thanks-step">What happens next</h3>
+      <div class="thanks-grid">
+        <div class="thanks-cell scroll-reveal">
+          <span class="thanks-num">i.</span>
+          <h4>Browse the directory</h4>
+          <p>Open the directory and filter California-licensed firms by city, practice area, and county. Read profiles, reviews, and audit notes. Take your time.</p>
+          <a class="thanks-link" href="${resultsHref}">See your firms →</a>
+        </div>
+        <div class="thanks-cell scroll-reveal">
+          <span class="thanks-num">ii.</span>
+          <h4>Verify with the State Bar</h4>
+          <p>Before retaining anyone, confirm the attorney's license is active and in good standing on the State Bar's public roll. We refresh against it daily but the live source is authoritative.</p>
+          <a class="thanks-link" href="https://www.calbar.ca.gov" rel="noopener">calbar.ca.gov →</a>
+        </div>
+        <div class="thanks-cell scroll-reveal">
+          <span class="thanks-num">iii.</span>
+          <h4>Contact firms directly</h4>
+          <p>Use the published phone, email, or contact form on each firm's listing. Don't include privileged or sensitive details until you've established an attorney-client relationship with a specific lawyer.</p>
+          <a class="thanks-link" href="${resultsHref}">Begin browsing →</a>
+        </div>
+      </div>
+    </section>
+
+    <aside class="public-trust scroll-reveal" style="margin-top:48px">
+      <p class="t-eyebrow">For the record</p>
+      <p>Communications submitted to this site are <strong>not</strong> privileged. We do not share your inquiry data with attorneys, do not sell consumer leads, and do not accept fees from attorneys for placement of any specific consumer. <a href="/privacy.html">Privacy</a> · <a href="/terms.html">Terms</a></p>
+    </aside>
+
+  `, { variant: 'public' }));
+});
+
+// ─── admin queue ────────────────────────────────────────────────────────────
+
+router.get('/admin/leads', requireAdmin, async (req, res) => {
+  const status = ((req.query.status as string) || '').trim();
+  const area = ((req.query.area as string) || '').trim();
+  const where: string[] = [];
+  const params: any[] = [];
+  if (status && (STATUSES as readonly string[]).includes(status)) { params.push(status); where.push(`status = $${params.length}`); }
+  if (area) { params.push(area); where.push(`practice_area = $${params.length}`); }
+  const sql = `
+    SELECT
+      l.id, l.full_name, l.email, l.phone, l.practice_area, l.zip, l.urgency, l.budget, l.status,
+      LEFT(COALESCE(l.case_description,''), 140) AS case_short, l.admin_notes, l.created_at,
+      l.matched_firm_ids,
+      COALESCE(
+        (SELECT json_agg(json_build_object(
+            'id', o.id, 'name', o.name, 'city', o.city, 'phone', o.phone, 'website', o.website
+          ) ORDER BY array_position(l.matched_firm_ids, o.id))
+         FROM organizations o
+         WHERE o.id = ANY(l.matched_firm_ids)),
+        '[]'::json
+      ) AS matched_firms
+    FROM leads l
+    ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+    ORDER BY l.id DESC
+    LIMIT 200
+  `;
+  const r = await query(sql, params);
+
+  const counts = await query<{ k: string; v: string }>(`
+    SELECT 'total' AS k, COUNT(*)::text AS v FROM leads
+    UNION ALL SELECT 'new',       COUNT(*)::text FROM leads WHERE status='new'
+    UNION ALL SELECT 'matched',   COUNT(*)::text FROM leads WHERE status='matched'
+    UNION ALL SELECT 'contacted', COUNT(*)::text FROM leads WHERE status='contacted'
+    UNION ALL SELECT 'won',       COUNT(*)::text FROM leads WHERE status='won'
+  `);
+  const cm: Record<string, string> = {}; for (const row of counts.rows) cm[row.k] = row.v;
+
+  const filterPills = ['', ...STATUSES].map(s =>
+    `<a class="pill ${esc(s)}" href="/admin/leads${s ? `?status=${esc(s)}` : ''}" style="text-decoration:none;${status === s ? 'outline:2px solid var(--accent)' : ''}">${s || 'all'}</a>`
+  ).join(' ');
+
+  const rows = r.rows.map((l: any) => {
+    const opts = STATUSES.map(s => `<option value="${s}"${l.status === s ? ' selected' : ''}>${s}</option>`).join('');
+    const firms = (l.matched_firms || []) as Array<{id:number;name:string;city:string|null;phone:string|null;website:string|null}>;
+    const firmsCell = firms.length === 0
+      ? '<span class="muted">—</span>'
+      : firms.map(f => {
+          const links: string[] = [];
+          if (f.phone) links.push(`<a href="tel:${esc(f.phone)}" title="${esc(f.phone)}">☎</a>`);
+          if (f.website) links.push(`<a href="${esc(f.website)}" target="_blank" rel="noopener" title="${esc(f.website)}">↗</a>`);
+          return `<div class="match-row"><span class="match-name">${esc(f.name)}</span><span class="match-city">${esc(f.city || '')}</span> ${links.join(' ')}</div>`;
+        }).join('');
+    return `<tr>
+      <td>#${l.id}<br><span class="muted">${new Date(l.created_at).toISOString().slice(0, 16).replace('T',' ')}</span></td>
+      <td><b>${esc(l.full_name)}</b><br>
+        <a href="mailto:${esc(l.email)}">${esc(l.email)}</a>${l.phone ? `<br><a href="tel:${esc(l.phone)}">${esc(l.phone)}</a>` : ''}
+      </td>
+      <td>${esc(l.practice_area)}<br><span class="muted">ZIP ${esc(l.zip || '—')}</span></td>
+      <td><span class="muted">${esc(l.urgency || '—')}</span><br><span class="muted">${esc(l.budget || '—')}</span></td>
+      <td style="max-width:240px">${esc(l.case_short || '—')}</td>
+      <td style="min-width:240px;font-size:12px;line-height:1.5">${firmsCell}</td>
+      <td><span class="pill ${esc(l.status)}">${esc(l.status)}</span></td>
+      <td>
+        <form method="post" action="/admin/leads/${l.id}/status" class="actions">
+          <select name="status" onchange="this.form.submit()">${opts}</select>
+        </form>
+      </td>
+    </tr>`;
+  }).join('');
+
+  res.send(layout('Admin · Leads', `
+    <h2>Leads</h2>
+    <div class="muted" style="margin-bottom:14px">
+      <b>${cm.total || 0}</b> total · <b>${cm.new || 0}</b> new · <b>${cm.matched || 0}</b> matched · <b>${cm.contacted || 0}</b> contacted · <b>${cm.won || 0}</b> won
+    </div>
+    ${flashFromQuery(req)}
+    <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px">${filterPills}</div>
+    <style>
+      .match-row{padding:3px 0;display:flex;align-items:center;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--rule-faint)}
+      .match-row:last-child{border-bottom:none}
+      .match-name{color:var(--ink);font-weight:500}
+      .match-city{color:var(--ink-mute);font-style:italic;font-size:11px}
+      .match-row a{color:var(--metal);text-decoration:none;padding:0 4px}
+      .match-row a:hover{color:var(--metal-glow)}
+    </style>
+    <table>
+      <thead><tr><th>ID / When</th><th>Person</th><th>Area</th><th>Urgency / $</th><th>Case</th><th>Matched firms</th><th>Status</th><th></th></tr></thead>
+      <tbody>${rows || '<tr><td colspan="8" class="muted" style="padding:30px;text-align:center">No leads yet — share <a href="/find-a-lawyer">the form</a></td></tr>'}</tbody>
+    </table>
+  `, { user: req.user }));
+});
+
+router.post('/admin/leads/:id/status', requireAdmin, async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const status = (req.body.status as string)?.trim();
+  if (!(STATUSES as readonly string[]).includes(status)) {
+    return res.redirect('/admin/leads?err=' + encodeURIComponent('Invalid status'));
+  }
+  await query(`UPDATE leads SET status = $2 WHERE id = $1`, [id, status]);
+  res.redirect('/admin/leads?msg=' + encodeURIComponent(`#${id} → ${status}`));
+});
+
+router.post('/admin/leads/:id/notes', requireAdmin, async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const notes = (req.body.notes as string)?.trim() || null;
+  await query(`UPDATE leads SET admin_notes = $2 WHERE id = $1`, [id, notes]);
+  res.redirect('/admin/leads?msg=' + encodeURIComponent(`#${id} notes saved`));
+});
+
+export default router;
diff --git a/src/server/portal.ts b/src/server/portal.ts
new file mode 100644
index 0000000..d6e8e3e
--- /dev/null
+++ b/src/server/portal.ts
@@ -0,0 +1,644 @@
+/**
+ * User + admin portals.
+ *
+ *   /signup, /login, /logout, /dashboard       — user
+ *   /admin                                      — admin (list, edit plan, suspend, delete)
+ *
+ * All HTML is server-rendered inline (no React/SSR framework — keep it simple
+ * and self-contained). Forms post to action routes; route handlers redirect
+ * back to a page with a flash query param.
+ */
+import express from 'express';
+import {
+  hashPassword, verifyPassword, createUser, findUserByEmail, createSession, destroySession,
+  setSessionCookie, clearSessionCookie, requireUser, requireAdmin, PLANS, STATUSES,
+} from './auth.ts';
+import { query } from '../db/pool.ts';
+import cookie from 'cookie';
+
+const router = express.Router();
+router.use(express.urlencoded({ extended: false }));
+
+// ─── shared layout helpers ─────────────────────────────────────────────────
+
+function layout(title: string, body: string, opts: { user?: any } = {}) {
+  const userBar = opts.user
+    ? `<div class="topnav-user">Signed in as <b>${esc(opts.user.email)}</b> · <span class="pill ${opts.user.plan}">${opts.user.plan}</span> · <a href="/dashboard">Dashboard</a>${opts.user.role === 'admin' ? ' · <a href="/admin">Admin</a> · <a href="/admin/leads">Leads</a>' : ''} · <form action="/logout" method="post" style="display:inline"><button class="link" type="submit">Sign out</button></form></div>`
+    : `<div class="topnav-user"><a href="/login">Sign in</a> · <a href="/signup">Sign up</a></div>`;
+  const monogram = `<svg viewBox="0 0 28 28" width="28" height="28" aria-hidden="true" style="flex:0 0 28px"><defs><linearGradient id="cb-portal" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#d4b683"/><stop offset="50%" stop-color="#b89968"/><stop offset="100%" stop-color="#8a7044"/></linearGradient></defs><circle cx="14" cy="14" r="13" fill="none" stroke="url(#cb-portal)" stroke-width="1"/><path fill="url(#cb-portal)" d="M9.6 9.5c-1.6 0-2.7 1.2-2.7 3v3c0 1.8 1.1 3 2.7 3 1.4 0 2.4-.8 2.6-2.1h-1.3c-.1.6-.6 1-1.3 1-.9 0-1.4-.6-1.4-1.7v-2.4c0-1.1.5-1.7 1.4-1.7.7 0 1.2.4 1.3 1h1.3c-.2-1.3-1.2-2.1-2.6-2.1zm5.5.1v8.7h2.7c1.6 0 2.6-.9 2.6-2.4 0-1-.5-1.7-1.3-2 .7-.3 1.1-1 1.1-1.8 0-1.5-1-2.5-2.5-2.5h-2.6zm1.3 1.1h1.2c.8 0 1.4.5 1.4 1.4 0 .9-.5 1.4-1.4 1.4h-1.2v-2.8zm0 3.9h1.4c.9 0 1.4.6 1.4 1.5s-.5 1.5-1.4 1.5h-1.4V14.6z"/></svg>`;
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · Counsel &amp; Bar</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:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+  :root{
+    --noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;
+    --rule:#2a2724;--rule-faint:#1a1815;
+    --ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;
+    --metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;
+    --good:#34d399;--warn:#d4a04a;--bad:#d87a7a;
+    --serif:"Cormorant Garamond","Cormorant","Georgia",serif;
+    --sans:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
+  }
+  *{box-sizing:border-box}
+  html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
+  body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55}
+  ::selection{background:var(--metal);color:var(--noir)}
+  a{color:var(--metal);text-decoration:none;transition:color 180ms ease}
+  a:hover{color:var(--metal-glow)}
+  header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
+  header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400;letter-spacing:.01em}
+  header.brand h1 a{color:var(--ink)}
+  header.brand h1 .amp{color:var(--metal);font-style:italic;padding:0 4px}
+  .topnav-user{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
+  .topnav-user b{color:var(--ink);font-weight:500}
+  .topnav-user a{color:var(--ink-mute);text-transform:uppercase}
+  .topnav-user a:hover{color:var(--ink)}
+  main{max-width:980px;margin:0 auto;padding:64px 48px}
+  main.wide{max-width:1280px}
+  h2{margin:0 0 28px;font-family:var(--serif);font-weight:300;font-size:clamp(34px,5vw,52px);line-height:1.1;letter-spacing:-.015em;color:var(--ink)}
+  h2 em{font-style:italic;color:var(--metal);font-weight:400}
+  h3{margin:32px 0 12px;font-family:var(--serif);font-weight:400;font-size:22px;color:var(--ink);letter-spacing:-.005em}
+  p.lede{font-size:17px;color:var(--ink-soft);margin:-16px 0 32px;max-width:60ch;font-weight:300}
+  form.stack{display:flex;flex-direction:column;gap:14px;max-width:480px}
+  input,select,textarea,button{font:inherit;font-family:var(--sans);color:var(--ink);background:var(--noir-rise);border:1px solid var(--rule);border-radius:0;padding:13px 16px;transition:border-color 180ms ease, box-shadow 180ms ease}
+  input:focus,select:focus,textarea:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
+  button{background:var(--metal);border-color:var(--metal);color:var(--noir);font-weight:500;font-size:12px;letter-spacing:.18em;text-transform:uppercase;padding:14px 24px;cursor:pointer;transition:background 220ms ease,transform 220ms ease}
+  button:hover{background:var(--metal-glow);border-color:var(--metal-glow);transform:translateY(-1px)}
+  button.danger{background:transparent;border-color:#5a2222;color:var(--bad)}
+  button.danger:hover{background:#2a0e0e;color:var(--bad);transform:none}
+  button.warn{background:transparent;border-color:#5a4520;color:var(--warn)}
+  button.warn:hover{background:#2a2010;color:var(--warn);transform:none}
+  button.link{background:none;border:none;padding:0;color:var(--metal);cursor:pointer;text-decoration:none;font:inherit;font-size:11px;letter-spacing:.14em;text-transform:uppercase}
+  button.link:hover{color:var(--metal-glow);transform:none}
+  table{width:100%;border-collapse:collapse;font-size:13px}
+  th,td{padding:14px 12px;border-bottom:1px solid var(--rule);text-align:left;vertical-align:middle}
+  th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
+  tr:hover td{background:var(--rule-faint)}
+  .pill{display:inline-block;font-size:10px;padding:3px 9px;border-radius:0;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
+  .pill.active{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+  .pill.suspended{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
+  .pill.deleted{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+  .pill.admin{background:#1a1a2a;color:#a5a0d4;border-color:#2a2a4a}
+  .pill.pro{background:#0e2a26;color:#7fd1c4;border-color:#1a4a44}
+  .pill.premium{background:#2a2010;color:var(--metal-glow);border-color:#4a3a1a}
+  .pill.free{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
+  .flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
+  .flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+  .flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+  .muted{color:var(--ink-mute);font-size:13px}
+  .actions{display:flex;gap:8px;flex-wrap:wrap}
+  .actions form{display:inline}
+  .actions button{padding:7px 12px;font-size:10px;letter-spacing:.12em}
+  .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0;margin:24px 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
+  .stat{padding:24px 20px;border-right:1px solid var(--rule)}
+  .stat:last-child{border-right:0}
+  .stat .num{font-family:var(--serif);font-weight:300;font-size:32px;line-height:1;color:var(--metal);font-variant-numeric:tabular-nums;margin-bottom:8px}
+  .stat .lbl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute)}
+  @media (max-width:720px){header.brand{padding:16px 20px;gap:10px;flex-wrap:wrap}main{padding:36px 20px}.stat{border-right:0;border-bottom:1px solid var(--rule)}}
+  @media (prefers-reduced-motion: no-preference){
+    .scroll-reveal{opacity:0;transform:translateY(22px);transition:opacity 900ms cubic-bezier(.16,.84,.3,1),transform 900ms cubic-bezier(.16,.84,.3,1)}
+    .scroll-reveal.in-view{opacity:1;transform:none}
+  }
+
+*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(184,153,104,.45);border-radius:1px}
+*:focus:not(:focus-visible){outline:none}
+
+</style>
+</head><body>
+<header class="brand">
+  <a href="/" style="display:inline-flex;align-items:center;gap:14px;text-decoration:none">${monogram}<h1 style="margin:0"><span style="color:var(--ink)">Counsel</span> <span class="amp">&amp;</span> <span style="color:var(--ink)">Bar</span></h1></a>
+  ${userBar}
+</header>
+<main>${body}</main>
+<script>
+(function(){
+  if(!('IntersectionObserver' in window))return;
+  if(window.matchMedia('(prefers-reduced-motion: reduce)').matches){
+    document.querySelectorAll('.scroll-reveal').forEach(function(el){el.classList.add('in-view');});return;
+  }
+  var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add('in-view');io.unobserve(e.target);}});},{threshold:0.12,rootMargin:'0px 0px -10% 0px'});
+  document.querySelectorAll('.scroll-reveal').forEach(function(el){io.observe(el);});
+})();
+</script>
+</body></html>`;
+}
+
+function esc(s: any): string {
+  if (s === null || s === undefined) return '';
+  return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
+}
+
+// Same-origin path allowlist for post-auth redirects: must start with a single
+// '/' and not '//' (which the browser interprets as protocol-relative).
+function safeNext(raw: any, fallback = '/dashboard'): string {
+  if (typeof raw !== 'string' || raw.length === 0) return fallback;
+  if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return fallback;
+  return raw;
+}
+
+function flash(req: express.Request): string {
+  const msg = (req.query.msg as string) || '';
+  const err = (req.query.err as string) || '';
+  if (err) return `<div class="flash error">${esc(err)}</div>`;
+  if (msg) return `<div class="flash success">${esc(msg)}</div>`;
+  return '';
+}
+
+// ─── public auth pages ──────────────────────────────────────────────────────
+
+router.get('/signup', (req, res) => {
+  if (req.user) return res.redirect('/dashboard');
+  const next = safeNext(req.query.next);
+  res.send(layout('Claim your listing', `
+    <div class="signup-vol">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · Attorney Listing</span>
+    </div>
+    <h2 class="signup-display">Claim your <em>listing.</em></h2>
+    <p class="signup-lede">Type your name. We check it against the active California State Bar roll — pick yourself, and your firm's profile is pre-filled. Verify what's accurate, edit what isn't, see your public booking page before you publish.</p>
+    <p class="step-eyebrow"><em>i</em> &nbsp;·&nbsp; Find yourself in the registry</p>
+
+    ${flash(req)}
+
+    <form class="stack big" method="post" action="/signup" autocomplete="off" id="claim-form">
+      <input type="hidden" name="next" value="${esc(next)}">
+
+      <!-- Step 1: name -->
+      <label class="big-lbl" style="position:relative">
+        Your Name
+        <input class="big-in" id="full_name" name="full_name" placeholder="Start typing your name — we'll match the CalBar registry" required autocomplete="off">
+        <div id="ac-list"></div>
+      </label>
+      <input type="hidden" id="bar_number" name="bar_number">
+
+      <!-- Step 2 + 3: details + sample booking — hidden until name is picked -->
+      <div id="step-confirm" class="step-hidden">
+        <hr class="step-rule">
+        <p class="step-eyebrow"><em>ii</em> &nbsp;·&nbsp; Confirm your firm</p>
+        <h3 class="step-h3">Is this still right?</h3>
+        <p class="muted" style="margin:0 0 24px">We pulled this from the State Bar listing. Edit anything that's stale.</p>
+
+        <label class="big-lbl">
+          Firm Name
+          <input class="big-in" id="firm_name" name="firm_name" placeholder="Law Office of Jane Doe" autocomplete="organization">
+        </label>
+
+        <label class="big-lbl" style="margin-top:18px">
+          Website <span style="text-transform:none;letter-spacing:0;color:var(--ink-mute);font-size:11px;font-weight:400">(visitors will tap-through to this)</span>
+          <input class="big-in" id="website" name="website" type="url" placeholder="https://janedoelaw.com" autocomplete="url">
+        </label>
+
+        <label class="big-lbl" style="margin-top:18px">
+          Phone
+          <input class="big-in" id="phone" name="phone" type="tel" placeholder="(415) 555-0100" autocomplete="tel">
+        </label>
+
+        <hr class="step-rule">
+        <p class="step-eyebrow"><em>iii</em> &nbsp;·&nbsp; Your public booking page (preview)</p>
+        <h3 class="step-h3">This is what visitors will see.</h3>
+        <p class="muted" style="margin:0 0 24px">A polished Zoom-consult page on <code>lawyers.agentabrams.com/<span id="prv-slug">jane-doe</span></code> — included with every paid listing. We provision the calendar, the video link, and Stripe payouts.</p>
+
+        <div class="zoom-preview scroll-reveal">
+          <div class="zp-header">
+            <div class="zp-avatar"></div>
+            <div>
+              <div class="zp-name" id="prv-name">Jane Doe, Esq.</div>
+              <div class="zp-firm" id="prv-firm">Law Office of Jane Doe</div>
+              <div class="zp-loc" id="prv-loc">California State Bar #000000 · Verified</div>
+            </div>
+          </div>
+          <div class="zp-divider"></div>
+          <div class="zp-body">
+            <div class="zp-col">
+              <div class="zp-eyebrow">30-minute consult</div>
+              <div class="zp-price">$150</div>
+              <div class="zp-detail">By Zoom · No-show refund · No signup required for client</div>
+              <button type="button" class="zp-cta" disabled>Book — Tue, 2:30 PM</button>
+              <div class="zp-disclaimer">Sample. Real booking goes live on first paid listing.</div>
+            </div>
+            <div class="zp-col zp-cal">
+              <div class="zp-eyebrow">Available this week</div>
+              <div class="zp-cal-grid">
+                <div class="zp-day"><div class="zp-d-name">Mon</div><div class="zp-d-num">5</div><div class="zp-slots">3 open</div></div>
+                <div class="zp-day zp-d-active"><div class="zp-d-name">Tue</div><div class="zp-d-num">6</div><div class="zp-slots">2 open</div></div>
+                <div class="zp-day"><div class="zp-d-name">Wed</div><div class="zp-d-num">7</div><div class="zp-slots">5 open</div></div>
+                <div class="zp-day"><div class="zp-d-name">Thu</div><div class="zp-d-num">8</div><div class="zp-slots">1 open</div></div>
+                <div class="zp-day zp-d-empty"><div class="zp-d-name">Fri</div><div class="zp-d-num">9</div><div class="zp-slots">—</div></div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <hr class="step-rule">
+        <p class="step-eyebrow"><em>iv</em> &nbsp;·&nbsp; Create your account</p>
+
+        <label class="big-lbl" style="margin-top:8px">
+          Email
+          <input class="big-in" name="email" type="email" placeholder="you@firm.com" required autocomplete="email">
+        </label>
+
+        <label class="big-lbl" style="margin-top:18px">
+          Password
+          <input class="big-in" name="password" type="password" placeholder="Min 8 characters" required minlength="8" autocomplete="new-password">
+        </label>
+
+        <button class="big-btn" type="submit" style="margin-top:28px">Claim your listing →</button>
+      </div>
+    </form>
+
+    <p class="muted" style="margin-top:24px">Already claimed? <a href="/login">Sign in</a></p>
+
+    <style>
+      form.stack.big{gap:22px;max-width:720px}
+      .signup-vol{display:flex;align-items:center;gap:18px;margin:0 0 26px;color:var(--metal)}
+      .signup-vol .v-rule{flex:0 0 56px;height:1px;background:linear-gradient(90deg,transparent,var(--metal) 50%,transparent)}
+      .signup-vol .v-text{font-family:var(--serif);font-style:italic;font-weight:400;font-size:14px;letter-spacing:.02em}
+      .signup-vol .v-meta{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--ink-mute);font-weight:500}
+      .signup-display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6.2vw,72px);line-height:1.04;letter-spacing:-.018em;margin:0 0 22px;max-width:18ch;color:var(--ink)}
+      .signup-display em{font-style:italic;color:var(--metal);font-weight:400}
+      .signup-lede{color:var(--ink-soft);font-size:17px;line-height:1.6;margin:0 0 56px;max-width:62ch;font-weight:300}
+      .step-eyebrow{font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 16px;display:flex;align-items:center;gap:0}
+      .step-eyebrow em{font-family:var(--serif);font-style:italic;font-weight:400;color:transparent;-webkit-text-stroke:1px var(--metal);font-size:32px;line-height:1;letter-spacing:-.02em;text-transform:none;margin-right:14px}
+      .step-h3{font-family:var(--serif);font-weight:400;font-size:24px;margin:0 0 8px;color:var(--ink);letter-spacing:-.005em}
+      .step-rule{border:none;border-top:1px solid var(--rule);margin:48px 0 32px}
+      .step-hidden{opacity:.35;filter:blur(2px);pointer-events:none;transition:opacity 600ms ease,filter 600ms ease;max-height:60px;overflow:hidden}
+      .step-hidden.revealed{opacity:1;filter:none;pointer-events:auto;max-height:none;overflow:visible}
+      .big-lbl{display:flex;flex-direction:column;gap:8px;font-size:10px;color:var(--ink-mute);font-weight:500;text-transform:uppercase;letter-spacing:.22em}
+      .big-in{padding:16px 18px!important;font-size:16px!important;background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);font-family:var(--sans);font-weight:300}
+      .big-in:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
+      .big-btn{padding:20px 36px!important;font-size:12px!important;letter-spacing:.18em;text-transform:uppercase;width:auto;align-self:flex-start;background:linear-gradient(180deg,#d4b683 0%,#b89968 38%,#9c7e4f 100%);color:var(--noir);border:1px solid var(--metal);box-shadow:inset 0 1px 0 rgba(255,235,200,.35),0 1px 0 rgba(0,0,0,.4)}
+      .big-btn:hover{background:linear-gradient(180deg,#e3c89a 0%,#c8a674 50%,#a98559 100%);border-color:var(--metal-glow);transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,240,210,.5),0 4px 18px rgba(184,153,104,.18)}
+      #ac-list{position:absolute;left:0;right:0;top:100%;z-index:10;background:var(--noir-rise);border:1px solid var(--rule);margin-top:2px;max-height:320px;overflow:auto;display:none;text-transform:none;letter-spacing:0}
+      .ac-row{padding:12px 16px;cursor:pointer;border-bottom:1px solid var(--rule);font-size:13px;color:var(--ink);font-weight:300}
+      .ac-row:last-child{border-bottom:none}
+      .ac-row:hover,.ac-row.sel{background:var(--rule-faint)}
+      .ac-row .nm{font-weight:500}
+      .ac-row .meta{color:var(--ink-mute);font-size:12px;margin-top:3px;font-style:italic}
+      .ac-row .pill{font-size:9px;padding:2px 7px;border-radius:0;background:transparent;border:1px solid var(--rule);color:var(--ink-mute);margin-left:8px;letter-spacing:.14em}
+
+      /* ── Zoom-consult preview (step 03) ── */
+      .zoom-preview{background:var(--noir-rise);border:1px solid var(--rule);padding:32px;margin:0 0 8px;position:relative}
+      .zoom-preview::before{content:'PREVIEW';position:absolute;top:-1px;right:24px;background:var(--noir);color:var(--metal);padding:6px 14px;font-size:9px;letter-spacing:.3em;border:1px solid var(--rule);border-top:none}
+      .zp-header{display:flex;gap:20px;align-items:center}
+      .zp-avatar{width:64px;height:64px;background:linear-gradient(135deg,var(--metal-deep),var(--metal-glow));border-radius:50%;flex-shrink:0;border:1px solid var(--metal)}
+      .zp-name{font-family:var(--serif);font-size:24px;font-weight:400;color:var(--ink);letter-spacing:-.005em;line-height:1.1}
+      .zp-firm{font-size:13px;color:var(--ink-soft);margin-top:4px}
+      .zp-loc{font-size:11px;color:var(--ink-mute);margin-top:4px;letter-spacing:.06em}
+      .zp-divider{height:1px;background:var(--rule);margin:24px 0}
+      .zp-body{display:grid;grid-template-columns:1fr 1.2fr;gap:32px}
+      @media (max-width:640px){.zp-body{grid-template-columns:1fr}}
+      .zp-eyebrow{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:10px}
+      .zp-price{font-family:var(--serif);font-size:48px;font-weight:300;color:var(--metal);line-height:1;margin-bottom:8px;letter-spacing:-.02em}
+      .zp-detail{font-size:12px;color:var(--ink-soft);margin-bottom:18px;line-height:1.5}
+      .zp-cta{padding:14px 24px;background:var(--metal);color:var(--noir);border:1px solid var(--metal);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;width:100%;cursor:not-allowed;opacity:.85}
+      .zp-disclaimer{font-size:10px;color:var(--ink-mute);font-style:italic;margin-top:10px;letter-spacing:.06em}
+      .zp-cal-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
+      .zp-day{padding:14px 8px;border:1px solid var(--rule);text-align:center;background:var(--noir-deep);transition:border-color 180ms ease}
+      .zp-day:hover{border-color:var(--metal-deep)}
+      .zp-d-name{font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
+      .zp-d-num{font-family:var(--serif);font-size:22px;color:var(--ink);font-weight:300;margin:6px 0 4px}
+      .zp-slots{font-size:10px;color:var(--metal);letter-spacing:.04em}
+      .zp-d-active{border-color:var(--metal);background:rgba(184,153,104,0.08)}
+      .zp-d-active .zp-d-num{color:var(--metal-glow)}
+      .zp-d-empty .zp-slots{color:var(--ink-mute);opacity:.5}
+    </style>
+    <script>
+    (function(){
+      var inp = document.getElementById('full_name');
+      var bar = document.getElementById('bar_number');
+      var firm = document.getElementById('firm_name');
+      var web = document.getElementById('website');
+      var phone = document.getElementById('phone');
+      var list = document.getElementById('ac-list');
+      var t, sel = -1, items = [];
+      function render(rows){
+        items = rows;
+        if (!rows.length) { list.style.display = 'none'; list.innerHTML = ''; return; }
+        list.innerHTML = rows.map(function(r, i){
+          var meta = r.firm_name ? '<span class="meta">' + esc(r.firm_name) + (r.city ? ' · ' + esc(r.city) : '') + '</span>' : '';
+          var st = r.license_status ? '<span class="pill">' + esc(r.license_status) + '</span>' : '';
+          return '<div class="ac-row" data-i="' + i + '"><div class="nm">' + esc(r.full_name) + ' #' + esc(r.bar_number) + st + '</div>' + meta + '</div>';
+        }).join('');
+        list.style.display = 'block';
+        sel = -1;
+        Array.prototype.forEach.call(list.querySelectorAll('.ac-row'), function(el){
+          el.addEventListener('mousedown', function(e){ e.preventDefault(); pick(parseInt(el.dataset.i, 10)); });
+        });
+      }
+      function pick(i){
+        var r = items[i];
+        if (!r) return;
+        inp.value = r.full_name;
+        if (r.bar_number) bar.value = r.bar_number;
+        if (r.firm_name && !firm.value) firm.value = r.firm_name;
+        if (r.firm_website && !web.value) web.value = r.firm_website;
+        if (r.firm_phone && !phone.value) phone.value = r.firm_phone;
+        list.style.display = 'none';
+        // Reveal Step 2/3/4 + populate the booking-page preview
+        var step = document.getElementById('step-confirm');
+        if (step) step.classList.add('revealed');
+        var prvName = document.getElementById('prv-name');
+        var prvFirm = document.getElementById('prv-firm');
+        var prvLoc  = document.getElementById('prv-loc');
+        var prvSlug = document.getElementById('prv-slug');
+        if (prvName) prvName.textContent = r.full_name + ', Esq.';
+        if (prvFirm) prvFirm.textContent = r.firm_name || '';
+        if (prvLoc)  prvLoc.textContent  = 'California State Bar #' + r.bar_number + ' · Verified';
+        if (prvSlug) prvSlug.textContent = r.full_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+        // Smooth-scroll a little so the new section is visible
+        setTimeout(function(){ step.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 320);
+      }
+      function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]; }); }
+      inp.addEventListener('input', function(){
+        clearTimeout(t);
+        var q = inp.value.trim();
+        if (q.length < 3) { list.style.display = 'none'; return; }
+        t = setTimeout(function(){
+          fetch('/api/professionals/search?q=' + encodeURIComponent(q))
+            .then(function(r){ return r.json(); })
+            .then(function(j){ render(j.rows || []); })
+            .catch(function(){ list.style.display = 'none'; });
+        }, 180);
+      });
+      inp.addEventListener('keydown', function(e){
+        if (list.style.display === 'none') return;
+        var rows = list.querySelectorAll('.ac-row');
+        if (e.key === 'ArrowDown'){ e.preventDefault(); sel = Math.min(rows.length-1, sel+1); }
+        else if (e.key === 'ArrowUp'){ e.preventDefault(); sel = Math.max(0, sel-1); }
+        else if (e.key === 'Enter' && sel >= 0){ e.preventDefault(); pick(sel); return; }
+        else if (e.key === 'Escape'){ list.style.display = 'none'; return; }
+        else return;
+        Array.prototype.forEach.call(rows, function(el, i){ el.classList.toggle('sel', i === sel); });
+      });
+      document.addEventListener('click', function(e){ if (!list.contains(e.target) && e.target !== inp) list.style.display = 'none'; });
+    })();
+    </script>
+  `, { user: req.user }));
+});
+
+router.post('/signup', async (req, res) => {
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const { email, password, full_name, bar_number, firm_name, website, phone, next } = req.body as any;
+  try {
+    if (!email || !password || password.length < 8) throw new Error('Email and 8+ char password required');
+    const existing = await findUserByEmail(email);
+    if (existing) return res.redirect('/signup?err=' + encodeURIComponent('Account already exists — try logging in'));
+    const userId = await createUser({
+      email, password, full_name, bar_number,
+      firm_name: firm_name || null,
+      website: website ? String(website).trim().slice(0, 250) : null,
+      phone: phone ? String(phone).trim().slice(0, 40) : null,
+    });
+    const sid = await createSession(userId, req.ip || null, req.headers['user-agent'] || null);
+    setSessionCookie(res, sid);
+    res.redirect(safeNext(next));
+  } catch (e) {
+    res.redirect('/signup?err=' + encodeURIComponent((e as Error).message));
+  }
+});
+
+router.get('/login', (req, res) => {
+  if (req.user) return res.redirect('/dashboard');
+  const next = safeNext(req.query.next);
+  res.send(layout('Login', `
+    <div class="signup-vol">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · Sign In</span>
+    </div>
+    <h2 class="signup-display" style="font-size:clamp(36px,5vw,56px);margin:0 0 36px;max-width:14ch">Welcome <em>back.</em></h2>
+    ${flash(req)}
+    <form class="stack" method="post" action="/login" style="max-width:480px">
+      <input type="hidden" name="next" value="${esc(next)}">
+      <input name="email" type="email" placeholder="Email" required autocomplete="email">
+      <input name="password" type="password" placeholder="Password" required autocomplete="current-password">
+      <button class="big-btn" type="submit" style="margin-top:8px;width:auto;align-self:flex-start">Sign in →</button>
+    </form>
+    <p class="muted" style="margin-top:24px">No account yet? <a href="/signup">Claim your listing</a></p>
+  `, { user: req.user }));
+});
+
+router.post('/login', async (req, res) => {
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const { email, password, next } = req.body as any;
+  try {
+    const u = await findUserByEmail(email);
+    if (!u || u.status !== 'active') throw new Error('Invalid email or password');
+    const ok = await verifyPassword(password, u.password_hash);
+    if (!ok) throw new Error('Invalid email or password');
+    const sid = await createSession(u.id, req.ip || null, req.headers['user-agent'] || null);
+    setSessionCookie(res, sid);
+    res.redirect(safeNext(next));
+  } catch (e) {
+    res.redirect('/login?err=' + encodeURIComponent((e as Error).message));
+  }
+});
+
+router.post('/logout', async (req, res) => {
+  const sid = cookie.parse(req.headers.cookie || '').sid;
+  if (sid) await destroySession(sid);
+  clearSessionCookie(res);
+  res.redirect('/login?msg=' + encodeURIComponent('Logged out'));
+});
+
+// ─── user dashboard ─────────────────────────────────────────────────────────
+
+router.get('/dashboard', requireUser, async (req, res) => {
+  const u = req.user!;
+  let firmInfo = '';
+  if (u.organization_id) {
+    const r = await query<{ name: string; address: string; city: string; website: string | null }>(
+      `SELECT name, address, city, website FROM organizations WHERE id = $1`, [u.organization_id]);
+    if (r.rows[0]) {
+      const f = r.rows[0];
+      firmInfo = `<div class="stat" style="grid-column:1/-1">
+        <div class="lbl">Linked firm</div>
+        <div style="font-size:16px;font-weight:600;margin:4px 0">${esc(f.name)}</div>
+        <div class="muted">${esc(f.address)}, ${esc(f.city)}${f.website ? ` · <a href="${esc(f.website)}" target="_blank">${esc(f.website)}</a>` : ''}</div>
+      </div>`;
+    }
+  }
+  const planBadge = `<span class="pill ${u.plan}">${u.plan}</span>`;
+  res.send(layout('Dashboard', `
+    <div class="signup-vol">
+      <span class="v-rule"></span>
+      <span class="v-text">Volume I</span>
+      <span class="v-meta">Edition 2026 · Practice Dashboard</span>
+    </div>
+    <h2 class="signup-display" style="font-size:clamp(36px,5vw,56px);max-width:18ch;margin:0 0 32px">Welcome ${u.full_name ? `<em>${esc(u.full_name.split(' ')[0])}.</em>` : '<em>back.</em>'}</h2>
+    ${flash(req)}
+    <div class="stats scroll-reveal">
+      <div class="stat"><div class="lbl">Plan</div><div class="num" style="font-size:18px">${planBadge}</div></div>
+      <div class="stat"><div class="lbl">Status</div><div class="num" style="font-size:18px"><span class="pill ${u.status}">${u.status}</span></div></div>
+      ${u.bar_number ? `<div class="stat"><div class="lbl">CA Bar #</div><div class="num">${esc(u.bar_number)}</div></div>` : ''}
+      ${firmInfo}
+    </div>
+
+    ${(u as any).tier === 'lawyer' || (u as any).tier === 'admin' ? `
+    <h3>Lawyer Pro · <em style="color:var(--good);font-style:italic">active</em></h3>
+    <p class="muted">You have Lawyer Pro. Respond to reviews, DM clients, get lead alerts.</p>
+    <p><a href="/api/billing/portal" style="font-size:13px">Manage billing in Stripe →</a></p>
+    ` : `
+    <h3>Upgrade to Lawyer <em>Pro</em></h3>
+    <p class="lede" style="font-size:15px;margin:0 0 18px;max-width:54ch">Respond to client reviews. Send DMs through the platform. Get notified the moment a matched lead is submitted in your practice area + ZIP.</p>
+    <div style="border:1px solid var(--rule);background:var(--noir-rise);padding:24px 28px;max-width:480px;margin:0 0 24px">
+      <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:14px">
+        <div style="font-family:var(--serif);font-size:42px;color:var(--metal);font-weight:300;line-height:1">$29</div>
+        <div class="muted" style="font-size:13px">/ month · cancel anytime</div>
+      </div>
+      <ul style="list-style:none;padding:0;margin:0 0 20px;font-size:13px;color:var(--ink-soft)">
+        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Reply publicly to client reviews on your profile</li>
+        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Direct messages with potential clients</li>
+        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Real-time lead alerts (practice area + ZIP)</li>
+        <li style="padding:6px 0;border-bottom:1px solid var(--rule-faint)"><span style="color:var(--metal)">✓</span> Edit firm profile, photos, hours</li>
+        <li style="padding:6px 0"><span style="color:var(--metal)">✓</span> Stripe-managed · cancel from this dashboard</li>
+      </ul>
+      <button id="upgradeProBtn" class="big-btn" type="button" style="width:100%;align-self:auto">Start Lawyer Pro →</button>
+      <p id="upgradeProErr" style="display:none;color:var(--bad);font-size:12px;margin-top:10px"></p>
+    </div>
+    <script>
+      document.getElementById('upgradeProBtn').addEventListener('click', async () => {
+        const btn = document.getElementById('upgradeProBtn');
+        const err = document.getElementById('upgradeProErr');
+        btn.disabled = true; btn.textContent = 'Redirecting to Stripe…'; err.style.display='none';
+        try {
+          const r = await fetch('/api/billing/upgrade', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
+          const j = await r.json();
+          if (j.url) { window.location = j.url; return; }
+          throw new Error(j.error || 'unknown error');
+        } catch (e) {
+          err.textContent = 'Could not start checkout: ' + (e && e.message ? e.message : e);
+          err.style.display='block';
+          btn.disabled = false; btn.textContent = 'Start Lawyer Pro →';
+        }
+      });
+    </script>
+    `}
+
+    <h3>One-time site rebuild · <em>EZ Upgrade $499</em></h3>
+    <p class="muted" style="max-width:54ch">A complete site rebuild on the Counsel &amp; Bar template, live in 7 days. <a href="/upgrade">View details →</a></p>
+
+    ${u.role === 'admin' || (u as any).tier === 'admin' ? '<h3>Admin</h3><p>You have admin access. <a href="/admin">Open admin panel →</a></p>' : ''}
+  `, { user: u }));
+});
+
+// ─── admin panel ────────────────────────────────────────────────────────────
+
+router.get('/admin', requireAdmin, async (req, res) => {
+  const search = ((req.query.q as string) || '').trim();
+  const where = search
+    ? `WHERE (LOWER(email) LIKE $1 OR LOWER(COALESCE(full_name,'')) LIKE $1 OR bar_number = $2)`
+    : '';
+  const params = search ? [`%${search.toLowerCase()}%`, search] : [];
+  const r = await query(`
+    SELECT id, email, full_name, role, plan, status, bar_number, organization_id, professional_id,
+           created_at, last_login_at
+    FROM app_users
+    ${where}
+    ORDER BY id DESC
+    LIMIT 200
+  `, params);
+
+  const counts = await query<{ k: string; v: string }>(`
+    SELECT 'total' AS k, COUNT(*)::text AS v FROM app_users
+    UNION ALL SELECT 'active', COUNT(*)::text FROM app_users WHERE status='active'
+    UNION ALL SELECT 'paying', COUNT(*)::text FROM app_users WHERE plan IN ('premium','pro') AND status='active'
+    UNION ALL SELECT 'admins', COUNT(*)::text FROM app_users WHERE role='admin'
+  `);
+  const cm: Record<string, string> = {};
+  for (const row of counts.rows) cm[row.k] = row.v;
+
+  const rows = r.rows.map((u: any) => {
+    const plans = PLANS.map(p => `<option value="${p}"${u.plan === p ? ' selected' : ''}>${p}</option>`).join('');
+    const actions = `
+      <form method="post" action="/admin/users/${u.id}/plan">
+        <select name="plan" onchange="this.form.submit()">${plans}</select>
+      </form>
+      <form method="post" action="/admin/users/${u.id}/${u.status === 'suspended' ? 'unsuspend' : 'suspend'}" onsubmit="return confirm('${u.status === 'suspended' ? 'Reactivate' : 'Suspend'} this user?')">
+        <button class="warn" type="submit">${u.status === 'suspended' ? 'Activate' : 'Suspend'}</button>
+      </form>
+      <form method="post" action="/admin/users/${u.id}/delete" onsubmit="return confirm('PERMANENTLY DELETE this user? This cannot be undone.')">
+        <button class="danger" type="submit">Delete</button>
+      </form>
+    `;
+    const last = u.last_login_at ? new Date(u.last_login_at).toISOString().slice(0, 10) : '—';
+    const created = new Date(u.created_at).toISOString().slice(0, 10);
+    return `<tr>
+      <td>${u.id}</td>
+      <td><b>${esc(u.email)}</b><br><span class="muted">${esc(u.full_name || '')}</span></td>
+      <td><span class="pill ${u.role === 'admin' ? 'admin' : ''}">${u.role}</span></td>
+      <td><span class="pill ${u.status}">${u.status}</span></td>
+      <td>${esc(u.bar_number || '—')}</td>
+      <td>${esc(u.organization_id || '—')}</td>
+      <td class="muted">${created}</td>
+      <td class="muted">${last}</td>
+      <td><div class="actions">${actions}</div></td>
+    </tr>`;
+  }).join('');
+
+  res.send(layout('Admin', `
+    <h2>Admin · Users</h2>
+    ${flash(req)}
+    <div class="stats scroll-reveal">
+      <div class="stat"><div class="num">${cm.total || 0}</div><div class="lbl">Total users</div></div>
+      <div class="stat"><div class="num">${cm.active || 0}</div><div class="lbl">Active</div></div>
+      <div class="stat"><div class="num">${cm.paying || 0}</div><div class="lbl">Premium + Pro</div></div>
+      <div class="stat"><div class="num">${cm.admins || 0}</div><div class="lbl">Admins</div></div>
+    </div>
+
+    <form method="get" action="/admin" style="display:flex;gap:8px;margin:18px 0">
+      <input name="q" placeholder="Search email, name, or bar #" value="${esc(search)}" style="flex:1">
+      <button type="submit">Search</button>
+      ${search ? `<a href="/admin" style="padding:9px 11px;border:1px solid var(--line);border-radius:6px">Clear</a>` : ''}
+    </form>
+
+    <table>
+      <thead><tr><th>ID</th><th>User</th><th>Role</th><th>Status</th><th>Bar #</th><th>Firm ID</th><th>Created</th><th>Last login</th><th>Actions</th></tr></thead>
+      <tbody>${rows || '<tr><td colspan="9" class="muted" style="padding:30px;text-align:center">No users yet</td></tr>'}</tbody>
+    </table>
+  `, { user: req.user }));
+});
+
+router.post('/admin/users/:id/plan', requireAdmin, async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!req.body || typeof req.body !== 'object') {
+    return res.status(400).send('Invalid request body');
+  }
+  const plan = (req.body.plan as string)?.trim();
+  if (!PLANS.includes(plan as any)) return res.redirect('/admin?err=' + encodeURIComponent('Invalid plan'));
+  await query(`UPDATE app_users SET plan = $2 WHERE id = $1`, [id, plan]);
+  res.redirect('/admin?msg=' + encodeURIComponent(`Plan updated to ${plan}`));
+});
+
+router.post('/admin/users/:id/suspend', requireAdmin, async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (id === req.user!.id) return res.redirect('/admin?err=' + encodeURIComponent("Can't suspend yourself"));
+  await query(`UPDATE app_users SET status = 'suspended' WHERE id = $1`, [id]);
+  await query(`DELETE FROM app_sessions WHERE user_id = $1`, [id]);
+  res.redirect('/admin?msg=' + encodeURIComponent('User suspended'));
+});
+
+router.post('/admin/users/:id/unsuspend', requireAdmin, async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  await query(`UPDATE app_users SET status = 'active' WHERE id = $1`, [id]);
+  res.redirect('/admin?msg=' + encodeURIComponent('User reactivated'));
+});
+
+router.post('/admin/users/:id/delete', requireAdmin, async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (id === req.user!.id) return res.redirect('/admin?err=' + encodeURIComponent("Can't delete yourself"));
+  // Hard delete — sessions + user. Cascades via FK. Records linking to organizations stay (set null on user side).
+  await query(`DELETE FROM app_users WHERE id = $1`, [id]);
+  res.redirect('/admin?msg=' + encodeURIComponent('User deleted'));
+});
+
+export default router;

← 331776b Dashboard: add professionals + phones + emails counters; new  ·  back to Lawyer Directory Builder  ·  Loop housekeeping: migrations, ingest scripts, compliance do 03e8b75 →